From 8f3c15044b8200a15ee1895c5127f4bf117da493 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Mon, 7 Sep 2026 11:49:28 +0000 Subject: [PATCH 1/2] 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 dbd9134268..ed77bd67aa 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 = 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"); + 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 = 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"); if (sb.failed) { free(sb.buf); diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index 59d48b3206..a1e4c40204 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1316,6 +1316,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)); @@ -1362,5 +1375,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); } From e171bd48b5e5845ec6f00db65ff300caac4f7304 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Sun, 20 Sep 2026 09:15:32 +0000 Subject: [PATCH 2/2] fix(cli): move opencode plugin hooks under server(), leave setup() empty DeusData asked (2026-09-19T20:25:34Z on PR #2089) to place the hooks in the server entry rather than returning them from setup(), since the server runtime reads default.server and dispatches the hooks it returns, while the V2 config loader only reads id + setup()/effect() and has no tool domain yet to register against. Moved the existing hook body under server: async (ctx) => {...} and added an empty setup() {}. Updated the emission test to check for both and to assert the old async setup(ctx) { shape is gone. Signed-off-by: Amir Fathi --- src/cli/client_adapter.c | 6 +++++- tests/test_agent_clients.c | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c index ed77bd67aa..16f2b3ba0e 100644 --- a/src/cli/client_adapter.c +++ b/src/cli/client_adapter.c @@ -303,7 +303,11 @@ char *cbm_client_adapter_opencode(const char *binary_path) { sb_append( &sb, "export default {\n" " id: 'codebase-memory-augment',\n" - " async setup(ctx) {\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" diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c index a1e4c40204..1cf5cab020 100644 --- a/tests/test_agent_clients.c +++ b/tests/test_agent_clients.c @@ -1323,7 +1323,11 @@ TEST(client_adapter_opencode_exports_the_v2_default_definition_issue2077) { 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) {")); + /* Hooks live under server(), which the server runtime reads; setup() + * stays empty since the V2 config loader has no tool domain yet. */ + ASSERT_NOT_NULL(strstr(js, "setup() {}")); + ASSERT_NOT_NULL(strstr(js, "server: async (ctx) => {")); + ASSERT_NULL(strstr(js, "async setup(ctx) {")); ASSERT_NULL(strstr(js, "export const CodebaseMemory")); free(js); PASS();