From 7e0b139d24bc18d6c3267b4d7c17e36d2f87b3dd Mon Sep 17 00:00:00 2001 From: weishu Date: Thu, 29 Jan 2026 10:53:15 +0800 Subject: [PATCH] feat: add change_title MCP tool support to OpenCode --- .../permission/BasePermissionHandler.ts | 1 + cli/src/opencode/opencodeLocalLauncher.ts | 26 ++++++ cli/src/opencode/opencodeRemoteLauncher.ts | 11 ++- cli/src/opencode/utils/opencodeConfig.ts | 81 +++++++++++++++++++ cli/src/opencode/utils/systemPrompt.ts | 20 +++++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 cli/src/opencode/utils/opencodeConfig.ts create mode 100644 cli/src/opencode/utils/systemPrompt.ts diff --git a/cli/src/modules/common/permission/BasePermissionHandler.ts b/cli/src/modules/common/permission/BasePermissionHandler.ts index edb486cc..eca40ef3 100644 --- a/cli/src/modules/common/permission/BasePermissionHandler.ts +++ b/cli/src/modules/common/permission/BasePermissionHandler.ts @@ -19,6 +19,7 @@ type AutoApprovalRuleSet = { const AUTO_APPROVE_TOOL_NAME_HINTS = [ 'change_title', 'happy__change_title', + 'hapi_change_title', // OpenCode MCP tool pattern 'geminireasoning', 'codexreasoning', 'think', diff --git a/cli/src/opencode/opencodeLocalLauncher.ts b/cli/src/opencode/opencodeLocalLauncher.ts index e41863c8..e806a5a7 100644 --- a/cli/src/opencode/opencodeLocalLauncher.ts +++ b/cli/src/opencode/opencodeLocalLauncher.ts @@ -5,6 +5,9 @@ import { Future } from '@/utils/future'; import { getLocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import { ensureOpencodeHookPlugin } from './utils/hookPlugin'; import { buildOpencodeEnv } from './utils/config'; +import { ensureOpencodeConfig } from './utils/opencodeConfig'; +import { TITLE_INSTRUCTION } from './utils/systemPrompt'; +import { buildHapiMcpBridge } from '@/codex/utils/buildHapiMcpBridge'; import type { OpencodeHookEvent } from './types'; import type { OpencodeHookServer } from './utils/startOpencodeHookServer'; import { createOpencodeStorageScanner, type OpencodeStorageScannerHandle } from './utils/opencodeStorageScanner'; @@ -263,6 +266,21 @@ export async function opencodeLocalLauncher( const opencodeConfigDir = resolveOpencodeConfigDir(session); ensureOpencodeHookPlugin(opencodeConfigDir, hookUrl, opts.hookServer.token); + // Start the hapi MCP server for change_title support (optional feature) + let happyServer: { url: string; stop: () => void } | null = null; + let opencodeConfigPath: string | null = null; + try { + const bridge = await buildHapiMcpBridge(session.client); + happyServer = bridge.server; + logger.debug(`[opencode-local]: Started hapi MCP server at ${happyServer.url}`); + + // Generate opencode.json config with MCP server and instructions + const { configPath } = ensureOpencodeConfig(opencodeConfigDir, bridge.mcpServers.hapi, TITLE_INSTRUCTION); + opencodeConfigPath = configPath; + } catch (error) { + logger.debug('[opencode-local]: Failed to start hapi MCP server (change_title will be unavailable)', error); + } + let storageScanner: OpencodeStorageScannerHandle | null = null; const messageRoles = new Map(); const sentTextParts = new Set(); @@ -617,6 +635,10 @@ export async function opencodeLocalLauncher( if (!env.OPENCODE_CONFIG_DIR) { env.OPENCODE_CONFIG_DIR = opencodeConfigDir; } + // Set OPENCODE_CONFIG to point to our generated config file (if MCP server started) + if (!env.OPENCODE_CONFIG && opencodeConfigPath) { + env.OPENCODE_CONFIG = opencodeConfigPath; + } await opencodeLocal({ path: session.path, @@ -659,6 +681,10 @@ export async function opencodeLocalLauncher( if (storageScanner) { await storageScanner.cleanup(); } + if (happyServer) { + happyServer.stop(); + logger.debug('[opencode-local]: Stopped hapi MCP server'); + } } return exitReason || 'exit'; diff --git a/cli/src/opencode/opencodeRemoteLauncher.ts b/cli/src/opencode/opencodeRemoteLauncher.ts index 1a3addcb..c647a749 100644 --- a/cli/src/opencode/opencodeRemoteLauncher.ts +++ b/cli/src/opencode/opencodeRemoteLauncher.ts @@ -9,6 +9,7 @@ import type { OpencodeSession } from './session'; import type { PermissionMode } from './types'; import { createOpencodeBackend } from './utils/opencodeBackend'; import { OpencodePermissionHandler } from './utils/permissionHandler'; +import { TITLE_INSTRUCTION } from './utils/systemPrompt'; class OpencodeRemoteLauncher extends RemoteLauncherBase { private readonly session: OpencodeSession; @@ -17,6 +18,7 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { private happyServer: { stop: () => void } | null = null; private abortController = new AbortController(); private displayPermissionMode: PermissionMode | null = null; + private instructionsSent = false; constructor(session: OpencodeSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -112,9 +114,16 @@ class OpencodeRemoteLauncher extends RemoteLauncherBase { this.applyDisplayMode(batch.mode.permissionMode); messageBuffer.addMessage(batch.message, 'user'); + // Inject title instructions on first prompt + let messageText = batch.message; + if (!this.instructionsSent) { + messageText = `${TITLE_INSTRUCTION}\n\n${batch.message}`; + this.instructionsSent = true; + } + const promptContent: PromptContent[] = [{ type: 'text', - text: batch.message + text: messageText }]; session.onThinkingChange(true); diff --git a/cli/src/opencode/utils/opencodeConfig.ts b/cli/src/opencode/utils/opencodeConfig.ts new file mode 100644 index 00000000..9af1ed19 --- /dev/null +++ b/cli/src/opencode/utils/opencodeConfig.ts @@ -0,0 +1,81 @@ +/** + * OpenCode configuration file generator. + * + * Generates opencode.json with MCP server configuration and instructions + * for the hapi change_title tool. + */ + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const CONFIG_FILENAME = 'opencode.json'; +const INSTRUCTIONS_FILENAME = 'hapi-instructions.md'; + +interface McpServerEntry { + command: string; + args: string[]; +} + +interface OpencodeConfig { + $schema: string; + mcp: Record; + instructions: string[]; +} + +/** + * Ensures the opencode.json config file exists with MCP server and instructions. + * + * @param rootPath - The OPENCODE_CONFIG_DIR path + * @param mcpServer - The hapi MCP server command configuration + * @param instructions - The instruction text to write to the instructions file + */ +export function ensureOpencodeConfig( + rootPath: string, + mcpServer: McpServerEntry, + instructions: string +): { configPath: string; instructionsPath: string } { + mkdirSync(rootPath, { recursive: true }); + + // Write instructions file + const instructionsPath = join(rootPath, INSTRUCTIONS_FILENAME); + writeFileSafe(instructionsPath, instructions); + + // Build opencode.json config + // Use absolute path for instructions since OpenCode resolves paths relative to project root + const config: OpencodeConfig = { + $schema: 'https://opencode.ai/config.json', + mcp: { + hapi: { + type: 'local', + command: [mcpServer.command, ...mcpServer.args], + enabled: true + } + }, + instructions: [instructionsPath] + }; + + const configPath = join(rootPath, CONFIG_FILENAME); + const configJson = JSON.stringify(config, null, 2); + writeFileSafe(configPath, configJson); + + return { configPath, instructionsPath }; +} + +/** + * Write file only if content has changed. + */ +function writeFileSafe(filePath: string, content: string): void { + try { + const current = readFileSync(filePath, 'utf-8'); + if (current === content) { + return; + } + } catch { + // Ignore missing or unreadable file + } + writeFileSync(filePath, content, 'utf-8'); +} diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts new file mode 100644 index 00000000..97dc0084 --- /dev/null +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -0,0 +1,20 @@ +/** + * OpenCode-specific system prompt for change_title tool. + * + * OpenCode exposes MCP tools with the naming pattern: _ + * The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`. + */ + +import { trimIdent } from '@/utils/trimIdent'; + +/** + * Title instruction for OpenCode to call the hapi MCP tool. + */ +export const TITLE_INSTRUCTION = trimIdent(` + ALWAYS when you start a new chat - you must call the tool "hapi_change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a chance to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. +`); + +/** + * The system prompt to inject for OpenCode sessions. + */ +export const opencodeSystemPrompt = TITLE_INSTRUCTION;