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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ overwrite user-modified agents.
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | Managed `AGENTS.md` activation pointer, skill, three read-only agents; `SessionStart` + `SubagentStart`, `PreToolUse` graph lookup for `Bash` searches |
| 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; plugin adds grep/glob graph lookup, post-`read` coverage, first-tool-result session context, and post-compaction reinjection |
| 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 on OpenCode 1.18.x |
| Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` |
| Aider | Detected | none | `CONVENTIONS.md` via `.aider.conf.yml` |
| KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions |
Expand Down
228 changes: 168 additions & 60 deletions src/cli/client_adapter.c
Original file line number Diff line number Diff line change
Expand Up @@ -427,9 +427,16 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
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");
"// tier routing (carried on the first tool result of each session), and\n"
"// on OpenCode 1.18.x reinjection after compaction.\n"
"//\n"
"// Two runtimes load this module:\n"
"// - OpenCode 1.18.x dispatches the hooks returned by server(ctx).\n"
"// - OpenCode 2 ignores server and calls setup(ctx), which registers the\n"
"// tool hook on the context's tool domain. The registration fails open,\n"
"// and the server() tool hook yields once it has registered. OpenCode 2\n"
"// gets no note after compaction: no compaction channel has been run on\n"
"// a real 2.x host yet.\n");
sb_append(&sb, "import { spawn } from 'node:child_process';\n\n");
sb_append(&sb, "const BIN = '");
sb_append(&sb, bin);
Expand Down Expand Up @@ -481,64 +488,165 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
" });\n"
"}\n\n");

/* OpenCode 2 tool results are structured (Tool.Result: content is the
* string/parts surface the model reads, output is the tool's schema-typed
* value). Append to content first, then a string output; if neither text
* surface exists, leave the result untouched so the tool's own output is
* never hidden. A parts array gets a new text part only when it already
* holds a part: `content: []` is a valid result, and a part added there
* would be the note alone, so an empty array falls through to the string
* output (or the unchanged result). The caller assigns the returned result
* onto event.result. */
sb_append(&sb,
"function appendResult(result, extra) {\n"
" if (!result || typeof result !== 'object') return result;\n"
" if (typeof result.content === 'string') {\n"
" return { ...result, content: result.content + '\\n' + extra };\n"
" }\n"
" if (Array.isArray(result.content) && result.content.length > 0) {\n"
" const content = result.content.slice();\n"
" for (let i = content.length - 1; i >= 0; i--) {\n"
" if (content[i] && content[i].type === 'text') {\n"
" content[i] = { ...content[i], text: content[i].text + '\\n' + extra };\n"
" return { ...result, content };\n"
" }\n"
" }\n"
" content.push({ type: 'text', text: extra });\n"
" return { ...result, content };\n"
" }\n"
" if (typeof result.output === 'string') {\n"
" return { ...result, output: result.output + '\\n' + extra };\n"
" }\n"
" return result;\n"
"}\n\n");

/* A host that dispatches both server() and setup() with a tool domain must
* not append the note twice: the server() tool hook yields once setup()
* registered the tool domain. */
sb_append(&sb, "let toolHooksRegistered = false;\n\n");

sb_append(
&sb, "export default {\n"
" id: 'codebase-memory-augment',\n"
" // V2 config loader: requires id + setup|effect; no tool domain yet\n"
" // at this point, so there is nothing to register here.\n"
" setup() {},\n"
" // Server runtime: reads default.server and dispatches the hooks it returns.\n"
" server: 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 = input?.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"
"};\n");
&sb,
"export default {\n"
" id: 'codebase-memory-augment',\n"
" // OpenCode 1.18.x server runtime: reads default.server and dispatches\n"
" // the hooks it returns.\n"
" server: 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 (toolHooksRegistered) return;\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 = input?.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"
" // OpenCode 2 runtime: default.server is ignored and setup(ctx) registers\n"
" // the tool hook on the context's tool domain. The registration fails\n"
" // open, so a missing domain cannot break the host.\n"
" async setup(ctx) {\n"
" const seen = new Set();\n"
" const dirs = new Map();\n"
" const dirFor = async (sessionID) => {\n"
" if (typeof sessionID !== 'string' || sessionID === '') return undefined;\n"
" if (dirs.has(sessionID)) return dirs.get(sessionID);\n"
" try {\n"
" const session = await ctx.session.get({ sessionID });\n"
" const dir = session?.location?.directory;\n"
" if (typeof dir === 'string' && dir !== '') {\n"
" dirs.set(sessionID, dir);\n"
" return dir;\n"
" }\n"
" } catch {}\n"
" return undefined;\n"
" };\n"
" const lifecycle = (dir) =>\n"
" augment({ hook_event_name: 'SessionStart', cwd: dir });\n"
" if (ctx?.tool?.hook) {\n"
" try {\n"
" await ctx.tool.hook('execute.after', async (event) => {\n"
" if (event?.status !== 'completed') return;\n"
" const dir = await dirFor(event?.sessionID);\n"
" if (!dir) return;\n"
" const pieces = [];\n"
" if (typeof event?.sessionID === 'string' && !seen.has(event.sessionID)) {\n"
" seen.add(event.sessionID);\n"
" pieces.push(await lifecycle(dir));\n"
" }\n"
" const args = event?.input ?? {};\n"
" const search =\n"
" event?.tool === 'grep' ? 'Grep' : event?.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 (event?.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"
" event.result = appendResult(event.result, extra);\n"
" }\n"
" });\n"
" toolHooksRegistered = true;\n"
" } catch {\n"
" // Fail open: the server() tool hook stays active.\n"
" }\n"
" }\n"
" },\n"
"};\n");

if (sb.failed) {
free(sb.buf);
Expand Down
17 changes: 12 additions & 5 deletions src/cli/client_adapter.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,18 @@ char *cbm_client_adapter_pi(const char *binary_path);
* other clients get through their own native hook configuration. OpenCode has
* no such configuration; a plugin module is its only extension point.
*
* NOTE for maintainers: this hooks `tool.execute.after`, whose ability to
* modify a tool's output is NOT part of OpenCode's documented plugin contract
* (only `tool.execute.before`'s argument mutation is). If OpenCode changes it,
* augmentation stops silently — no error, no signal. That risk is accepted
* deliberately and recorded here so it is not rediscovered as a mystery.
* NOTE for maintainers: the module serves two loader generations from one
* default export. OpenCode 1.18.x dispatches the hooks returned by
* `server(ctx)`; its `tool.execute.after` ability to modify a tool's output is
* NOT part of the documented plugin contract (only `tool.execute.before`'s
* argument mutation is), so if that runtime changes, augmentation stops
* silently — no error, no signal. That risk is accepted deliberately for the
* server() path and recorded here so it is not rediscovered as a mystery.
* OpenCode 2 ignores `server` and calls `setup(ctx)`, which registers
* `execute.after` on the context's tool domain and fails open. It registers no
* `compaction` hook: pushing a plain object into `event.messages` would fail,
* if at all, inside OpenCode's own compaction where the plugin cannot catch it,
* so a note after compaction needs a real 2.x run first.
*
* Returns NULL on allocation failure or when binary_path is NULL/empty. */
char *cbm_client_adapter_opencode(const char *binary_path);
Expand Down
Loading
Loading