diff --git a/packages/vscode/src/bridge-config-runtime.test.js b/packages/vscode/src/bridge-config-runtime.test.js index 9cdabc23..4a346baa 100644 --- a/packages/vscode/src/bridge-config-runtime.test.js +++ b/packages/vscode/src/bridge-config-runtime.test.js @@ -52,6 +52,25 @@ afterEach(() => { const readJson = (filePath) => JSON.parse(fs.readFileSync(filePath, 'utf8')); describe('VS Code config bridge plugin parity', () => { + test('explicit config reload restarts OpenCode', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-reload-')); + tempRoots.push(root); + const ctx = createCtx(root); + + const reloaded = await handleConfigBridgeMessage({ + id: 'reload', + type: 'api:config/reload', + }, ctx, deps); + + expect(reloaded).toEqual({ + id: 'reload', + type: 'api:config/reload', + success: true, + data: { restarted: true }, + }); + expect(ctx.restart).toHaveBeenCalledTimes(1); + }); + test('removes agent fields when update payload sends null', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-agent-null-')); tempRoots.push(root); @@ -102,7 +121,14 @@ describe('VS Code config bridge plugin parity', () => { }, ctx, deps); expect(created?.success).toBe(true); - expect(ctx.restart).toHaveBeenCalledTimes(1); + expect(created?.data).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin entry changed. Restart OpenCode to apply.', + }); + expect(ctx.restart).not.toHaveBeenCalled(); const listed = await handleConfigBridgeMessage({ id: 'list', @@ -251,27 +277,36 @@ describe('VS Code config bridge plugin parity', () => { expect(fs.readFileSync(path.join(configDir, 'plugins', 'demo-plugin.ts'), 'utf8')).toBe('export default {}'); }); - test('reports plugin mutation success when restart fails after writing config', async () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-plugin-restart-')); + test('creates MCP config with deferred restart when restart would fail', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-mcp-deferred-')); tempRoots.push(root); const ctx = createCtx(root, async () => { throw new Error('restart failed'); }); const created = await handleConfigBridgeMessage({ - id: 'create-restart-failure', - type: 'api:config/plugins', + id: 'create-mcp-deferred', + type: 'api:config/mcp', payload: { method: 'POST', - target: 'entry', + name: 'mcp-deferred', directory: root, - body: { scope: 'project', spec: 'plugin-restart' }, + body: { scope: 'project', type: 'local', command: ['node', 'server.js'] }, }, }, ctx, deps); expect(created?.success).toBe(true); - expect(created?.data).toMatchObject({ success: true, requiresReload: false, reloadFailed: true }); - expect(created?.data?.warning).toContain('restart failed'); - expect(readJson(path.join(root, '.opencode', 'opencode.json')).plugin).toEqual(['plugin-restart']); + expect(created?.data).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'MCP server "mcp-deferred" created. Restart OpenCode to apply.', + }); + expect(ctx.restart).not.toHaveBeenCalled(); + expect(readJson(path.join(root, '.opencode', 'opencode.json')).mcp['mcp-deferred']).toMatchObject({ + type: 'local', + command: ['node', 'server.js'], + }); }); }); diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 361f2a1a..b87e6879 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -56,6 +56,7 @@ import { installSkillsFromRepository as installSkillsFromGit, type SkillsCatalogSourceConfig, } from './skillsCatalog'; +import { buildDeferredRestartResponse } from './config-mutation-response'; import type { BridgeContext, BridgeResponse } from './bridge'; type BridgeMessageInput = { @@ -84,31 +85,9 @@ const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: str : (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) ); -const pluginMutationPayload = async ( - ctx: BridgeContext | undefined, - deps: ConfigRuntimeDeps, - label: string, -) => { - try { - await ctx?.manager?.restart(); - return { - success: true, - requiresReload: true, - message: `${label}. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - reloadFailed: false, - }; - } catch (error) { - return { - success: true, - requiresReload: false, - message: `${label}, but OpenCode reload failed.`, - reloadDelayMs: deps.clientReloadDelayMs, - reloadFailed: true, - warning: error instanceof Error ? error.message : String(error), - }; - } -}; +const pluginMutationPayload = (label: string) => buildDeferredRestartResponse( + `${label}. Restart OpenCode to apply.`, +); const parseSkillsCatalogSources = (settings: Record): SkillsCatalogSourceConfig[] => { const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs; @@ -222,8 +201,12 @@ export async function handleConfigBridgeMessage( } await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true }); await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8'); - await ctx?.manager?.restart(); - return { id, type, success: true, data: { success: true } }; + return { + id, + type, + success: true, + data: buildDeferredRestartResponse('AGENTS.md saved. Restart OpenCode to apply.'), + }; } case 'api:magic-prompts:get': { @@ -295,33 +278,21 @@ export async function handleConfigBridgeMessage( const scopeValue = body?.scope as string | undefined; const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; createAgent(agentName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} created successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Agent ${agentName} created successfully. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'PATCH') { updateAgent(agentName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} updated successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Agent ${agentName} updated successfully. Restart OpenCode to apply.`), }; } @@ -329,17 +300,11 @@ export async function handleConfigBridgeMessage( const scopeValue = body?.scope as string | undefined; const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined; deleteAgent(agentName, workingDirectory, scope); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Agent ${agentName} deleted successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Agent ${agentName} deleted successfully. Restart OpenCode to apply.`), }; } @@ -378,49 +343,31 @@ export async function handleConfigBridgeMessage( const scopeValue = body?.scope as string | undefined; const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined; createCommand(commandName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Command ${commandName} created successfully. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'PATCH') { updateCommand(commandName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Command ${commandName} updated successfully. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'DELETE') { deleteCommand(commandName, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Command ${commandName} deleted successfully. Restart OpenCode to apply.`), }; } @@ -458,49 +405,31 @@ export async function handleConfigBridgeMessage( if (normalizedMethod === 'POST') { const scope = body?.scope as 'user' | 'project' | undefined; createMcpConfig(mcpName, (body || {}) as Record, workingDirectory, scope); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" created. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`MCP server "${mcpName}" created. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'PATCH') { updateMcpConfig(mcpName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" updated. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`MCP server "${mcpName}" updated. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'DELETE') { deleteMcpConfig(mcpName, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `MCP server "${mcpName}" deleted. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`MCP server "${mcpName}" deleted. Restart OpenCode to apply.`), }; } @@ -562,7 +491,7 @@ export async function handleConfigBridgeMessage( id, type, success: true, - data: await pluginMutationPayload(ctx, deps, 'Plugin entry changed'), + data: pluginMutationPayload('Plugin entry changed'), }; } @@ -590,7 +519,7 @@ export async function handleConfigBridgeMessage( id, type, success: true, - data: await pluginMutationPayload(ctx, deps, 'Plugin file changed'), + data: pluginMutationPayload('Plugin file changed'), }; } @@ -678,49 +607,31 @@ export async function handleConfigBridgeMessage( const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined; const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode'; createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record, workingDirectory, scope); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Skill ${skillName} created successfully. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'PATCH') { updateSkill(skillName, (body || {}) as Record, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Skill ${skillName} updated successfully. Restart OpenCode to apply.`), }; } if (normalizedMethod === 'DELETE') { deleteSkill(skillName, workingDirectory); - await ctx?.manager?.restart(); return { id, type, success: true, - data: { - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: deps.clientReloadDelayMs, - }, + data: buildDeferredRestartResponse(`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`), }; } @@ -773,11 +684,7 @@ export async function handleConfigBridgeMessage( if (data.ok) { const installed = data.installed || []; const skipped = data.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await ctx?.manager?.restart(); - } + const requiresRestart = installed.length > 0; return { id, @@ -787,9 +694,12 @@ export async function handleConfigBridgeMessage( ok: true, installed, skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined, + ...(requiresRestart + ? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.') + : { + requiresReload: false, + message: 'No skills were installed', + }), }, }; } diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index ffc1bfaf..41bb4bc7 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -10,6 +10,7 @@ import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; import { credentialStatus, deleteCredential, importCursorCredential, normalizeCredential, readCredential, validateCredential, writeCredential, type ManagedProvider } from './quotaCredentials'; import { getSessionActivitySnapshot } from './sessionActivityWatcher'; import { getOpenCodeUpgradeStatus, upgradeManagedOpenCode } from './opencode-upgrade-runtime'; +import { buildDeferredRestartResponse } from './config-mutation-response'; import type { BridgeContext, BridgeResponse } from './bridge'; type BridgeMessageInput = { @@ -443,21 +444,19 @@ export async function handleSystemBridgeMessage( return { id, type, success: false, error: 'Invalid scope' }; } - if (removed) { - await ctx?.manager?.restart(); - } return { id, type, success: true, data: { - success: true, removed, - requiresReload: removed, - message: removed - ? `Provider ${providerId} disconnected successfully. Reloading interface…` - : `Provider ${providerId} was not configured.`, - reloadDelayMs: removed ? deps.clientReloadDelayMs : undefined, + ...(removed + ? buildDeferredRestartResponse(`Provider ${providerId} disconnected successfully. Restart OpenCode to apply.`) + : { + success: true, + requiresReload: false, + message: `Provider ${providerId} was not configured.`, + }), }, }; } catch (error) { diff --git a/packages/vscode/src/config-mutation-response.ts b/packages/vscode/src/config-mutation-response.ts new file mode 100644 index 00000000..484554eb --- /dev/null +++ b/packages/vscode/src/config-mutation-response.ts @@ -0,0 +1,16 @@ +/** + * Shared response shape for OpenCode config mutations. + * + * Settings writes persist to disk immediately but defer the OpenCode restart + * so the UI can accumulate pending changes and apply them once via + * api:config/reload ("Apply & Restart OpenCode"). + */ +export function buildDeferredRestartResponse(message: string) { + return { + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message, + }; +} diff --git a/packages/web/server/lib/opencode/config-entity-routes.js b/packages/web/server/lib/opencode/config-entity-routes.js index 7a87f7d5..c3e48ce5 100644 --- a/packages/web/server/lib/opencode/config-entity-routes.js +++ b/packages/web/server/lib/opencode/config-entity-routes.js @@ -1,9 +1,9 @@ +import { buildDeferredRestartResponse } from './config-mutation-response.js'; + export const registerConfigEntityRoutes = (app, dependencies) => { const { resolveProjectDirectory, resolveOptionalProjectDirectory, - refreshOpenCodeAfterConfigChange, - clientReloadDelayMs, getAgentSources, getAgentConfig, createAgent, @@ -26,51 +26,14 @@ export const registerConfigEntityRoutes = (app, dependencies) => { expandSnippets, } = dependencies; - // Build the response for a config mutation based on whether OpenCode actually - // reloaded the change. When connected to an external OpenCode server that - // OpenChamber cannot restart, the change is persisted to disk but the running - // server will not serve it until the user restarts that server. We must not - // report a clean "reloading" success in that case, otherwise the UI silently - // reverts the edit to the stale value on the next refresh. - const buildConfigMutationResponse = (refreshResult, { liveMessage, manualRestartMessage }) => { - if (refreshResult && refreshResult.external) { - return { - success: true, - requiresReload: false, - requiresManualRestart: true, - message: manualRestartMessage, - }; - } - - return { - success: true, - requiresReload: true, - message: liveMessage, - reloadDelayMs: clientReloadDelayMs, - }; - }; - + // Persist to disk immediately; OpenCode restart is deferred to an explicit + // Apply & Restart so settings edits do not interrupt live sessions. const completeMcpMutation = async (res, action, name, applyChange) => { applyChange(); - - try { - await refreshOpenCodeAfterConfigChange(`mcp ${action}`); - return res.json({ - success: true, - requiresReload: true, - message: `MCP server "${name}" ${action}d. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); - } catch (error) { - console.error(`[API:MCP ${action}] Reload failed after config write:`, error); - return res.json({ - success: true, - requiresReload: false, - reloadFailed: true, - message: `MCP server "${name}" ${action}d, but OpenCode reload failed.`, - warning: error.message || 'OpenCode reload failed after the MCP configuration changed', - }); - } + const past = action === 'delete' ? 'deleted' : `${action}d`; + return res.json(buildDeferredRestartResponse( + `MCP server "${name}" ${past}. Restart OpenCode to apply.`, + )); }; app.get('/api/config/agents/:name', async (req, res) => { @@ -128,14 +91,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Scope:', scope, 'Working directory:', directory); createAgent(agentName, config, directory, scope); - const refreshResult = await refreshOpenCodeAfterConfigChange('agent creation', { - agentName - }); - - res.json(buildConfigMutationResponse(refreshResult, { - liveMessage: `Agent ${agentName} created successfully. Reloading interface…`, - manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`, - })); + res.json(buildDeferredRestartResponse( + `Agent ${agentName} created successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to create agent:', error); res.status(500).json({ error: error.message || 'Failed to create agent' }); @@ -156,14 +114,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Working directory:', directory); updateAgent(agentName, updates, directory); - const refreshResult = await refreshOpenCodeAfterConfigChange('agent update'); console.log(`[Server] Agent ${agentName} updated successfully`); - res.json(buildConfigMutationResponse(refreshResult, { - liveMessage: `Agent ${agentName} updated successfully. Reloading interface…`, - manualRestartMessage: `Agent ${agentName} saved. Restart your connected OpenCode server to apply the change.`, - })); + res.json(buildDeferredRestartResponse( + `Agent ${agentName} updated successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('[Server] Failed to update agent:', error); console.error('[Server] Error stack:', error.stack); @@ -181,12 +137,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => { const scope = req.body?.scope; deleteAgent(agentName, directory, scope); - const refreshResult = await refreshOpenCodeAfterConfigChange('agent deletion'); - - res.json(buildConfigMutationResponse(refreshResult, { - liveMessage: `Agent ${agentName} deleted successfully. Reloading interface…`, - manualRestartMessage: `Agent ${agentName} deleted. Restart your connected OpenCode server to apply the change.`, - })); + res.json(buildDeferredRestartResponse( + `Agent ${agentName} deleted successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to delete agent:', error); res.status(500).json({ error: error.message || 'Failed to delete agent' }); @@ -323,16 +276,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Scope:', scope, 'Working directory:', directory); createCommand(commandName, config, directory, scope); - await refreshOpenCodeAfterConfigChange('command creation', { - commandName - }); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} created successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Command ${commandName} created successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to create command:', error); res.status(500).json({ error: error.message || 'Failed to create command' }); @@ -353,16 +299,12 @@ export const registerConfigEntityRoutes = (app, dependencies) => { console.log('[Server] Working directory:', directory); updateCommand(commandName, updates, directory); - await refreshOpenCodeAfterConfigChange('command update'); console.log(`[Server] Command ${commandName} updated successfully`); - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} updated successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Command ${commandName} updated successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('[Server] Failed to update command:', error); console.error('[Server] Error stack:', error.stack); @@ -379,14 +321,9 @@ export const registerConfigEntityRoutes = (app, dependencies) => { } deleteCommand(commandName, directory); - await refreshOpenCodeAfterConfigChange('command deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Command ${commandName} deleted successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Command ${commandName} deleted successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to delete command:', error); res.status(500).json({ error: error.message || 'Failed to delete command' }); diff --git a/packages/web/server/lib/opencode/config-mutation-response.js b/packages/web/server/lib/opencode/config-mutation-response.js new file mode 100644 index 00000000..33c2791d --- /dev/null +++ b/packages/web/server/lib/opencode/config-mutation-response.js @@ -0,0 +1,44 @@ +/** + * Shared response shapes for OpenCode config mutations. + * + * Settings writes persist to disk immediately but defer the OpenCode restart + * so the UI can accumulate pending changes and apply them once via + * POST /api/config/reload ("Apply & Restart OpenCode"). + */ + +export function buildDeferredRestartResponse(message) { + return { + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message, + }; +} + +export function buildExternalManualRestartResponse(message) { + return { + success: true, + requiresReload: false, + requiresManualRestart: true, + message, + }; +} + +export function buildConfigMutationResponse(refreshResult, { liveMessage, manualRestartMessage, deferredMessage }) { + if (refreshResult && refreshResult.external) { + return buildExternalManualRestartResponse(manualRestartMessage); + } + + // When callers skip the live refresh (deferred apply flow), report a pending restart. + if (!refreshResult || refreshResult.deferred === true) { + return buildDeferredRestartResponse(deferredMessage || liveMessage); + } + + return { + success: true, + requiresReload: true, + message: liveMessage, + reloadDelayMs: refreshResult.reloadDelayMs, + }; +} diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index e9fd8de2..3b735169 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1,3 +1,5 @@ +import { buildExternalManualRestartResponse } from './config-mutation-response.js'; + const parseLoopbackUrl = (rawUrl) => { if (typeof rawUrl !== 'string') { return null; @@ -1024,7 +1026,13 @@ export const registerSettingsUtilityRoutes = (app, dependencies) => { try { console.log('[Server] Manual configuration reload requested'); - await refreshOpenCodeAfterConfigChange('manual configuration reload'); + const refreshResult = await refreshOpenCodeAfterConfigChange('manual configuration reload'); + + if (refreshResult?.external) { + return res.json(buildExternalManualRestartResponse( + 'Configuration is saved on disk. Restart your connected OpenCode server to apply the changes.', + )); + } res.json({ success: true, diff --git a/packages/web/server/lib/opencode/plugin-routes.js b/packages/web/server/lib/opencode/plugin-routes.js index 5046fdbf..b9352f25 100644 --- a/packages/web/server/lib/opencode/plugin-routes.js +++ b/packages/web/server/lib/opencode/plugin-routes.js @@ -3,6 +3,7 @@ import os from 'os'; import { getNpmInfo as defaultGetNpmInfo } from './npm-registry.js'; import { isExactSemver as defaultIsExactSemver, isPathSpec as defaultIsPathSpec, parseNpmSpec as defaultParseNpmSpec, parsePathSpec as defaultParsePathSpec } from './plugin-spec.js'; +import { buildDeferredRestartResponse } from './config-mutation-response.js'; const ENTRY_EXISTS_CODES = new Set(['ENTRY_EXISTS', 'EEXIST']); const FILE_EXISTS_CODES = new Set(['FILE_EXISTS', 'EEXIST']); @@ -12,8 +13,6 @@ const BAD_REQUEST_CODES = new Set(['INVALID_FILENAME', 'INVALID_SCOPE', 'INVALID export const registerPluginRoutes = (app, dependencies) => { const { resolveOptionalProjectDirectory, - refreshOpenCodeAfterConfigChange, - clientReloadDelayMs, listPluginEntries, getPluginEntry, createPluginEntry, @@ -43,34 +42,13 @@ export const registerPluginRoutes = (app, dependencies) => { return directory || null; }; - const successPayload = (message) => ({ - success: true, - requiresReload: true, - message, - reloadDelayMs: clientReloadDelayMs, - reloadFailed: false, - warning: undefined, - }); - const completePluginMutation = async (res, operation, _noun, applyChange) => { applyChange(); const pastTense = operation.replace(/ion$/, 'ed').replace(/update$/, 'updated'); - - try { - await refreshOpenCodeAfterConfigChange(`plugin ${operation}`); - return res.json(successPayload(`Plugin ${pastTense}. Reloading interface…`)); - } catch (error) { - console.error(`[API:plugin ${operation}] Reload failed after config write:`, error); - return res.json({ - success: true, - requiresReload: false, - message: `Plugin ${pastTense}, but OpenCode reload failed.`, - reloadDelayMs: clientReloadDelayMs, - reloadFailed: true, - warning: error.message || 'OpenCode reload failed after plugin config changed', - }); - } + return res.json(buildDeferredRestartResponse( + `Plugin ${pastTense}. Restart OpenCode to apply.`, + )); }; const validateEntryId = (id) => { diff --git a/packages/web/server/lib/opencode/plugin-routes.test.js b/packages/web/server/lib/opencode/plugin-routes.test.js index 4aaf7404..ca00a94b 100644 --- a/packages/web/server/lib/opencode/plugin-routes.test.js +++ b/packages/web/server/lib/opencode/plugin-routes.test.js @@ -263,11 +263,17 @@ describe('opencode plugin routes', () => { expect(response.body.results[0]).toMatchObject({ kind: 'npm-ok', spec: '@scope/foo@1.0.0', name: '@scope/foo' }); }); - test('POST /entry creates entry and requires reload', async () => { + test('POST /entry creates entry and defers restart', async () => { const response = await createEntry('a'); - expect(response.body).toMatchObject({ success: true, requiresReload: true, reloadDelayMs: 25 }); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry creation'); + expect(response.body).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin entry created. Restart OpenCode to apply.', + }); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('GET after POST returns created entry', async () => { @@ -300,9 +306,15 @@ describe('opencode plugin routes', () => { .expect(200); expect(response.body.success).toBe(true); + expect(response.body).toMatchObject({ + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin entry updated. Restart OpenCode to apply.', + }); const after = await request(app).get('/api/config/plugins').expect(200); expect(after.body.entries[0]).toEqual(expect.objectContaining({ spec: 'b', scope: 'user' })); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry update'); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('DELETE /entry/:id removes entry and prunes plugin key', async () => { @@ -310,20 +322,33 @@ describe('opencode plugin routes', () => { const listed = await request(app).get('/api/config/plugins').expect(200); const id = listed.body.entries[0].id; - await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200); + const response = await request(app).delete(`/api/config/plugins/entry/${encodeURIComponent(id)}`).expect(200); const after = await request(app).get('/api/config/plugins').expect(200); expect(after.body.entries).toEqual([]); expect(readJson(userConfigPath).plugin).toBeUndefined(); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin entry deletion'); + expect(response.body).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin entry deleted. Restart OpenCode to apply.', + }); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('POST /file writes plugin dir file', async () => { const response = await createFile('test.js', '//x'); - expect(response.body).toMatchObject({ success: true, requiresReload: true }); + expect(response.body).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin file created. Restart OpenCode to apply.', + }); expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//x'); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file creation'); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('POST duplicate file returns 409', async () => { @@ -342,13 +367,20 @@ describe('opencode plugin routes', () => { const listed = await request(app).get('/api/config/plugins').expect(200); const id = listed.body.files[0].id; - await request(app) + const response = await request(app) .put(`/api/config/plugins/file/${encodeURIComponent(id)}`) .send({ content: '//y' }) .expect(200); expect(fs.readFileSync(path.join(rootDir, 'plugins', 'test.js'), 'utf8')).toBe('//y'); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file update'); + expect(response.body).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin file updated. Restart OpenCode to apply.', + }); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('DELETE /file/:id unlinks file', async () => { @@ -356,10 +388,17 @@ describe('opencode plugin routes', () => { const listed = await request(app).get('/api/config/plugins').expect(200); const id = listed.body.files[0].id; - await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200); + const response = await request(app).delete(`/api/config/plugins/file/${encodeURIComponent(id)}`).expect(200); expect(fs.existsSync(path.join(rootDir, 'plugins', 'test.js'))).toBe(false); - expect(refreshOpenCodeAfterConfigChange).toHaveBeenCalledWith('plugin file deletion'); + expect(response.body).toMatchObject({ + success: true, + requiresReload: false, + requiresRestart: true, + restartDeferred: true, + message: 'Plugin file deleted. Restart OpenCode to apply.', + }); + expect(refreshOpenCodeAfterConfigChange).not.toHaveBeenCalled(); }); test('PATCH unknown entry id returns 404', async () => { diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index b78b550c..a24d1513 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -2,11 +2,13 @@ import { createProjectIdFromPath } from '../projects/project-id.js'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import { + buildDeferredRestartResponse, +} from './config-mutation-response.js'; export const registerOpenCodeRoutes = (app, dependencies) => { const { crypto, - clientReloadDelayMs, getOpenCodeResolutionSnapshot, getOpenCodeUpgradeCapability, formatSettingsResponse, @@ -489,15 +491,18 @@ export const registerOpenCodeRoutes = (app, dependencies) => { } if (removed) { - await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected (${scope})`); + return res.json({ + success: true, + removed, + ...buildDeferredRestartResponse('Provider disconnected successfully. Restart OpenCode to apply.'), + }); } return res.json({ success: true, removed, - requiresReload: removed, - message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', - reloadDelayMs: removed ? clientReloadDelayMs : undefined, + requiresReload: false, + message: 'Provider was not connected', }); } catch (error) { console.error('Failed to disconnect provider:', error); @@ -591,14 +596,9 @@ export const registerOpenCodeRoutes = (app, dependencies) => { await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8'); - // Refresh OpenCode so it picks up the new AGENTS.md without a full restart - try { - await refreshOpenCodeAfterConfigChange('global behavior (AGENTS.md) updated'); - } catch { - // Non-fatal: file was written successfully - } - - return res.json({ success: true }); + return res.json(buildDeferredRestartResponse( + 'AGENTS.md saved. Restart OpenCode to apply.', + )); } catch (error) { console.error('Failed to write AGENTS.md:', error); return res.status(500).json({ error: error.message || 'Failed to write AGENTS.md' }); diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index b66680a5..9ef6b1ae 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -1,4 +1,5 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2'; +import { buildDeferredRestartResponse } from './config-mutation-response.js'; export const registerSkillRoutes = (app, dependencies) => { const { @@ -10,9 +11,8 @@ export const registerSkillRoutes = (app, dependencies) => { readSettingsFromDisk, sanitizeSkillCatalogs, isUnsafeSkillRelativePath, - refreshOpenCodeAfterConfigChange, - clientReloadDelayMs, buildOpenCodeUrl, + getOpenCodeAuthHeaders, getOpenCodePort, getSkillSources, @@ -444,19 +444,18 @@ export const registerSkillRoutes = (app, dependencies) => { const installed = result.installed || []; const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } + const requiresRestart = installed.length > 0; return res.json({ ok: true, installed, skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + ...(requiresRestart + ? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.') + : { + requiresReload: false, + message: 'No skills were installed', + }), }); } @@ -495,19 +494,18 @@ export const registerSkillRoutes = (app, dependencies) => { const installed = result.installed || []; const skipped = result.skipped || []; - const requiresReload = installed.length > 0; - - if (requiresReload) { - await refreshOpenCodeAfterConfigChange('skills install'); - } + const requiresRestart = installed.length > 0; res.json({ ok: true, installed, skipped, - requiresReload, - message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed', - reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined, + ...(requiresRestart + ? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.') + : { + requiresReload: false, + message: 'No skills were installed', + }), }); } catch (error) { console.error('Failed to install skills:', error); @@ -588,14 +586,9 @@ export const registerSkillRoutes = (app, dependencies) => { console.log('[Server] Scope:', scope, 'Working directory:', directory); createSkill(skillName, { ...config, source: skillSource }, directory, scope); - await refreshOpenCodeAfterConfigChange('skill creation'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} created successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Skill ${skillName} created successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to create skill:', error); res.status(500).json({ error: error.message || 'Failed to create skill' }); @@ -615,14 +608,9 @@ export const registerSkillRoutes = (app, dependencies) => { console.log('[Server] Working directory:', directory); updateSkill(skillName, updates, directory, updates?.targetPath); - await refreshOpenCodeAfterConfigChange('skill update'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} updated successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Skill ${skillName} updated successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('[Server] Failed to update skill:', error); res.status(500).json({ error: error.message || 'Failed to update skill' }); @@ -707,14 +695,9 @@ export const registerSkillRoutes = (app, dependencies) => { } deleteSkill(skillName, directory); - await refreshOpenCodeAfterConfigChange('skill deletion'); - - res.json({ - success: true, - requiresReload: true, - message: `Skill ${skillName} deleted successfully. Reloading interface…`, - reloadDelayMs: clientReloadDelayMs, - }); + res.json(buildDeferredRestartResponse( + `Skill ${skillName} deleted successfully. Restart OpenCode to apply.`, + )); } catch (error) { console.error('Failed to delete skill:', error); res.status(500).json({ error: error.message || 'Failed to delete skill' });