1772 lines
65 KiB
TypeScript
1772 lines
65 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 {
|
|
isProjectTemplateId,
|
|
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;
|
|
const AUTO_APPROVE_PERMISSION_MAX_ATTEMPTS = 3;
|
|
const AUTO_APPROVE_PERMISSION_CACHE_LIMIT = 1_000;
|
|
const permissionAutoApprovals = new Map<string, Promise<void>>();
|
|
|
|
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;
|
|
}
|
|
|
|
type ProjectTemplateActivationCheck =
|
|
| { ok: true }
|
|
| { ok: false; status: Exclude<ProjectConfigReadResult['status'], 'valid'> | 'incomplete'; error: string };
|
|
|
|
async function validateProjectTemplateForActivation(project: { path: string }): Promise<ProjectTemplateActivationCheck> {
|
|
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 templateCheck = await validateProjectTemplateForActivation(project);
|
|
return templateCheck.ok ? project : null;
|
|
}
|
|
|
|
function sendProjectTemplateActivationFailure(
|
|
res: ServerResponse,
|
|
templateCheck: Exclude<ProjectTemplateActivationCheck, { ok: true }>,
|
|
) {
|
|
sendJson(res, 409, {
|
|
success: false,
|
|
error: templateCheck.error,
|
|
templateStatus: templateCheck.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 templateCheck = await validateProjectTemplateForActivation(activeProject);
|
|
if (!templateCheck.ok) {
|
|
sendProjectTemplateActivationFailure(res, templateCheck);
|
|
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', templateStatus: '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 getPermissionRequestId(input: unknown): string | null {
|
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
const record = input as Record<string, unknown>;
|
|
if (typeof record.id === 'string' && record.id.trim()) return record.id.trim();
|
|
if (typeof record.requestID === 'string' && record.requestID.trim()) return record.requestID.trim();
|
|
return getPermissionRequestId(record.request)
|
|
?? getPermissionRequestId(record.permission)
|
|
?? null;
|
|
}
|
|
|
|
function prunePermissionAutoApprovalCache(): void {
|
|
while (permissionAutoApprovals.size >= AUTO_APPROVE_PERMISSION_CACHE_LIMIT) {
|
|
const oldestKey = permissionAutoApprovals.keys().next().value;
|
|
if (typeof oldestKey !== 'string') return;
|
|
permissionAutoApprovals.delete(oldestKey);
|
|
}
|
|
}
|
|
|
|
async function autoApprovePermission(
|
|
client: ActiveProjectClient,
|
|
projectPath: string,
|
|
permission: unknown,
|
|
): Promise<boolean> {
|
|
const requestID = getPermissionRequestId(permission);
|
|
if (!requestID) {
|
|
logger.warn('[opencode-permission] Cannot auto-approve permission without request id');
|
|
return false;
|
|
}
|
|
|
|
const cacheKey = `${projectPath}\u0000${requestID}`;
|
|
let approval = permissionAutoApprovals.get(cacheKey);
|
|
if (!approval) {
|
|
prunePermissionAutoApprovalCache();
|
|
approval = (async () => {
|
|
let lastError: unknown;
|
|
for (let attempt = 1; attempt <= AUTO_APPROVE_PERMISSION_MAX_ATTEMPTS; attempt += 1) {
|
|
try {
|
|
await client.replyPermission(requestID, 'always');
|
|
logger.info('[opencode-permission] Auto-approved permission', {
|
|
requestID,
|
|
sessionID: getEventSessionId(permission),
|
|
reply: 'always',
|
|
});
|
|
return;
|
|
} catch (error) {
|
|
lastError = error;
|
|
logger.warn('[opencode-permission] Auto-approval attempt failed', {
|
|
requestID,
|
|
attempt,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
})();
|
|
permissionAutoApprovals.set(cacheKey, approval);
|
|
void approval.catch(() => {
|
|
if (permissionAutoApprovals.get(cacheKey) === approval) {
|
|
permissionAutoApprovals.delete(cacheKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
await approval;
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
const client = createOpencodeClient({
|
|
baseUrl: context.status.url!,
|
|
directory: context.activeProject.path,
|
|
});
|
|
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 === 'permission.asked') {
|
|
try {
|
|
await autoApprovePermission(client, context.activeProject.path, normalized.payload);
|
|
} catch (error) {
|
|
logger.warn('[opencode-permission] Auto-approval failed after retries', {
|
|
requestID: getPermissionRequestId(normalized.payload),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
boundary = buffer.indexOf('\n\n');
|
|
continue;
|
|
}
|
|
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;
|
|
templateId?: string;
|
|
defaultModel?: unknown;
|
|
}>(req);
|
|
if (!isProjectTemplateId(body.templateId)) throw new Error('Missing or invalid project template');
|
|
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,
|
|
templateId: body.templateId,
|
|
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 config = await writeProjectConfig(project.path, body.config);
|
|
let status = ctx.opencodeManager.getStatus();
|
|
if (config.initialized && 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/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');
|
|
await ctx.opencodeProjectStore.removeProject(body.projectId);
|
|
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 templateCheck = await validateProjectTemplateForActivation(requestedProject);
|
|
if (!templateCheck.ok) {
|
|
sendProjectTemplateActivationFailure(res, templateCheck);
|
|
return true;
|
|
}
|
|
const project = await ctx.opencodeProjectStore.setActiveProject(body.projectId);
|
|
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 model = 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 activeProject = await ctx.opencodeProjectStore.getActiveProject();
|
|
const permissions = await client.listPermissions();
|
|
await Promise.allSettled(permissions.map(async (permission) => {
|
|
try {
|
|
await autoApprovePermission(client, activeProject?.path ?? '', permission);
|
|
} catch (error) {
|
|
logger.warn('[opencode-permission] Failed to recover pending permission with auto-approval', {
|
|
requestID: getPermissionRequestId(permission),
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}));
|
|
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 }>(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 model = 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;
|
|
}
|