From dc34e63a0a0aa29cb70456b692733124aba65350 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 7 Sep 2026 11:49:28 +0000 Subject: [PATCH] fix(cli): emit the OpenCode plugin as a V2 default export OpenCode's V2 loader validates only the module's default export, expecting {id, setup()|effect()}. cbm_client_adapter_opencode still emitted the V1 named export (export const CodebaseMemory = ...), so every server start failed with SchemaError: Missing key at ["default"]. Wrap the existing hook logic in a default export with an id and a setup(ctx) function; the returned hooks object is unchanged. Fixes #2077 Signed-off-by: Amir Fathi --- src/cli/client_adapter.c | 105 +++++++++++++++++++------------------ tests/test_agent_clients.c | 14 +++++ 2 files changed, 68 insertions(+), 51 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index 8d4ba4af4..c62afb951 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -300,57 +300,60 @@ char *cbm_client_adapter_opencode(const char *binary_path) { " });\n" "}\n\n"); - sb_append(&sb, - "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"); + sb_append( + &sb, "export default {\n" + " id: 'codebase-memory-augment',\n" + " async setup(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" + "};\n"); if (sb.failed) { free(sb.buf); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index c2c728203..8d75814fd 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1313,6 +1313,19 @@ TEST(client_adapter_opencode_covers_lifecycle_read_and_compaction) { PASS(); } +/* #2077: OpenCode's V2 loader only reads the default export and needs an + * id plus a setup()/effect() function; the old named export had neither. */ +TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { + char *js = cbm_client_adapter_opencode("/usr/local/bin/codebase-memory-mcp"); + ASSERT_NOT_NULL(js); + ASSERT_NOT_NULL(strstr(js, "export default {")); + ASSERT_NOT_NULL(strstr(js, "id: 'codebase-memory-augment'")); + ASSERT_NOT_NULL(strstr(js, "async setup(ctx) {")); + ASSERT_NULL(strstr(js, "export const CodebaseMemory")); + 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)); @@ -1359,5 +1372,6 @@ SUITE(agent_clients) { 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_opencode_exports_the_v2_default_definition_issue2077); RUN_TEST(client_adapter_rejects_missing_binary_path); }