Defer OpenCode restarts for config mutations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 06:40:01 +00:00
co-authored by Serhii Dziupin
parent 824d1fbbf4
commit 775da6e9f4
11 changed files with 272 additions and 323 deletions
@@ -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' });
@@ -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,
};
}
@@ -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,
@@ -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) => {
@@ -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 () => {
+13 -13
View File
@@ -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' });
@@ -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' });