Files
makelore/electron/api/routes/opencode.ts
brother7 e97a7ce9df feat: 增加共享 Agent Browser 调试能力
需求:在 AI 编程会话中让用户与 Agent 共享同一浏览器页面,并查看控制台与网络信息。

实现:新增沙箱浏览器内核、Host API/渲染器面板、OpenCode 工具接入及安全边界测试。
2026-07-31 14:53:36 +08:00

1806 lines
66 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from 'http';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { HostApiContext } from '../context';
import { importCurrentUserModelConfig } from './providers';
import { flushStreamingHeaders, parseJsonBody, sendJson, writeStreamingChunk } from '../route-utils';
import {
createOpencodeClient,
decorateOpencodeRequest,
type OpencodeCommandInfo,
type OpencodeCommandPartInput,
type OpencodeFilePartInput,
type OpencodePermissionReply,
type RevertOpencodeSessionMessageInput,
type SendOpencodeSessionMessageInput,
} from '../../opencode/client';
import { readWorksPublishFile } from '../../opencode/works-publish-file';
import { readWorksDeployCheck } from '../../opencode/works-square-deploy-check';
import {
buildOpencodeRuntimeConfigSummaryFromNianCodeProviders,
type OpencodeRuntimeConfigSummary,
} from '../../opencode/provider-config';
import {
PLAYWRIGHT_MCP_SERVER_ID,
resolvePlaywrightMcpServer,
} from '../../opencode/playwright-mcp';
import { NIANCODE_USER_MODEL_ACCOUNT_ID } from '../../../shared/user-model-config';
import { listInstalledOpencodeSkills } from '../../opencode/skill-registry';
import { BUNDLED_COURSE_SKILL_IDS } from '../../opencode/superpowers';
import { logger } from '../../utils/logger';
import { getProviderService } from '../../services/providers/provider-service';
import { getValidWorksSquareAccessToken } from '../../services/works-square-session';
import { getHostApiToken } from '../server';
import {
listProjectKnowledge,
readProjectConfig,
writeProjectConfig,
type ProjectConfigReadResult,
} from '../../opencode/project-config';
import {
readProjectConversationState,
writeProjectConversationState,
} from '../../opencode/project-conversations';
import {
completeProjectSession,
removeProjectSessionMetadata,
patchProjectSessionMetadata,
upsertProjectSessionMetadata,
type ProjectConversationState,
} from '../../../shared/project-conversations';
import type { ProjectConfig } from '../../../shared/project-config';
import { initializeProjectDirectory } from '../../opencode/project-directory-initialization';
const bundledCourseSkillIds = new Set<string>(BUNDLED_COURSE_SKILL_IDS);
type ActiveProjectClient = ReturnType<typeof createOpencodeClient>;
type OpencodePromptModel = NonNullable<SendOpencodeSessionMessageInput['model']>;
type RuntimeStatus = ReturnType<HostApiContext['opencodeManager']['getStatus']>;
interface ExpectedUserModelProxyConfig {
usesLocalProxy: boolean;
baseUrl: string;
modelIds: string[];
imageInputModelIds: string[];
modelLimits: Record<string, { context: number; output: number }>;
}
const LOCAL_AI_PROXY_PATH = '/api/ai-proxy/v1';
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
const DIRECT_WORKS_SQUARE_CREDENTIAL_REFRESH_SKEW_MS = 2 * 60 * 1000;
const BASE64_DATA_URL_PATTERN = /^data:[^,]+;base64,/i;
const COMMAND_TOKEN_PATTERN = /^[^\s/][^\s]*$/u;
const COMMAND_CONTROL_PATTERN = /\p{Cc}/u;
const COMMAND_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
const COMMAND_MODEL_PATTERN = /^[^/\s]{1,128}\/[^/\s]{1,128}$/u;
const MAX_COMMAND_NAME = 256;
const MAX_COMMAND_CONTEXT_TEXT = 2_000;
const MAX_COMMAND_PARTS = 32;
const MAX_COMMAND_ARGUMENTS = 8_192;
const MAX_COMMAND_FILE_URL_CHARS = 32 * 1024 * 1024;
const COMMAND_IMAGE_DATA_URL_PATTERN =
/^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]*={0,2})$/iu;
const STRICT_BASE64_PATTERN =
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u;
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function isValidCommandToken(value: string): boolean {
return value.length <= MAX_COMMAND_NAME
&& COMMAND_TOKEN_PATTERN.test(value)
&& !COMMAND_CONTROL_PATTERN.test(value);
}
function normalizeCommandInfo(value: unknown): OpencodeCommandInfo | null {
const record = asRecord(value);
if (!record) return null;
const name = typeof record.name === 'string' ? record.name.trim() : '';
if (!name || !isValidCommandToken(name)) return null;
return {
name,
...(typeof record.description === 'string' ? { description: record.description } : {}),
...(typeof record.agent === 'string' ? { agent: record.agent } : {}),
...(typeof record.model === 'string' ? { model: record.model } : {}),
...(record.source === 'command' || record.source === 'mcp' || record.source === 'skill'
? { source: record.source }
: {}),
...(typeof record.subtask === 'boolean' ? { subtask: record.subtask } : {}),
hints: Array.isArray(record.hints)
? record.hints.filter((hint): hint is string => typeof hint === 'string')
: [],
};
}
function normalizeCommandParts(value: unknown): OpencodeCommandPartInput[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length > MAX_COMMAND_PARTS) return null;
const parts: OpencodeCommandPartInput[] = [];
for (const item of value) {
const record = asRecord(item);
if (!record) return null;
if (record.type === 'text') {
if (record.synthetic !== true || typeof record.text !== 'string') return null;
if (
!record.text
|| record.text.length > MAX_COMMAND_CONTEXT_TEXT
|| record.text.includes('\0')
) {
return null;
}
parts.push({ type: 'text', text: record.text, synthetic: true });
continue;
}
if (record.type === 'file') {
const mime = typeof record.mime === 'string' ? record.mime.trim().toLowerCase() : '';
const url = typeof record.url === 'string' ? record.url.trim() : '';
const filename = typeof record.filename === 'string' ? record.filename.trim() : '';
const match = url.length <= MAX_COMMAND_FILE_URL_CHARS
? url.match(COMMAND_IMAGE_DATA_URL_PATTERN)
: null;
const base64 = match?.[2] ?? '';
if (
!mime.startsWith('image/')
|| !match
|| match[1].toLowerCase() !== mime
|| !base64
|| !STRICT_BASE64_PATTERN.test(base64)
) {
return null;
}
parts.push({
type: 'file',
mime,
url,
...(filename ? { filename: filename.slice(0, 255) } : {}),
});
continue;
}
return null;
}
return parts;
}
function parseRequiredCommandName(value: unknown): string | null {
if (typeof value !== 'string') return null;
const command = value.trim();
return command && isValidCommandToken(command) ? command : null;
}
function parseOptionalCommandIdentifier(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const candidate = value.trim();
return COMMAND_IDENTIFIER_PATTERN.test(candidate) ? candidate : undefined;
}
function parseOptionalCommandModel(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const candidate = value.trim();
return COMMAND_MODEL_PATTERN.test(candidate) ? candidate : undefined;
}
function normalizePromptFileParts(value: unknown): OpencodeFilePartInput[] {
if (!Array.isArray(value)) return [];
const files: OpencodeFilePartInput[] = [];
for (const item of value) {
const record = asRecord(item);
if (!record || record.type !== 'file') continue;
const mime = typeof record.mime === 'string' ? record.mime.trim() : '';
const url = typeof record.url === 'string' ? record.url.trim() : '';
if (!mime || !BASE64_DATA_URL_PATTERN.test(url)) continue;
const filename = typeof record.filename === 'string' ? record.filename.trim() : '';
files.push({
type: 'file',
mime,
url,
...(filename ? { filename } : {}),
});
}
return files;
}
function promptModelLogRef(model: OpencodePromptModel | undefined): string | null {
return model ? `${model.providerID}/${model.modelID}` : null;
}
function parseRuntimePromptModel(modelRef: unknown): OpencodePromptModel | undefined {
if (typeof modelRef !== 'string') return undefined;
const trimmed = modelRef.trim();
if (!trimmed) return undefined;
const separatorIndex = trimmed.indexOf('/');
if (separatorIndex <= 0 || separatorIndex === trimmed.length - 1) return undefined;
const providerID = trimmed.slice(0, separatorIndex).trim();
const modelID = trimmed.slice(separatorIndex + 1).trim();
if (!providerID || !modelID) return undefined;
return { providerID, modelID };
}
async function buildNianCodeRuntimeConfigSummary(): Promise<OpencodeRuntimeConfigSummary> {
return await buildOpencodeRuntimeConfigSummaryFromNianCodeProviders({
mcpServers: {
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
},
});
}
function publicRuntimeConfigSummary(summary: OpencodeRuntimeConfigSummary) {
return {
...summary,
...(summary.providers ? {
providers: summary.providers.map(({ baseURL: _baseURL, ...provider }) => provider),
} : {}),
};
}
async function loadRuntimeConfigSummaryForPrompt(): Promise<OpencodeRuntimeConfigSummary | undefined> {
try {
return await buildNianCodeRuntimeConfigSummary();
} catch (error) {
logger.warn('[opencode-route] Failed to resolve runtime prompt model', error);
return undefined;
}
}
let directWorksSquareCredentialRefreshPromise: Promise<void> | null = null;
async function refreshDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
const account = await getProviderService().getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (account?.metadata?.worksSquareCredentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
return;
}
const expiresAt = Date.parse(account.metadata.worksSquareCredentialExpiresAt ?? '');
if (
Number.isFinite(expiresAt)
&& expiresAt > Date.now() + DIRECT_WORKS_SQUARE_CREDENTIAL_REFRESH_SKEW_MS
) {
return;
}
const accessToken = await getValidWorksSquareAccessToken();
if (!accessToken) {
throw new Error('Works Square access token unavailable; please log in again');
}
await importCurrentUserModelConfig(ctx, accessToken);
logger.info('[opencode-route] Refreshed direct Works Square AI gateway credential before prompt');
}
async function ensureDirectWorksSquareCredentialBeforePrompt(ctx: HostApiContext): Promise<void> {
if (!directWorksSquareCredentialRefreshPromise) {
directWorksSquareCredentialRefreshPromise = refreshDirectWorksSquareCredentialBeforePrompt(ctx)
.finally(() => {
directWorksSquareCredentialRefreshPromise = null;
});
}
await directWorksSquareCredentialRefreshPromise;
}
async function resolveRuntimePromptModel(
summary?: OpencodeRuntimeConfigSummary,
): Promise<OpencodePromptModel | undefined> {
if (summary) {
return parseRuntimePromptModel(summary.model);
}
return parseRuntimePromptModel((await loadRuntimeConfigSummaryForPrompt())?.model);
}
function normalizeRuntimeBaseUrl(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim().replace(/\/+$/, '');
return trimmed || null;
}
function providerBaseUrlFromRuntimeConfig(config: unknown, providerId: string): string | null {
const provider = providerFromRuntimeConfig(config, providerId);
if (!provider) return null;
const options = provider.options;
if (!options || typeof options !== 'object' || Array.isArray(options)) return null;
return normalizeRuntimeBaseUrl((options as Record<string, unknown>).baseURL);
}
function providerFromRuntimeConfig(config: unknown, providerId: string): Record<string, unknown> | null {
if (!config || typeof config !== 'object' || Array.isArray(config)) return null;
const providers = (config as Record<string, unknown>).provider;
if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return null;
const provider = (providers as Record<string, unknown>)[providerId];
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) return null;
return provider as Record<string, unknown>;
}
function providerModelsFromRuntimeConfig(config: unknown, providerId: string): Record<string, unknown> | null {
const provider = providerFromRuntimeConfig(config, providerId);
return asRecord(provider?.models);
}
function runtimeModelHasImageInput(models: Record<string, unknown>, modelId: string): boolean {
const model = asRecord(models[modelId]);
if (!model) return false;
const modalities = asRecord(model.modalities);
const modalityInput = Array.isArray(modalities?.input) ? modalities.input : [];
if (modalityInput.some((item) => typeof item === 'string' && item.toLowerCase() === 'image')) {
return true;
}
const capabilities = asRecord(model.capabilities);
const capabilityInput = asRecord(capabilities?.input);
return capabilityInput?.image === true;
}
function runtimeModelHasExpectedLimit(
models: Record<string, unknown>,
modelId: string,
expected: { context: number; output: number },
): boolean {
const model = asRecord(models[modelId]);
const limit = asRecord(model?.limit);
return limit?.context === expected.context && limit?.output === expected.output;
}
function isLocalAiProxyBaseUrl(baseUrl: string): boolean {
if (baseUrl.includes('?') || baseUrl.includes('#')) return false;
const rawAuthority = baseUrl.match(/^http:\/\/([^/]+)(?:\/|$)/i)?.[1];
if (!rawAuthority || !/^(?:127\.0\.0\.1|\[::1\])(?::\d+)?$/.test(rawAuthority)) {
return false;
}
try {
const url = new URL(baseUrl);
const isLiteralLoopback = url.hostname === '127.0.0.1' || url.hostname === '[::1]';
return (
url.protocol === 'http:'
&& isLiteralLoopback
&& url.pathname.replace(/\/+$/, '') === LOCAL_AI_PROXY_PATH
&& url.username === ''
&& url.password === ''
&& url.search === ''
&& url.hash === ''
);
} catch {
return false;
}
}
function expectedUserModelProxyConfig(
summary: OpencodeRuntimeConfigSummary | undefined,
): ExpectedUserModelProxyConfig | null {
const providers = Array.isArray(summary?.providers) ? summary.providers : [];
const provider = providers.find((item) => item.id === NIANCODE_USER_MODEL_ACCOUNT_ID);
if (!provider) return null;
const configuredBaseUrl = normalizeRuntimeBaseUrl(provider.baseURL);
if (!configuredBaseUrl) return null;
const usesLocalProxy = isLocalAiProxyBaseUrl(configuredBaseUrl);
const modelLimits = Object.fromEntries(
Object.entries(provider.modelLimits ?? {}).map(([modelId, limit]) => [
modelId,
{ ...limit },
]),
);
if (!usesLocalProxy && Object.keys(modelLimits).length === 0) return null;
const selectedModel = parseRuntimePromptModel(summary?.model);
const modelIds = new Set(provider.modelIds ?? []);
if (selectedModel?.providerID === NIANCODE_USER_MODEL_ACCOUNT_ID) {
modelIds.add(selectedModel.modelID);
}
return {
usesLocalProxy,
baseUrl: configuredBaseUrl,
modelIds: [...modelIds],
imageInputModelIds: [...new Set(provider.imageInputModelIds ?? [])],
modelLimits,
};
}
async function rebindLocalProxyHostApiTokenIfNeeded(expectedBaseUrl: string): Promise<boolean> {
const providerService = getProviderService();
const account = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
const accountBaseUrl = normalizeRuntimeBaseUrl(account?.baseUrl);
if (!account || accountBaseUrl !== expectedBaseUrl) {
return false;
}
const currentHostApiToken = getHostApiToken();
if (!currentHostApiToken) {
return false;
}
const existingApiKey = await providerService.getAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (existingApiKey === currentHostApiToken) {
return false;
}
await providerService.updateAccount(
NIANCODE_USER_MODEL_ACCOUNT_ID,
account,
currentHostApiToken,
);
return true;
}
async function runtimeUsesExpectedUserModelProxy(
status: RuntimeStatus,
directory: string,
expectedConfig: ExpectedUserModelProxyConfig,
): Promise<boolean> {
if (status.state !== 'running' || !status.url) return false;
const client = createOpencodeClient({
baseUrl: status.url,
directory,
});
const runtimeConfig = await client.getConfig();
const actualBaseUrl = providerBaseUrlFromRuntimeConfig(
runtimeConfig,
NIANCODE_USER_MODEL_ACCOUNT_ID,
);
if (actualBaseUrl !== expectedConfig.baseUrl) return false;
const actualModels = providerModelsFromRuntimeConfig(runtimeConfig, NIANCODE_USER_MODEL_ACCOUNT_ID);
if (!actualModels) return Object.keys(expectedConfig.modelLimits).length === 0;
const actualModelIds = Object.keys(actualModels);
if (!expectedConfig.modelIds.every((modelId) => actualModelIds.includes(modelId))) return false;
if (
!expectedConfig.imageInputModelIds.every(
(modelId) => runtimeModelHasImageInput(actualModels, modelId),
)
) {
return false;
}
return Object.entries(expectedConfig.modelLimits).every(([modelId, limit]) =>
runtimeModelHasExpectedLimit(actualModels, modelId, limit),
);
}
async function ensureRuntimeUserModelProxyConfig(
res: ServerResponse,
ctx: HostApiContext,
status: RuntimeStatus,
directory: string,
summary?: OpencodeRuntimeConfigSummary,
): Promise<RuntimeStatus | null> {
let expectedConfig: ExpectedUserModelProxyConfig | null;
try {
expectedConfig = expectedUserModelProxyConfig(
summary ?? await buildNianCodeRuntimeConfigSummary(),
);
} catch (error) {
logger.warn('[opencode-route] Failed to inspect generated runtime provider config', error);
return status;
}
if (!expectedConfig) return status;
try {
if (
expectedConfig.usesLocalProxy
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl)
) {
logger.warn('[opencode-route] Restarting runtime because local AI proxy Host API token changed');
const restartedStatus = await ctx.opencodeManager.restart();
if (restartedStatus.state !== 'running' || !restartedStatus.url) {
sendJson(res, 409, {
success: false,
error: 'Runtime restarted but is not running',
});
return null;
}
status = restartedStatus;
}
} catch (error) {
logger.warn('[opencode-route] Failed to rebind local AI proxy Host API token', error);
}
try {
if (await runtimeUsesExpectedUserModelProxy(status, directory, expectedConfig)) {
return status;
}
} catch (error) {
logger.warn('[opencode-route] Failed to inspect running runtime provider config', error);
}
logger.warn('[opencode-route] Restarting runtime because user model provider config is stale', {
expectedModelIds: expectedConfig.modelIds,
expectedImageInputModelIds: expectedConfig.imageInputModelIds,
expectedLimitedModelIds: Object.keys(expectedConfig.modelLimits),
});
const restartedStatus = await ctx.opencodeManager.restart();
if (restartedStatus.state !== 'running' || !restartedStatus.url) {
sendJson(res, 409, {
success: false,
error: 'Runtime restarted but is not running',
});
return null;
}
try {
if (await runtimeUsesExpectedUserModelProxy(restartedStatus, directory, expectedConfig)) {
return restartedStatus;
}
} catch (error) {
logger.warn('[opencode-route] Failed to inspect restarted runtime provider config', error);
}
sendJson(res, 409, {
success: false,
error: 'Runtime provider configuration is stale. Please stop the runtime and start it again.',
});
return null;
}
function buildNewProjectPath(parentPath: string | undefined, projectName: string | undefined): string {
const normalizedParent = parentPath?.trim();
const normalizedName = projectName?.trim();
if (!normalizedParent) throw new Error('Missing project parent path');
if (!normalizedName) throw new Error('Missing project name');
if (normalizedName === '.' || normalizedName === '..') throw new Error('Invalid project name');
if (path.isAbsolute(normalizedName) || normalizedName.includes('/') || normalizedName.includes('\\') || normalizedName.includes('\0')) {
throw new Error('Invalid project name');
}
const parent = path.resolve(normalizedParent);
const target = path.resolve(parent, normalizedName);
const relative = path.relative(parent, target);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error('Invalid project name');
}
return target;
}
function buildSelectedProjectPath(projectPath: string | undefined): string {
const normalizedPath = projectPath?.trim();
if (!normalizedPath) throw new Error('Missing selected project path');
if (normalizedPath.includes('\0')) throw new Error('Invalid selected project path');
const target = path.resolve(normalizedPath);
if (target === path.parse(target).root) {
throw new Error('The filesystem root cannot be used as a project folder');
}
return target;
}
async function sendProjectSnapshot(
res: ServerResponse,
ctx: HostApiContext,
extra?: Record<string, unknown>,
) {
const [projects, activeProject] = await Promise.all([
ctx.opencodeProjectStore.listProjects(),
ctx.opencodeProjectStore.getActiveProject(),
]);
const validatedActiveProject = await getValidatedActiveProject(projects, activeProject);
sendJson(res, 200, {
...extra,
projects,
activeProject: validatedActiveProject,
});
}
async function findProjectById(ctx: HostApiContext, projectId: string) {
const projects = await ctx.opencodeProjectStore.listProjects();
return projects.find((project) => project.id === projectId) ?? null;
}
async function closeAgentBrowserForProject(
ctx: HostApiContext,
projectPath: string,
): Promise<void> {
try {
await ctx.agentBrowser?.close(projectPath);
} catch (error) {
if (
error
&& typeof error === 'object'
&& (error as { code?: unknown }).code === 'PROJECT_MISMATCH'
) {
return;
}
throw error;
}
}
type ProjectActivationCheck =
| { ok: true }
| { ok: false; status: Exclude<ProjectConfigReadResult['status'], 'valid'> | 'incomplete'; error: string };
async function validateProjectForActivation(project: { path: string }): Promise<ProjectActivationCheck> {
const result = await readProjectConfig(project.path);
if (result.status === 'valid') return { ok: true };
return {
ok: false,
status: result.status,
error: result.status === 'missing' ? 'Project configuration is missing' : result.error,
};
}
async function getValidatedActiveProject<TProject extends { id: string; path: string }>(
projects: TProject[],
activeProject: TProject | null | undefined,
): Promise<TProject | null> {
if (!activeProject) return null;
const project = projects.find((candidate) => candidate.id === activeProject.id);
if (!project) return null;
const projectCheck = await validateProjectForActivation(project);
return projectCheck.ok ? project : null;
}
function sendProjectActivationFailure(
res: ServerResponse,
projectCheck: Exclude<ProjectActivationCheck, { ok: true }>,
) {
sendJson(res, 409, {
success: false,
error: projectCheck.error,
projectStatus: projectCheck.status,
});
}
async function getValidatedActiveProjectForRuntime<TProject extends { path: string }>(
res: ServerResponse,
ctx: HostApiContext,
): Promise<TProject | null> {
const activeProject = await ctx.opencodeProjectStore.getActiveProject() as TProject | null;
if (!activeProject) {
sendJson(res, 409, {
success: false,
error: 'No active project selected',
});
return null;
}
const projectCheck = await validateProjectForActivation(activeProject);
if (!projectCheck.ok) {
sendProjectActivationFailure(res, projectCheck);
return null;
}
const config = await readProjectConfig(activeProject.path);
if (config.status !== 'valid' || !config.config.initialized) {
sendJson(res, 409, { success: false, error: 'Project initialization is incomplete', projectStatus: 'incomplete' });
return null;
}
return activeProject;
}
async function createClientForActiveProject(
res: ServerResponse,
ctx: HostApiContext,
options: { ensureUserModelProxyConfig?: boolean } = {},
runtimeConfigSummary?: OpencodeRuntimeConfigSummary,
): Promise<ActiveProjectClient | null> {
let status = ctx.opencodeManager.getStatus();
if (status.state !== 'running' || !status.url) {
sendJson(res, 409, {
success: false,
error: 'Runtime is not running',
});
return null;
}
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx);
if (!activeProject) {
return null;
}
if (options.ensureUserModelProxyConfig) {
const ensuredStatus = await ensureRuntimeUserModelProxyConfig(
res,
ctx,
status,
activeProject.path,
runtimeConfigSummary,
);
if (!ensuredStatus) return null;
status = ensuredStatus;
}
return createOpencodeClient({
baseUrl: status.url,
directory: activeProject.path,
});
}
async function getActiveProjectContext(res: ServerResponse, ctx: HostApiContext) {
const status = ctx.opencodeManager.getStatus();
if (status.state !== 'running' || !status.url) {
sendJson(res, 409, {
success: false,
error: 'Runtime is not running',
});
return null;
}
const activeProject = await getValidatedActiveProjectForRuntime(res, ctx);
if (!activeProject) {
return null;
}
return { status, activeProject };
}
function parseSseFrame(frame: string): { eventName: string; data: string } | null {
const lines = frame.replace(/\r/g, '').split('\n');
let eventName = '';
const dataLines: string[] = [];
for (const line of lines) {
if (line.startsWith('event:')) {
eventName = line.slice(6).trim();
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
if (dataLines.length === 0) return null;
return {
eventName,
data: dataLines.join('\n'),
};
}
function getEventSessionId(input: unknown): string | null {
if (!input || typeof input !== 'object') return null;
const record = input as Record<string, unknown>;
if (typeof record.sessionID === 'string' && record.sessionID.trim()) return record.sessionID;
if (typeof record.sessionId === 'string' && record.sessionId.trim()) return record.sessionId;
return getEventSessionId(record.part)
?? getEventSessionId(record.info)
?? getEventSessionId(record.message)
?? null;
}
function isQuestionAnswers(input: unknown): input is string[][] {
return Array.isArray(input)
&& input.every((answer) => (
Array.isArray(answer)
&& answer.every((item) => typeof item === 'string')
));
}
function isPermissionReply(input: unknown): input is OpencodePermissionReply {
return input === 'once' || input === 'always' || input === 'reject';
}
function normalizeRevertPayload(input: Record<string, unknown>): RevertOpencodeSessionMessageInput | null {
const messageID = typeof input.messageID === 'string' && input.messageID.trim()
? input.messageID.trim()
: typeof input.messageId === 'string' && input.messageId.trim()
? input.messageId.trim()
: null;
if (!messageID) return null;
const partID = typeof input.partID === 'string' && input.partID.trim()
? input.partID.trim()
: typeof input.partId === 'string' && input.partId.trim()
? input.partId.trim()
: undefined;
return {
messageID,
...(partID ? { partID } : {}),
};
}
function normalizeOpencodeEventFrame(
frame: { eventName: string; data: string },
sessionFilter: string | null,
): { type: string; payload: unknown } | null {
try {
const raw = JSON.parse(frame.data) as unknown;
const record = raw && typeof raw === 'object' ? raw as Record<string, unknown> : null;
const type = frame.eventName || (typeof record?.type === 'string' ? record.type : '');
const payload = record && 'properties' in record ? record.properties : raw;
if (!type) return null;
const sessionId = getEventSessionId(payload);
if (sessionFilter && sessionId && sessionId !== sessionFilter) {
return null;
}
if (sessionFilter && !sessionId && type !== 'server.connected' && type !== 'server.disconnected') {
return null;
}
return { type, payload };
} catch {
return null;
}
}
function summarizeOpencodeSessionError(payload: unknown): {
sessionID?: string;
errorName?: string;
message?: string;
} | null {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
const record = payload as Record<string, unknown>;
const error = record.error;
const summary: {
sessionID?: string;
errorName?: string;
message?: string;
} = {};
if (typeof record.sessionID === 'string' && record.sessionID.trim()) {
summary.sessionID = record.sessionID;
}
if (typeof error === 'string' && error.trim()) {
summary.message = error;
} else if (error && typeof error === 'object' && !Array.isArray(error)) {
const errorRecord = error as Record<string, unknown>;
if (typeof errorRecord.name === 'string' && errorRecord.name.trim()) {
summary.errorName = errorRecord.name;
}
const data = errorRecord.data;
if (data && typeof data === 'object' && !Array.isArray(data)) {
const dataMessage = (data as Record<string, unknown>).message;
if (typeof dataMessage === 'string' && dataMessage.trim()) {
summary.message = dataMessage;
}
}
if (!summary.message && typeof errorRecord.message === 'string' && errorRecord.message.trim()) {
summary.message = errorRecord.message;
}
}
return summary.sessionID || summary.errorName || summary.message ? summary : null;
}
export async function handleOpencodeRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (url.pathname === '/api/opencode/events' && req.method === 'GET') {
try {
const context = await getActiveProjectContext(res, ctx);
if (!context) return true;
const decorated = decorateOpencodeRequest({
baseUrl: context.status.url!,
path: '/event',
directory: context.activeProject.path,
});
const upstream = await globalThis.fetch(decorated.url, {
method: 'GET',
headers: {
...decorated.headers,
Accept: 'text/event-stream',
},
});
if (!upstream.ok || !upstream.body) {
sendJson(res, 502, {
success: false,
error: `Failed to connect to event stream (${upstream.status})`,
});
return true;
}
const sessionFilter = url.searchParams.get('sessionId')?.trim() ?? null;
res.statusCode = 200;
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
flushStreamingHeaders(res);
if (!await writeStreamingChunk(res, ': connected\n\n')) {
return true;
}
let buffer = '';
for await (const chunk of upstream.body as AsyncIterable<Uint8Array>) {
buffer += Buffer.from(chunk).toString('utf8');
buffer = buffer.replace(/\r\n/g, '\n');
let boundary = buffer.indexOf('\n\n');
while (boundary >= 0) {
const frameText = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const parsed = parseSseFrame(frameText);
const normalized = parsed ? normalizeOpencodeEventFrame(parsed, sessionFilter) : null;
if (normalized) {
if (normalized.type === 'session.error') {
const summary = summarizeOpencodeSessionError(normalized.payload);
logger.warn('[opencode-events] session.error from runtime', summary ?? {});
}
if (!await writeStreamingChunk(
res,
`event: ${normalized.type}\ndata: ${JSON.stringify(normalized.payload)}\n\n`,
)) {
return true;
}
}
boundary = buffer.indexOf('\n\n');
}
}
res.end();
} catch (error) {
if (res.headersSent) {
if (!res.destroyed && !res.writableEnded) {
res.end();
}
} else {
sendJson(res, 500, { success: false, error: String(error) });
}
}
return true;
}
if (url.pathname === '/api/opencode/projects' && req.method === 'GET') {
try {
await sendProjectSnapshot(res, ctx);
} catch (error) {
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/open' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ path?: string }>(req);
if (!body.path) throw new Error('Missing project path');
const project = await ctx.opencodeProjectStore.rememberProject(body.path);
await sendProjectSnapshot(res, ctx, { success: true, project });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/create' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
projectPath?: string;
parentPath?: string;
projectName?: string;
defaultModel?: unknown;
}>(req);
const defaultModel = body.defaultModel === undefined || body.defaultModel === null
? null
: typeof body.defaultModel === 'string' && body.defaultModel.trim()
? body.defaultModel.trim()
: (() => { throw new Error('Invalid default model'); })();
const useSelectedDirectory = typeof body.projectPath === 'string' && Boolean(body.projectPath.trim());
if (useSelectedDirectory && (body.parentPath?.trim() || body.projectName?.trim())) {
throw new Error('Ambiguous project path input');
}
const projectPath = useSelectedDirectory
? buildSelectedProjectPath(body.projectPath)
: buildNewProjectPath(body.parentPath, body.projectName);
const { config } = await initializeProjectDirectory({
projectPath,
defaultModel,
allowExistingDirectory: useSelectedDirectory,
});
const project = await ctx.opencodeProjectStore.rememberProject(projectPath);
await sendProjectSnapshot(res, ctx, { success: true, project, config });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/config' && req.method === 'GET') {
try {
const projectId = url.searchParams.get('projectId')?.trim();
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
const result = await readProjectConfig(project.path);
const knowledgeFiles = result.status === 'valid' ? await listProjectKnowledge(project.path) : [];
sendJson(res, 200, { ...result, knowledgeFiles });
} catch (error) {
sendJson(res, 500, { status: 'invalid', error: error instanceof Error ? error.message : String(error), knowledgeFiles: [] });
}
return true;
}
if (url.pathname === '/api/opencode/projects/config' && req.method === 'PUT') {
try {
const body = await parseJsonBody<{ projectId?: string; config?: ProjectConfig }>(req);
if (!body.projectId || !body.config) throw new Error('Missing project configuration');
const project = await findProjectById(ctx, body.projectId);
if (!project) throw new Error('Project not found');
const previousResult = await readProjectConfig(project.path);
const previousConfig = previousResult.status === 'valid' ? previousResult.config : null;
const config = await writeProjectConfig(project.path, body.config);
let status = ctx.opencodeManager.getStatus();
const runtimeSettingsChanged = !previousConfig
|| previousConfig.defaultModel !== config.defaultModel
|| previousConfig.superpowersEnabled !== config.superpowersEnabled;
if (config.initialized && runtimeSettingsChanged && status.state === 'running') {
status = await ctx.opencodeManager.restart();
}
sendJson(res, 200, { success: true, config, knowledgeFiles: await listProjectKnowledge(project.path), status });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/conversations' && req.method === 'GET') {
try {
const projectId = url.searchParams.get('projectId')?.trim();
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
sendJson(res, 200, { state: await readProjectConversationState(project.path) });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/conversations' && req.method === 'POST') {
try {
const body = await parseJsonBody<{
projectId?: string;
sessionId?: string;
agentId?: string;
action?: 'link' | 'archive' | 'restore' | 'delete' | 'read' | 'increment-unread' | 'complete';
unread?: boolean;
}>(req);
const projectId = body.projectId?.trim();
const sessionId = body.sessionId?.trim();
if (!projectId || !sessionId) throw new Error('Missing conversation identifiers');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
const state = await readProjectConversationState(project.path);
let nextState: ProjectConversationState;
const now = new Date().toISOString();
switch (body.action) {
case 'link':
if (!body.agentId?.trim()) throw new Error('Missing Agent id');
nextState = upsertProjectSessionMetadata(state, sessionId, body.agentId.trim(), now);
break;
case 'archive':
nextState = patchProjectSessionMetadata(state, sessionId, { archivedAt: now }, now);
break;
case 'restore':
nextState = patchProjectSessionMetadata(state, sessionId, { archivedAt: null }, now);
break;
case 'delete':
nextState = removeProjectSessionMetadata(state, sessionId, now);
break;
case 'read':
nextState = patchProjectSessionMetadata(state, sessionId, { unreadCount: 0 }, now);
break;
case 'increment-unread': {
const current = state.sessions.find((item) => item.sessionId === sessionId);
nextState = patchProjectSessionMetadata(
state,
sessionId,
{ unreadCount: (current?.unreadCount ?? 0) + 1 },
now,
);
break;
}
case 'complete':
nextState = completeProjectSession(state, sessionId, body.unread === true, now);
break;
default:
throw new Error('Unknown conversation action');
}
sendJson(res, 200, { success: true, state: await writeProjectConversationState(project.path, nextState) });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/knowledge' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ projectId?: string; fileName?: string; contentBase64?: string }>(req);
if (!body.projectId || !body.fileName || typeof body.contentBase64 !== 'string') throw new Error('Missing knowledge file');
const project = await findProjectById(ctx, body.projectId);
if (!project) throw new Error('Project not found');
const config = await readProjectConfig(project.path);
if (config.status !== 'valid') throw new Error('Project configuration is unavailable');
const fileName = path.basename(body.fileName.trim());
if (!fileName || fileName === '.' || fileName === '..' || fileName.includes('\0')) throw new Error('Invalid knowledge filename');
const content = Buffer.from(body.contentBase64, 'base64');
if (content.byteLength > 25 * 1024 * 1024) throw new Error('Knowledge file exceeds 25 MB');
const directory = path.join(project.path, 'knowledge');
await mkdir(directory, { recursive: true });
await writeFile(path.join(directory, fileName), content, { flag: 'wx' });
sendJson(res, 200, { success: true, knowledgeFiles: await listProjectKnowledge(project.path) });
} catch (error) {
sendJson(res, 500, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
const worksPublishMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-publish$/);
if (worksPublishMatch && req.method === 'GET') {
try {
const projectId = decodeURIComponent(worksPublishMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
sendJson(res, 200, await readWorksPublishFile(project.path));
} catch (error) {
sendJson(res, 500, {
status: 'invalid',
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
const worksDeployCheckMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-deploy-check$/);
if (worksDeployCheckMatch && req.method === 'GET') {
try {
const projectId = decodeURIComponent(worksDeployCheckMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
const project = await findProjectById(ctx, projectId);
if (!project) throw new Error('Project not found');
const publish = await readWorksPublishFile(project.path);
sendJson(
res,
200,
await readWorksDeployCheck(project.path, publish.status === 'ready' ? publish.publish : null),
);
} catch (error) {
sendJson(res, 500, {
status: 'invalid',
filePath: '',
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
const worksCloudDeployMatch = url.pathname.match(/^\/api\/opencode\/projects\/([^/]+)\/works-cloud-deploy$/);
if (worksCloudDeployMatch && (req.method === 'GET' || req.method === 'POST')) {
try {
const projectId = decodeURIComponent(worksCloudDeployMatch[1] ?? '');
if (!projectId) throw new Error('Missing project id');
if (!ctx.worksCloudDeployment) throw new Error('Cloud deployment coordinator is unavailable');
if (req.method === 'POST') {
const deployment = await ctx.worksCloudDeployment.arm(projectId);
sendJson(res, 202, { success: true, deployment });
} else {
sendJson(res, 200, { success: true, deployment: await ctx.worksCloudDeployment.get(projectId) });
}
} catch (error) {
sendJson(res, 500, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
if (url.pathname === '/api/opencode/projects/remove' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ projectId?: string }>(req);
if (!body.projectId) throw new Error('Missing project id');
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
if (activeProject?.id === body.projectId) {
await closeAgentBrowserForProject(ctx, activeProject.path);
}
await ctx.opencodeProjectStore.removeProject(body.projectId);
if (activeProject?.id === body.projectId) {
await closeAgentBrowserForProject(ctx, activeProject.path);
}
await sendProjectSnapshot(res, ctx, { success: true, removedProjectId: body.projectId });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/active' && req.method === 'GET') {
try {
const [projects, activeProject] = await Promise.all([
ctx.opencodeProjectStore.listProjects(),
ctx.opencodeProjectStore.getActiveProject(),
]);
sendJson(res, 200, await getValidatedActiveProject(projects, activeProject));
} catch (error) {
sendJson(res, 400, { success: false, error: error instanceof Error ? error.message : String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/projects/active' && req.method === 'POST') {
try {
const body = await parseJsonBody<{ projectId?: string }>(req);
if (!body.projectId) throw new Error('Missing project id');
const requestedProject = await findProjectById(ctx, body.projectId);
if (!requestedProject) throw new Error('Project not found');
const projectCheck = await validateProjectForActivation(requestedProject);
if (!projectCheck.ok) {
sendProjectActivationFailure(res, projectCheck);
return true;
}
const activeProject = await ctx.opencodeProjectStore.getActiveProject();
if (activeProject?.id !== body.projectId && activeProject) {
await closeAgentBrowserForProject(ctx, activeProject.path);
}
const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId);
if (activeProject?.id !== body.projectId && activeProject) {
await closeAgentBrowserForProject(ctx, activeProject.path);
}
await sendProjectSnapshot(res, ctx, { success: true, project });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/commands' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const [commands, runtimeConfig] = await Promise.all([
client.listCommands(),
client.getConfig(),
]);
sendJson(res, 200, {
commands: commands.flatMap((command) => {
const normalized = normalizeCommandInfo(command);
return normalized ? [normalized] : [];
}),
shareEnabled: asRecord(runtimeConfig)?.share !== 'disabled',
});
} catch (error) {
sendJson(res, 500, {
success: false,
error: error instanceof Error ? error.message : String(error),
});
}
return true;
}
if (url.pathname === '/api/opencode/sessions' && req.method === 'GET') {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
sendJson(res, 200, { sessions: await client.listSessions() });
return true;
}
if (url.pathname === '/api/opencode/skills' && req.method === 'GET') {
const getManagedConfigDir = 'getManagedConfigDir' in ctx.opencodeManager
? ctx.opencodeManager.getManagedConfigDir.bind(ctx.opencodeManager)
: undefined;
const installedSkills = (await listInstalledOpencodeSkills(getManagedConfigDir?.()))
.filter((skill) => bundledCourseSkillIds.has(skill.name));
sendJson(res, 200, { skills: installedSkills });
return true;
}
if (url.pathname === '/api/opencode/sessions' && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<Record<string, unknown>>(req);
sendJson(res, 200, { success: true, session: await client.createSession(body) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionForkMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/fork$/);
if (sessionForkMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<{ messageID?: unknown }>(req);
const messageID = typeof body.messageID === 'string' && body.messageID.trim()
? body.messageID.trim()
: undefined;
const sessionID = decodeURIComponent(sessionForkMatch[1]);
const session = await client.forkSession(sessionID, messageID ? { messageID } : {});
sendJson(res, 200, { success: true, session });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionShareMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/share$/);
if (sessionShareMatch && (req.method === 'POST' || req.method === 'DELETE')) {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionShareMatch[1]);
const session = req.method === 'POST'
? await client.shareSession(sessionID)
: await client.unshareSession(sessionID);
sendJson(res, 200, { success: true, session });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionSummarizeMatch = url.pathname.match(
/^\/api\/opencode\/sessions\/([^/]+)\/summarize$/,
);
if (sessionSummarizeMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
const body = await parseJsonBody<{ model?: unknown }>(req);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
if (!model) {
sendJson(res, 409, { success: false, error: 'No runtime model configured' });
return true;
}
const sessionID = decodeURIComponent(sessionSummarizeMatch[1]);
await client.summarizeSession(sessionID, model);
sendJson(res, 202, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionCommandMatch = url.pathname.match(
/^\/api\/opencode\/sessions\/([^/]+)\/command$/,
);
if (sessionCommandMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
const body = await parseJsonBody<{
command?: unknown;
arguments?: unknown;
agent?: unknown;
model?: unknown;
variant?: unknown;
parts?: unknown;
}>(req);
const command = parseRequiredCommandName(body.command);
const argumentsText = typeof body.arguments === 'string'
&& body.arguments.length <= MAX_COMMAND_ARGUMENTS
? body.arguments
: null;
const parts = normalizeCommandParts(body.parts);
if (!command || argumentsText === null || !parts) {
sendJson(res, 400, { success: false, error: 'Invalid command payload' });
return true;
}
const sessionID = decodeURIComponent(sessionCommandMatch[1]);
const agent = parseOptionalCommandIdentifier(body.agent);
const model = parseOptionalCommandModel(body.model);
const variant = parseOptionalCommandIdentifier(body.variant);
const invalidOptionalField = (
(body.agent !== undefined && !agent)
|| (body.model !== undefined && !model)
|| (body.variant !== undefined && !variant)
);
if (invalidOptionalField) {
sendJson(res, 400, { success: false, error: 'Invalid command runtime context' });
return true;
}
logger.info('[opencode-route] Executing session command', {
sessionID,
command,
partCount: parts.length,
fileMimes: parts.flatMap((part) => part.type === 'file' ? [part.mime] : []),
});
await client.executeSessionCommand(sessionID, {
command,
arguments: argumentsText,
...(agent ? { agent } : {}),
...(model ? { model } : {}),
...(variant ? { variant } : {}),
...(parts.length ? { parts } : {}),
});
sendJson(res, 202, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/sessions/status' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
sendJson(res, 200, { statuses: await client.getSessionStatuses() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/questions' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
sendJson(res, 200, { questions: await client.listQuestions() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const questionReplyMatch = url.pathname.match(/^\/api\/opencode\/questions\/([^/]+)\/reply$/);
if (questionReplyMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<{ answers?: unknown }>(req);
if (!isQuestionAnswers(body.answers)) {
sendJson(res, 400, { success: false, error: 'Invalid question answers' });
return true;
}
const requestID = decodeURIComponent(questionReplyMatch[1]);
await client.replyQuestion(requestID, body.answers);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const questionRejectMatch = url.pathname.match(/^\/api\/opencode\/questions\/([^/]+)\/reject$/);
if (questionRejectMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const requestID = decodeURIComponent(questionRejectMatch[1]);
await client.rejectQuestion(requestID);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/permissions' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const permissions = await client.listPermissions();
sendJson(res, 200, { permissions });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const permissionReplyMatch = url.pathname.match(/^\/api\/opencode\/permissions\/([^/]+)\/reply$/);
if (permissionReplyMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<{ reply?: unknown; message?: unknown }>(req);
if (!isPermissionReply(body.reply)) {
sendJson(res, 400, { success: false, error: 'Invalid permission reply' });
return true;
}
const message = typeof body.message === 'string' && body.message.trim() ? body.message.trim() : undefined;
const requestID = decodeURIComponent(permissionReplyMatch[1]);
await client.replyPermission(requestID, body.reply, message);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/files/status' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
sendJson(res, 200, { files: await client.getFileStatuses() });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/files/find' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
sendJson(res, 400, { success: false, error: 'Missing file query' });
return true;
}
const limit = Number(url.searchParams.get('limit'));
sendJson(res, 200, {
files: await client.findFiles(query, {
limit: Number.isFinite(limit) && limit > 0 ? limit : undefined,
}),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/search' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const pattern = url.searchParams.get('pattern')?.trim() ?? '';
if (!pattern) {
sendJson(res, 400, { success: false, error: 'Missing search pattern' });
return true;
}
sendJson(res, 200, {
matches: await client.searchText(pattern),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/files/content' && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const filePath = url.searchParams.get('path')?.trim() ?? '';
if (!filePath) {
sendJson(res, 400, { success: false, error: 'Missing file path' });
return true;
}
sendJson(res, 200, { file: await client.getFileContent(filePath) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)$/);
if (sessionMatch && req.method === 'PATCH') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<{ title?: string }>(req);
const title = typeof body.title === 'string' ? body.title.trim() : '';
if (!title) {
sendJson(res, 400, { success: false, error: 'Missing session title' });
return true;
}
const sessionID = decodeURIComponent(sessionMatch[1]);
sendJson(res, 200, {
success: true,
session: await client.updateSession(sessionID, { title }),
});
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (sessionMatch && req.method === 'DELETE') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionMatch[1]);
await client.deleteSession(sessionID);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionAbortMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/abort$/);
if (sessionAbortMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionAbortMatch[1]);
await client.abortSession(sessionID);
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionDiffMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/diff$/);
if (sessionDiffMatch && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionDiffMatch[1]);
sendJson(res, 200, { diffs: await client.getSessionDiff(sessionID) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionTodosMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/todos$/);
if (sessionTodosMatch && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionTodosMatch[1]);
sendJson(res, 200, { todos: await client.getSessionTodos(sessionID) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionRevertMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/revert$/);
if (sessionRevertMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const body = await parseJsonBody<Record<string, unknown>>(req);
const payload = normalizeRevertPayload(body);
if (!payload) {
sendJson(res, 400, { success: false, error: 'Missing message id' });
return true;
}
const sessionID = decodeURIComponent(sessionRevertMatch[1]);
const reverted = await client.revertSessionMessage(sessionID, payload);
sendJson(res, 200, { success: true, session: reverted });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionUnrevertMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/unrevert$/);
if (sessionUnrevertMatch && req.method === 'POST') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionUnrevertMatch[1]);
const restored = await client.unrevertSession(sessionID);
sendJson(res, 200, { success: true, session: restored });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
const sessionMessagesMatch = url.pathname.match(/^\/api\/opencode\/sessions\/([^/]+)\/messages$/);
if (sessionMessagesMatch && req.method === 'GET') {
try {
const client = await createClientForActiveProject(res, ctx);
if (!client) return true;
const sessionID = decodeURIComponent(sessionMessagesMatch[1]);
sendJson(res, 200, { messages: await client.getSessionMessages(sessionID) });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (sessionMessagesMatch && req.method === 'POST') {
try {
await ensureDirectWorksSquareCredentialBeforePrompt(ctx);
const runtimeConfigSummary = await loadRuntimeConfigSummaryForPrompt();
const client = await createClientForActiveProject(
res,
ctx,
{ ensureUserModelProxyConfig: true },
runtimeConfigSummary,
);
if (!client) return true;
const body = await parseJsonBody<{
text?: string;
files?: unknown;
userContext?: unknown;
agent?: unknown;
model?: unknown;
}>(req);
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) {
sendJson(res, 400, { success: false, error: 'Missing message text' });
return true;
}
const sessionID = decodeURIComponent(sessionMessagesMatch[1]);
const explicitModel = body.model === undefined ? undefined : parseRuntimePromptModel(body.model);
if (body.model !== undefined && !explicitModel) {
sendJson(res, 400, { success: false, error: 'Invalid model reference' });
return true;
}
const model = explicitModel ?? await resolveRuntimePromptModel(runtimeConfigSummary);
const files = normalizePromptFileParts(body.files);
const userContext = typeof body.userContext === 'string' ? body.userContext.trim().slice(0, 2_000) : '';
const agent = typeof body.agent === 'string' && /^[a-z0-9][a-z0-9-]{0,63}$/.test(body.agent.trim())
? body.agent.trim()
: '';
logger.info('[opencode-route] Starting session prompt', {
sessionID,
runtimeUrl: ctx.opencodeManager.getStatus().url ?? null,
model: promptModelLogRef(model),
textLength: text.length,
fileCount: files.length,
fileMimes: files.map((file) => file.mime),
});
await client.promptSessionAsync(sessionID, {
text,
...(userContext ? { system: userContext } : {}),
...(agent ? { agent } : {}),
...(files.length ? { files } : {}),
...(model ? { model } : {}),
});
sendJson(res, 202, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/status' && req.method === 'GET') {
sendJson(res, 200, ctx.opencodeManager.getStatus());
return true;
}
if (url.pathname === '/api/opencode/start' && req.method === 'POST') {
try {
const status = await ctx.opencodeManager.start();
sendJson(res, 200, { success: true, status });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/stop' && req.method === 'POST') {
try {
await ctx.opencodeManager.stop();
sendJson(res, 200, { success: true });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/restart' && req.method === 'POST') {
try {
const status = await ctx.opencodeManager.restart();
sendJson(res, 200, { success: true, status });
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
if (url.pathname === '/api/opencode/health' && req.method === 'GET') {
sendJson(res, 200, await ctx.opencodeManager.checkHealth());
return true;
}
if (url.pathname === '/api/opencode/config-summary' && req.method === 'GET') {
try {
sendJson(res, 200, publicRuntimeConfigSummary(await buildNianCodeRuntimeConfigSummary()));
} catch (error) {
sendJson(res, 500, { success: false, error: String(error) });
}
return true;
}
return false;
}