Skip to main content

OpenCode V2 upgrade

· 7 min read
Stephan Hochdörfer
Head of IT Business Operations

For about one year I’ve been using OpenCode as my coding agent. OpenCode recently announced the release of the next major version of OpenCode. After a few weeks, I decided to migrate and here’s how I did it.

If you are not relying on any OpenCode plugins, the migration is pretty straightforward. In my case, it was not. I am using a few tools and plugins to improve the main OpenCode functionality. Plugins like engram, squeez, worktrunk, and codebase-memory-mcp.

While most of the plugins have already released a new plugin version which is compatible with the new OpenCode V2 Plugin API specification, codebase-memory-mcp already has some changes merged but no release crafted. Since I did not want to wait longer, I decided to still start the migration and try to let AI help me finalize the codebase-memory-mcp plugin myself.

Also, I am relying on OpenChamber as the UI for OpenCode which meant, I needed to migrate OpenChamber also.

Preparation​

Before starting the migration, I made sure to upgrade all tools to their latest versions. I also backed up my current .config/opencode directory to avoid any potential issues during the upgrade.

Upgrading OpenCode​

To upgrade OpenCode, I first updated it to the latest V1 version using the following command:

opencode upgrade

Then, I ran the major upgrade using:

opencode upgrade --major

This took some time to download all the new dependencies. Once it was done, I started OpenCode in one of my projects to test if everything was working correctly.

Installing OpenCode Plugins​

Most of the plugins I use have already released new versions compatible with the OpenCode V2 Plugin API specification. I reinstalled the plugins using the following commands:

engram setup opencode
squeez setup
wt config plugins opencode install

However, the codebase-memory-mcp plugin had some changes merged but no release yet published. I decided to use OpenCode to generate a compatible plugin file. Here's the current implementation I'm using:

// codebase-memory-mcp:start
import { spawn } from 'node:child_process';

const BIN = '/home/myuser/.local/bin/codebase-memory-mcp';

function augment(tool, args) {
return new Promise((resolve) => {
const child = spawn(BIN, ['hook-augment'], {
stdio: ['pipe', 'pipe', 'ignore'],
env: { ...process.env, CBM_LOG_LEVEL: 'error' },
});
let out = '';
child.stdout.on('data', (d) => (out += d.toString()));
child.on('error', () => resolve(''));
child.on('close', () => resolve(out));
child.stdin.end(JSON.stringify({
hook_event_name: 'PreToolUse',
tool_name: tool,
tool_input: args ?? {},
}));
});
}

export const CodebaseMemory = async () => ({
'tool.execute.after': async (input, output) => {
const tool = input?.tool === 'grep' ? 'Grep' : input?.tool === 'glob' ? 'Glob' : null;
if (!tool) return;
const extra = await augment(tool, output?.args);
if (extra && typeof output?.output === 'string') {
output.output += '\n' + extra;
}
},
});
// codebase-memory-mcp:end

// ─── OpenCode v2 port ────────────────────────────────────────────────────────
// V1 (1.18.x) uses the named `CodebaseMemory` factory above; V2 reads this
// default export and registers the equivalent hooks on the plugin context.
// Types are structural: the global plugin directory has no `@opencode/plugin`
// dependency to import from.
import { spawn as cbmSpawn } from 'node:child_process';

const CBM_BIN = '/home/myuser/.local/bin/codebase-memory-mcp';

type CbmTextPart = { type: 'text'; text: string };
type CbmToolResult = { content?: string | CbmTextPart[]; output?: unknown };
type CbmToolEvent = {
tool: string;
sessionID?: string;
status: 'completed' | 'error';
input?: unknown;
result?: CbmToolResult;
};
type CbmSessionEvent = { sessionID?: string };
type CbmRegistration = { dispose: () => Promise<void> };
type CbmContext = {
location: { directory: string };
tool: { hook(name: 'execute.after', callback: (event: CbmToolEvent) => Promise<void> | void): Promise<CbmRegistration> };
session: { hook(name: 'compaction' | 'context', callback: (event: CbmSessionEvent) => Promise<void> | void): Promise<CbmRegistration> };
};

// `hook-augment` emits the Claude JSON envelope; older builds may emit plain
// text, so fall back to the raw body only when it is not JSON. A parsed
// envelope with no `additionalContext` (e.g. a `systemMessage` such as "no CBM
// daemon is running") contributes nothing. Every failure path resolves to ''.
function cbmContextFromHookOutput(out: string): string {
const trimmed = out.trim();
if (!trimmed) return '';
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
try {
const context = JSON.parse(trimmed)?.hookSpecificOutput?.additionalContext;
return typeof context === 'string' ? context : '';
} catch {
// Not JSON after all; fall through to the plain-text dialect.
}
}
return trimmed;
}

function cbmAugment(payload: Record<string, unknown>): Promise<string> {
return new Promise((resolve) => {
const child = cbmSpawn(CBM_BIN, ['hook-augment'], {
stdio: ['pipe', 'pipe', 'ignore'],
env: { ...process.env, CBM_LOG_LEVEL: 'error' },
});
let out = '';
child.stdout.on('data', (d) => (out += d.toString()));
child.on('error', () => resolve(''));
child.on('close', () => resolve(cbmContextFromHookOutput(out)));
child.stdin.end(JSON.stringify(payload));
});
}

// `Tool.Result.output` is schema-typed structured data; `content` is the surface
// the model reads, so prefer it and only fall back to a string `output`.
function cbmWithContext(result: CbmToolResult, extra: string): CbmToolResult {
if (typeof result.content === 'string') {
return { ...result, content: `${result.content}\n${extra}` };
}
if (Array.isArray(result.content)) {
return { ...result, content: [...result.content, { type: 'text', text: extra }] };
}
if (typeof result.output === 'string') {
return { ...result, content: `${result.output}\n${extra}` };
}
return { ...result, content: extra };
}

export default {
id: 'codebase-memory-augment',
async setup(ctx: CbmContext): Promise<void> {
// The session-start note rides on the first completed tool result of a
// session; a compaction clears the mark so the rebuilt context re-carries it.
const annotated = new Set<string>();
const pendingReinject = new Set<string>();
const directory = ctx.location.directory;

const lifecycle = () => cbmAugment({ hook_event_name: 'SessionStart', cwd: directory });

await ctx.tool.hook('execute.after', async (event) => {
if (event.status !== 'completed' || !event.result) return;
const name = (event.tool ?? '').toLowerCase();
const input = (event.input && typeof event.input === 'object' ? event.input : {}) as Record<string, unknown>;
const pieces: string[] = [];

const sessionID = event.sessionID;
if (typeof sessionID === 'string' && !annotated.has(sessionID)) {
annotated.add(sessionID);
pieces.push(await lifecycle());
}

if (name === 'grep' || name === 'glob') {
pieces.push(await cbmAugment({
hook_event_name: 'PreToolUse',
tool_name: name === 'grep' ? 'Grep' : 'Glob',
tool_input: input,
cwd: directory,
}));
} else if (name === 'read') {
const filePath = input.filePath ?? input.file_path ?? input.path;
if (typeof filePath === 'string' && filePath) {
pieces.push(await cbmAugment({
hook_event_name: 'PostToolUse',
tool_name: 'Read',
tool_input: { file_path: filePath },
cwd: directory,
}));
}
}

const extra = pieces.filter(Boolean).join('\n');
if (extra) event.result = cbmWithContext(event.result, extra);
});

// V2 has no `output.context`; compaction carries `event.system`. Record the
// session and let the next model request hand the note back to execute.after.
await ctx.session.hook('compaction', (event) => {
if (typeof event.sessionID === 'string') pendingReinject.add(event.sessionID);
});
await ctx.session.hook('context', (event) => {
if (typeof event.sessionID === 'string' && pendingReinject.delete(event.sessionID)) {
annotated.delete(event.sessionID);
}
});
},
};

I added this file as cbm-augment.ts in my .config/opencode/plugins directory.

Upgrading OpenChamber​

To upgrade OpenChamber, I ran the following command in WSL2:

openchamber update

However, the command said it worked, but the version query showed otherwise. I checked the installed files and found that OpenChamber was installed in two locations:

openchamber is /home/myuser/.local/share/pnpm/bin/openchamber
openchamber is /home/myuser/.nodenv/shims/openchamber

The /home/myuser/.local/share/pnpm/bin/openchamber version was still on V1, while /home/myuser/.nodenv/shims/openchamber was properly upgraded to V2.

I deleted the file /home/myuser/.local/share/pnpm/bin/openchamber and replaced it with a symlink to /home/myuser/.nodenv/shims/openchamber.

On the Windows side, I downloaded the latest Windows installer from the GitHub release page and installed the update. The client started fine and was able to connect to the server instance.

Conclusion​

In conclusion, migrating to OpenCode V2 and upgrading OpenChamber required some manual effort, but it was worth it to take advantage of the new features and improvements. If you're planning to upgrade, make sure to backup your configuration and plugins, and be prepared to troubleshoot any issues that may arise.