feat(mcp): reconnect failed MCP servers in managed OpenCode
OpenCode marks an MCP server failed when it does not come up at startup or when a live connection drops, and never retries. A new managed plugin reconnects those servers with a per-server backoff (1s doubling to a 30s cap), wakes early on mcp.tools.changed, and stops on dispose. Only the failed state is retried; disabled and auth states stay untouched. The OPENCODE_CONFIG_CONTENT merge that agent-tool and system-prompt each carried is now one shared helper so the three managed plugins compose. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
This commit is contained in:
@@ -111,6 +111,7 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js';
|
|||||||
import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
|
import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
|
||||||
import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
|
import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
|
||||||
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
|
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
|
||||||
|
import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js';
|
||||||
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
|
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
|
||||||
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
|
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
|
||||||
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
|
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
|
||||||
@@ -285,6 +286,7 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
|
|||||||
let notificationTemplateRuntime = null;
|
let notificationTemplateRuntime = null;
|
||||||
let agentToolRuntime = null;
|
let agentToolRuntime = null;
|
||||||
let systemPromptRuntime = null;
|
let systemPromptRuntime = null;
|
||||||
|
let mcpReconnectRuntime = null;
|
||||||
|
|
||||||
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
|
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
|
||||||
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
|
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
|
||||||
@@ -1222,11 +1224,15 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
|||||||
const managedEnv = includeControl || includeWeb || includeMemory
|
const managedEnv = includeControl || includeWeb || includeMemory
|
||||||
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
|
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
|
||||||
: {};
|
: {};
|
||||||
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
|
|
||||||
|
|
||||||
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
// Each managed plugin appends itself to the config the previous one produced.
|
||||||
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
|
let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
||||||
return { ...managedEnv, ...systemPromptEnv };
|
if (settings?.optimizeSystemPrompt === true) {
|
||||||
|
({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
|
||||||
|
}
|
||||||
|
// Always on for managed OpenCode: it only retries servers OpenCode gave up on.
|
||||||
|
const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent);
|
||||||
|
return { ...managedEnv, ...mcpReconnectEnv };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1500,6 +1506,11 @@ async function main(options = {}) {
|
|||||||
path,
|
path,
|
||||||
dataDir: OPENCHAMBER_DATA_DIR,
|
dataDir: OPENCHAMBER_DATA_DIR,
|
||||||
});
|
});
|
||||||
|
mcpReconnectRuntime = createMcpReconnectRuntime({
|
||||||
|
fsPromises,
|
||||||
|
path,
|
||||||
|
dataDir: OPENCHAMBER_DATA_DIR,
|
||||||
|
});
|
||||||
|
|
||||||
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
||||||
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parse as parseJsonc } from 'jsonc-parser';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
|
||||||
import {
|
import {
|
||||||
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
|
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
|
||||||
OPENCHAMBER_AGENT_TOOL_ACTIONS,
|
OPENCHAMBER_AGENT_TOOL_ACTIONS,
|
||||||
@@ -252,23 +252,6 @@ ${entries.join('')} },
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mergePluginConfig = (rawConfig, pluginUrl) => {
|
|
||||||
const errors = [];
|
|
||||||
const parsed = asNonEmptyString(rawConfig) ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) : {};
|
|
||||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its managed tool');
|
|
||||||
}
|
|
||||||
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
|
||||||
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its managed tool');
|
|
||||||
}
|
|
||||||
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
|
||||||
parsed.plugin = [
|
|
||||||
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
|
||||||
pluginUrl,
|
|
||||||
];
|
|
||||||
return JSON.stringify(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createAgentToolRuntime = (dependencies) => {
|
export const createAgentToolRuntime = (dependencies) => {
|
||||||
const {
|
const {
|
||||||
crypto,
|
crypto,
|
||||||
@@ -296,7 +279,7 @@ export const createAgentToolRuntime = (dependencies) => {
|
|||||||
activeToken = crypto.randomBytes(32).toString('base64url');
|
activeToken = crypto.randomBytes(32).toString('base64url');
|
||||||
const pluginUrl = pathToFileURL(pluginPath).href;
|
const pluginUrl = pathToFileURL(pluginPath).href;
|
||||||
return {
|
return {
|
||||||
OPENCODE_CONFIG_CONTENT: mergePluginConfig(env.OPENCODE_CONFIG_CONTENT, pluginUrl),
|
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(env.OPENCODE_CONFIG_CONTENT, pluginUrl, 'managed tool'),
|
||||||
OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`,
|
OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`,
|
||||||
OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken,
|
OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Managed MCP Reconnect
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
OpenCode connects each configured MCP server once, when a project directory is
|
||||||
|
first used. A server that does not come up then is marked `failed` and never
|
||||||
|
retried; a server whose live connection later drops is marked `failed` too and
|
||||||
|
stays that way until OpenCode restarts. This module injects a small plugin into
|
||||||
|
the OpenCode process OpenChamber launches that reconnects those servers, so a
|
||||||
|
server that was slow to start or crashed mid-session comes back on its own.
|
||||||
|
|
||||||
|
## Runtime flow
|
||||||
|
|
||||||
|
1. `prepareManagedOpenCodeEnv(configContent)` materializes the plugin under
|
||||||
|
`<openchamber-data-dir>/mcp-reconnect/` and appends its `file://` URL to
|
||||||
|
`OPENCODE_CONFIG_CONTENT` through the shared merge in
|
||||||
|
`packages/web/server/lib/opencode/managed-plugin-config.js`.
|
||||||
|
2. It is always on for managed OpenCode. There is no setting, because it only
|
||||||
|
acts on servers OpenCode has already given up on.
|
||||||
|
3. OpenCode loads the plugin once per project directory with an SDK client
|
||||||
|
scoped to that directory, so each directory reconnects its own servers.
|
||||||
|
4. The plugin reads MCP status one second after load, calls connect for every
|
||||||
|
server in the `failed` state, then re-reads status after a per-server delay
|
||||||
|
that doubles from one second to a cap of thirty. A server seen in any other
|
||||||
|
state resets its counter. While nothing is failed it checks every thirty
|
||||||
|
seconds.
|
||||||
|
5. A dropped connection publishes `mcp.tools.changed`, which the plugin uses to
|
||||||
|
check right away instead of waiting out the idle interval.
|
||||||
|
6. OpenCode calls the plugin's `dispose` hook when it tears the directory down,
|
||||||
|
which stops the loop.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
- Only `failed` is retried. `disabled` is the user's choice, and `needs_auth`
|
||||||
|
or `needs_client_registration` need the user to act; retrying those would
|
||||||
|
either re-enable a server the user turned off or loop on a login prompt.
|
||||||
|
- The plugin logs nothing and swallows every error. OpenCode already logs each
|
||||||
|
failed attempt, and a status call failing during an OpenCode restart is not
|
||||||
|
news.
|
||||||
|
- One check runs at a time; a wake-up arriving during a check is honored once
|
||||||
|
it finishes rather than starting a second loop.
|
||||||
|
|
||||||
|
## What the UI sees
|
||||||
|
|
||||||
|
OpenCode publishes no event when a reconnect succeeds. The chat picks the
|
||||||
|
server up on the next prompt because tools are resolved from live state, but
|
||||||
|
the MCP page reads status on bootstrap and refresh, so it can show `failed` for
|
||||||
|
a while after the server is back.
|
||||||
|
|
||||||
|
## Runtime parity
|
||||||
|
|
||||||
|
- Web and Desktop managed OpenCode: injected automatically.
|
||||||
|
- External OpenCode (`OPENCODE_HOST` or skip-start) and VS Code's separate
|
||||||
|
OpenCode lifecycle: not injected, because OpenChamber does not control that
|
||||||
|
process environment.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenCode marks an MCP server `failed` when it does not come up at startup or
|
||||||
|
* when a live connection drops, and never tries again. This plugin runs inside
|
||||||
|
* the managed OpenCode process and reconnects those servers with a per-server
|
||||||
|
* exponential backoff, so a server that was merely slow to start, or a local
|
||||||
|
* one that crashed, comes back without an OpenCode restart.
|
||||||
|
*
|
||||||
|
* Only `failed` is retried. `needs_auth`, `needs_client_registration`, and
|
||||||
|
* `disabled` are user decisions or need user action, and retrying them would
|
||||||
|
* either loop on a login prompt or re-enable a server the user turned off.
|
||||||
|
*
|
||||||
|
* The plugin talks to OpenCode through the SDK client OpenCode hands it, which
|
||||||
|
* is scoped to one project directory, so each open directory reconnects its
|
||||||
|
* own servers. Nothing is logged: OpenCode already reports each failed attempt.
|
||||||
|
*/
|
||||||
|
const createPluginSource = () => String.raw`
|
||||||
|
const INITIAL_RETRY_MS = 1000
|
||||||
|
const MAX_RETRY_MS = 30000
|
||||||
|
const IDLE_CHECK_MS = 30000
|
||||||
|
|
||||||
|
export const OpenChamberMcpReconnectPlugin = async ({ client }) => {
|
||||||
|
let disposed = false
|
||||||
|
let running = false
|
||||||
|
let wakeRequested = false
|
||||||
|
let timer
|
||||||
|
// Consecutive failed attempts per server, cleared once it is seen healthy.
|
||||||
|
const attempts = new Map()
|
||||||
|
const dueAt = new Map()
|
||||||
|
|
||||||
|
const schedule = (delayMs) => {
|
||||||
|
if (disposed) return
|
||||||
|
clearTimeout(timer)
|
||||||
|
timer = setTimeout(tick, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (running) {
|
||||||
|
wakeRequested = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
let delay = IDLE_CHECK_MS
|
||||||
|
try {
|
||||||
|
const statuses = (await client.mcp.status())?.data ?? {}
|
||||||
|
const now = Date.now()
|
||||||
|
const due = []
|
||||||
|
for (const [name, entry] of Object.entries(statuses)) {
|
||||||
|
if (entry?.status !== "failed") {
|
||||||
|
attempts.delete(name)
|
||||||
|
dueAt.delete(name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const at = dueAt.get(name) ?? now
|
||||||
|
if (at > now) {
|
||||||
|
delay = Math.min(delay, at - now)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
due.push(name)
|
||||||
|
}
|
||||||
|
for (const name of attempts.keys()) {
|
||||||
|
if (!Object.hasOwn(statuses, name)) {
|
||||||
|
attempts.delete(name)
|
||||||
|
dueAt.delete(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(due.map((name) => client.mcp.connect({ path: { name } })))
|
||||||
|
|
||||||
|
// The result is read on the next tick: a server that came back clears
|
||||||
|
// its counter there, one still failed waits out its backoff.
|
||||||
|
const after = Date.now()
|
||||||
|
for (const name of due) {
|
||||||
|
const count = (attempts.get(name) ?? 0) + 1
|
||||||
|
const wait = Math.min(INITIAL_RETRY_MS * 2 ** (count - 1), MAX_RETRY_MS)
|
||||||
|
attempts.set(name, count)
|
||||||
|
dueAt.set(name, after + wait)
|
||||||
|
delay = Math.min(delay, wait)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Status is unavailable while OpenCode is shutting down or restarting;
|
||||||
|
// the idle check picks up again when it is back.
|
||||||
|
} finally {
|
||||||
|
running = false
|
||||||
|
const wake = wakeRequested
|
||||||
|
wakeRequested = false
|
||||||
|
schedule(wake ? INITIAL_RETRY_MS : delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(INITIAL_RETRY_MS)
|
||||||
|
|
||||||
|
return {
|
||||||
|
// A dropped connection publishes this event; checking right away beats
|
||||||
|
// waiting out the idle interval.
|
||||||
|
event: async ({ event }) => {
|
||||||
|
if (event?.type === "mcp.tools.changed") schedule(INITIAL_RETRY_MS)
|
||||||
|
},
|
||||||
|
dispose: async () => {
|
||||||
|
disposed = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const createMcpReconnectRuntime = ({ fsPromises, path, dataDir }) => {
|
||||||
|
const pluginDirectory = path.join(dataDir, 'mcp-reconnect');
|
||||||
|
const pluginPath = path.join(pluginDirectory, 'openchamber-mcp-reconnect-plugin.js');
|
||||||
|
|
||||||
|
const prepareManagedOpenCodeEnv = async (rawConfig) => {
|
||||||
|
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
||||||
|
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
||||||
|
return {
|
||||||
|
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'MCP reconnect plugin'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { prepareManagedOpenCodeEnv };
|
||||||
|
};
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createMcpReconnectRuntime } from './runtime.js';
|
||||||
|
|
||||||
|
const temporaryDirectories = [];
|
||||||
|
const disposers = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(disposers.splice(0).map((dispose) => dispose()));
|
||||||
|
vi.useRealTimers();
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
const materialize = async (rawConfig = '{}') => {
|
||||||
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-mcp-reconnect-'));
|
||||||
|
temporaryDirectories.push(dataDir);
|
||||||
|
const runtime = createMcpReconnectRuntime({ fsPromises: fs, path, dataDir });
|
||||||
|
const prepared = await runtime.prepareManagedOpenCodeEnv(rawConfig);
|
||||||
|
const pluginPath = path.join(dataDir, 'mcp-reconnect', 'openchamber-mcp-reconnect-plugin.js');
|
||||||
|
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}-${Math.random()}`);
|
||||||
|
return { prepared, pluginPath, plugin: pluginModule.OpenChamberMcpReconnectPlugin };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in for the SDK client OpenCode hands to plugins. `statuses` is live
|
||||||
|
* state the test mutates; `connect` records when each attempt happened.
|
||||||
|
*/
|
||||||
|
const createClient = (statuses, { onConnect } = {}) => {
|
||||||
|
const attempts = [];
|
||||||
|
return {
|
||||||
|
attempts,
|
||||||
|
statuses,
|
||||||
|
mcp: {
|
||||||
|
status: vi.fn(async () => ({ data: { ...statuses } })),
|
||||||
|
connect: vi.fn(async ({ path: { name } }) => {
|
||||||
|
attempts.push({ name, at: Date.now() });
|
||||||
|
onConnect?.(name);
|
||||||
|
return { data: true };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = async (plugin, client) => {
|
||||||
|
const hooks = await plugin({ client });
|
||||||
|
disposers.push(hooks.dispose);
|
||||||
|
return hooks;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('managed MCP reconnect runtime', () => {
|
||||||
|
it('materializes the plugin and preserves existing plugin entries', async () => {
|
||||||
|
const { prepared, pluginPath } = await materialize('{ "plugin": ["file:///existing.js"], "model": "test/model" }');
|
||||||
|
const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT);
|
||||||
|
expect(config.model).toBe('test/model');
|
||||||
|
expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconnects only servers in the failed state', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { plugin } = await materialize();
|
||||||
|
const client = createClient({
|
||||||
|
broken: { status: 'failed', error: 'spawn ENOENT' },
|
||||||
|
healthy: { status: 'connected' },
|
||||||
|
off: { status: 'disabled' },
|
||||||
|
login: { status: 'needs_auth' },
|
||||||
|
registration: { status: 'needs_client_registration', error: 'no client id' },
|
||||||
|
});
|
||||||
|
await start(plugin, client);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(client.attempts.map((attempt) => attempt.name)).toEqual(['broken']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backs off per server up to a cap while it keeps failing', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { plugin } = await materialize();
|
||||||
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||||||
|
const started = Date.now();
|
||||||
|
await start(plugin, client);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(92_000);
|
||||||
|
|
||||||
|
expect(client.attempts.map((attempt) => attempt.at - started)).toEqual([
|
||||||
|
1000, 2000, 4000, 8000, 16_000, 32_000, 62_000, 92_000,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops retrying once a server is back and starts fresh when it drops again', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { plugin } = await materialize();
|
||||||
|
const statuses = { flaky: { status: 'failed', error: 'refused' } };
|
||||||
|
const client = createClient(statuses, {
|
||||||
|
onConnect: () => { statuses.flaky = { status: 'connected' }; },
|
||||||
|
});
|
||||||
|
const hooks = await start(plugin, client);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000);
|
||||||
|
expect(client.attempts).toHaveLength(1);
|
||||||
|
|
||||||
|
statuses.flaky = { status: 'failed', error: 'Connection closed' };
|
||||||
|
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'flaky' } } });
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
|
||||||
|
expect(client.attempts).toHaveLength(2);
|
||||||
|
expect(client.attempts[1].at - client.attempts[0].at).toBeGreaterThan(30_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps going when status is temporarily unavailable', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { plugin } = await materialize();
|
||||||
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||||||
|
client.mcp.status.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||||
|
await start(plugin, client);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(client.attempts).toHaveLength(0);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(30_000);
|
||||||
|
expect(client.attempts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing after dispose', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { plugin } = await materialize();
|
||||||
|
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
|
||||||
|
const hooks = await plugin({ client });
|
||||||
|
|
||||||
|
await hooks.dispose();
|
||||||
|
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'broken' } } });
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
|
||||||
|
expect(client.mcp.status).not.toHaveBeenCalled();
|
||||||
|
expect(client.attempts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru
|
|||||||
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
|
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
|
||||||
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
|
||||||
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
|
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
|
||||||
|
- `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
|
||||||
|
- `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
|
||||||
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
|
||||||
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
|
||||||
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
|
||||||
@@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit
|
|||||||
- `killProcessOnPort(port)`
|
- `killProcessOnPort(port)`
|
||||||
|
|
||||||
Managed OpenCode launch also merges the environment returned by the agent-tool
|
Managed OpenCode launch also merges the environment returned by the agent-tool
|
||||||
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
|
||||||
|
plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
|
||||||
be replaced by injected values. External OpenCode processes receive no
|
be replaced by injected values. External OpenCode processes receive no
|
||||||
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
|
||||||
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { parse as parseJsonc } from 'jsonc-parser';
|
||||||
|
|
||||||
|
const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a managed OpenChamber plugin to the `plugin` list of an
|
||||||
|
* `OPENCODE_CONFIG_CONTENT` value.
|
||||||
|
*
|
||||||
|
* Existing entries are preserved; an earlier entry for the same URL is dropped
|
||||||
|
* so a restart never registers the same plugin twice. Every managed plugin
|
||||||
|
* (agent tools, system prompt optimizer, MCP reconnect) goes through this one
|
||||||
|
* merge so they compose in any order.
|
||||||
|
*
|
||||||
|
* @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
|
||||||
|
* @param {string} pluginUrl `file://` URL of the materialized plugin
|
||||||
|
* @param {string} purpose short noun phrase for the error message, e.g. "managed tool"
|
||||||
|
* @returns {string} the merged config as JSON
|
||||||
|
*/
|
||||||
|
export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => {
|
||||||
|
const errors = [];
|
||||||
|
const text = (rawConfig ?? '').trim();
|
||||||
|
const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {};
|
||||||
|
if (errors.length > 0 || !isJsonObject(parsed)) {
|
||||||
|
throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`);
|
||||||
|
}
|
||||||
|
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
||||||
|
throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`);
|
||||||
|
}
|
||||||
|
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
||||||
|
parsed.plugin = [
|
||||||
|
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
||||||
|
pluginUrl,
|
||||||
|
];
|
||||||
|
return JSON.stringify(parsed);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { parse as parseJsonc } from 'jsonc-parser';
|
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
|
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
|
||||||
|
|
||||||
const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
|
const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
|
||||||
const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
|
const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
|
||||||
@@ -33,25 +33,6 @@ export const OpenChamberSystemPromptPlugin = async () => ({
|
|||||||
})
|
})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const mergePluginConfig = (rawConfig, pluginUrl) => {
|
|
||||||
const errors = [];
|
|
||||||
const parsed = typeof rawConfig === 'string' && rawConfig.trim()
|
|
||||||
? parseJsonc(rawConfig, errors, { allowTrailingComma: true })
|
|
||||||
: {};
|
|
||||||
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer');
|
|
||||||
}
|
|
||||||
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
|
|
||||||
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer');
|
|
||||||
}
|
|
||||||
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
|
|
||||||
parsed.plugin = [
|
|
||||||
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
|
|
||||||
pluginUrl,
|
|
||||||
];
|
|
||||||
return JSON.stringify(parsed);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
||||||
const pluginDirectory = path.join(dataDir, 'system-prompt');
|
const pluginDirectory = path.join(dataDir, 'system-prompt');
|
||||||
const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
|
const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
|
||||||
@@ -60,7 +41,7 @@ export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
|
|||||||
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
||||||
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
||||||
return {
|
return {
|
||||||
OPENCODE_CONFIG_CONTENT: mergePluginConfig(rawConfig, pathToFileURL(pluginPath).href),
|
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'system prompt optimizer'),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user