Skip to content

Commit 1e3f2fd

Browse files
authored
Merge pull request DeusData#1487 from JCVIDev/feat/opencode-context-hooks
feat(cli): extend the OpenCode plugin to every documented context surface
2 parents 61de19b + fb7cd2f commit 1e3f2fd

3 files changed

Lines changed: 93 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -488,7 +488,7 @@ overwrite user-modified agents.
488488
| Codex CLI | Detected | `$CODEX_HOME/config.toml` | `AGENTS.md`, skill, three read-only agents; `SessionStart` + `SubagentStart` |
489489
| Gemini CLI | Detected | `.gemini/settings.json` | `GEMINI.md`, three explicit read/graph-tool subagents; `BeforeTool`, `AfterTool` `read_file` coverage, and `SessionStart` |
490490
| Zed | Detected | platform `settings.json` (JSONC) | `AGENTS.md` + shared skill |
491-
| OpenCode | Detected | `$OPENCODE_CONFIG` or resolved global config | `AGENTS.md`, skill, three deny-by-default read-only agents |
491+
| 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 |
492492
| Antigravity | Detected | `.gemini/config/mcp_config.json` | `.gemini/GEMINI.md` |
493493
| Aider | Detected | — | `CONVENTIONS.md` via `.aider.conf.yml` |
494494
| KiloCode | Detected | `.config/kilo/kilo.jsonc` | Rule + three graph-tool subagents with deny-by-default permissions |

‎src/cli/client_adapter.c‎

Lines changed: 69 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -265,17 +265,23 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
265265

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

276-
/* hook-augment requires hook_event_name and accepts Grep/Glob only under
277-
* PreToolUse; omitting it makes the whole hook a silent no-op. */
278-
sb_append(&sb, "function augment(tool, args) {\n"
279+
/* hook-augment requires hook_event_name; its default dialect accepts
280+
* Grep/Glob under PreToolUse, Read under PostToolUse, and the
281+
* SessionStart lifecycle event — and emits the Claude JSON envelope, so
282+
* the plugin unwraps additionalContext instead of pasting raw JSON into
283+
* the tool output. Every failure path resolves to '' (fail open). */
284+
sb_append(&sb, "function augment(payload) {\n"
279285
" return new Promise((resolve) => {\n"
280286
" const child = spawn(BIN, ['hook-augment'], {\n"
281287
" stdio: ['pipe', 'pipe', 'ignore'],\n"
@@ -284,27 +290,67 @@ char *cbm_client_adapter_opencode(const char *binary_path) {
284290
" let out = '';\n"
285291
" child.stdout.on('data', (d) => (out += d.toString()));\n"
286292
" child.on('error', () => resolve(''));\n"
287-
" child.on('close', () => resolve(out));\n"
288-
" child.stdin.end(JSON.stringify({\n"
289-
" hook_event_name: 'PreToolUse',\n"
290-
" tool_name: tool,\n"
291-
" tool_input: args ?? {},\n"
292-
" }));\n"
293+
" child.on('close', () => {\n"
294+
" try {\n"
295+
" const ctx = JSON.parse(out)?.hookSpecificOutput?.additionalContext;\n"
296+
" resolve(typeof ctx === 'string' ? ctx : '');\n"
297+
" } catch { resolve(''); }\n"
298+
" });\n"
299+
" child.stdin.end(JSON.stringify(payload));\n"
293300
" });\n"
294301
"}\n\n");
295302

296303
sb_append(&sb,
297-
"export const CodebaseMemory = async () => ({\n"
298-
" 'tool.execute.after': async (input, output) => {\n"
299-
" const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' "
300-
": null;\n"
301-
" if (!tool) return;\n"
302-
" const extra = await augment(tool, output?.args);\n"
303-
" if (extra && typeof output?.output === 'string') {\n"
304-
" output.output += '\\n' + extra;\n"
305-
" }\n"
306-
" },\n"
307-
"});\n");
304+
"export const CodebaseMemory = async (ctx) => {\n"
305+
" const dir = ctx?.directory;\n"
306+
" const seen = new Set();\n"
307+
" const lifecycle = () =>\n"
308+
" augment({ hook_event_name: 'SessionStart', cwd: dir });\n"
309+
" return {\n"
310+
" 'tool.execute.after': async (input, output) => {\n"
311+
" if (typeof output?.output !== 'string') return;\n"
312+
" const pieces = [];\n"
313+
" const sid = input?.sessionID;\n"
314+
" if (typeof sid === 'string' && !seen.has(sid)) {\n"
315+
" seen.add(sid);\n"
316+
" pieces.push(await lifecycle());\n"
317+
" }\n"
318+
" const args = output?.args ?? {};\n"
319+
" const search =\n"
320+
" input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;\n"
321+
" if (search) {\n"
322+
" pieces.push(await augment({\n"
323+
" hook_event_name: 'PreToolUse',\n"
324+
" tool_name: search,\n"
325+
" tool_input: args,\n"
326+
" cwd: dir,\n"
327+
" }));\n"
328+
" } else if (input?.tool === 'read') {\n"
329+
" const filePath = args.filePath ?? args.file_path ?? args.path;\n"
330+
" if (typeof filePath === 'string' && filePath) {\n"
331+
" pieces.push(await augment({\n"
332+
" hook_event_name: 'PostToolUse',\n"
333+
" tool_name: 'Read',\n"
334+
" tool_input: { file_path: filePath },\n"
335+
" cwd: dir,\n"
336+
" }));\n"
337+
" }\n"
338+
" }\n"
339+
" const extra = pieces.filter(Boolean).join('\\n');\n"
340+
" if (extra) {\n"
341+
" output.output += '\\n' + extra;\n"
342+
" }\n"
343+
" },\n"
344+
" // Documented (experimental) compaction surface: output.context is the\n"
345+
" // mutable array of context strings for the rebuilt session.\n"
346+
" 'experimental.session.compacting': async (_input, output) => {\n"
347+
" const note = await lifecycle();\n"
348+
" if (note && Array.isArray(output?.context)) {\n"
349+
" output.context.push(note);\n"
350+
" }\n"
351+
" },\n"
352+
" };\n"
353+
"};\n");
308354

309355
if (sb.failed) {
310356
free(sb.buf);

‎tests/test_agent_clients.c‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1291,6 +1291,28 @@ TEST(client_adapter_opencode_sends_the_required_hook_event) {
12911291
PASS();
12921292
}
12931293

1294+
/* The richer OpenCode adapter carries every context surface the plugin API
1295+
* documents: session-start tier routing on the first tool result of each
1296+
* session, post-read coverage notes, and post-compaction reinjection through
1297+
* the documented experimental surface. It must unwrap hook-augment's Claude
1298+
* JSON envelope so plain text — not raw JSON — reaches the model. */
1299+
TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) {
1300+
char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp");
1301+
ASSERT_NOT_NULL(js);
1302+
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'SessionStart'"));
1303+
ASSERT_NOT_NULL(strstr(js, "hook_event_name: 'PostToolUse'"));
1304+
ASSERT_NOT_NULL(strstr(js, "tool_name: 'Read'"));
1305+
ASSERT_NOT_NULL(strstr(js, "'experimental.session.compacting'"));
1306+
ASSERT_NOT_NULL(strstr(js, "additionalContext"));
1307+
/* file_path is the key hook-augment's default dialect reads; OpenCode's
1308+
* read tool argues filePath, so the adapter must map it. */
1309+
ASSERT_NOT_NULL(strstr(js, "file_path: filePath"));
1310+
/* Session context may only be injected once per session id. */
1311+
ASSERT_NOT_NULL(strstr(js, "seen.has(sid)"));
1312+
free(js);
1313+
PASS();
1314+
}
1315+
12941316
/* Empty/NULL inputs must not produce a module at all. */
12951317
TEST(client_adapter_rejects_missing_binary_path) {
12961318
ASSERT_NULL(cbm_client_adapter_pi(NULL));
@@ -1336,5 +1358,6 @@ SUITE(agent_clients) {
13361358
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
13371359
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
13381360
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
1361+
RUN_TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction);
13391362
RUN_TEST(client_adapter_rejects_missing_binary_path);
13401363
}

0 commit comments

Comments
 (0)