feat(coding): add core host api composition

This commit is contained in:
2026-08-23 20:00:35 +08:00
parent 98bac20639
commit 22b4a9f9c4
23 changed files with 2258 additions and 59 deletions

View File

@@ -0,0 +1,176 @@
import path from 'node:path';
import type { AgentBrowserModule } from '../agent-browser';
import { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { createCodingConversationStore } from '../coding-projects/conversation-store';
import { CodingProjectService } from '../coding-projects/project-service';
import {
createCodingProjectStore,
type CodingProjectStorage,
} from '../coding-projects/project-store';
import { CodingConversationService } from '../coding-runtime/conversation-service';
import { PiManagedExtensionHost } from '../coding-runtime/pi/extension-host';
import { PiManagedInputRevisionCoordinator } from '../coding-runtime/pi/managed-input-revision';
import { PiProductTools } from '../coding-runtime/pi/product-tools';
import {
buildPiProviderCatalog,
resolvePiProviderCredentialFromSecretStore,
selectPiProviderModel,
} from '../coding-runtime/pi/provider-config';
import {
createPiManagedWorkerOpener,
PiConversationRuntime,
} from '../coding-runtime/pi/runtime';
import { PiSessionRegistry } from '../coding-runtime/pi/session-registry';
import { createPiManagedSubagentChildOpener } from '../coding-runtime/pi/subagent-child';
import { PiSubagentScheduler } from '../coding-runtime/pi/subagent';
import { PiProcessBudget, PiWorkerPool } from '../coding-runtime/pi/worker-pool';
import { getProviderService } from '../services/providers/provider-service';
import { createCodingProductHost, type CodingProductComposition } from './coding-product-services';
export interface CodingCompositionPaths {
executablePath: string;
cliPath: string;
userDataDir: string;
bundledSkillsDir: string;
}
export interface CreateCodingCompositionOptions {
storage: CodingProjectStorage;
browser: AgentBrowserModule;
paths: CodingCompositionPaths;
localProxyCredential?: string;
}
export function resolveCodingPiRuntimePaths(input: {
isPackaged: boolean;
resourcesPath: string;
appPath: string;
executablePath: string;
}): Pick<CodingCompositionPaths, 'executablePath' | 'cliPath'> {
return {
executablePath: input.executablePath,
cliPath: input.isPackaged
? path.join(input.resourcesPath, 'pi-runtime', 'dist', 'cli.js')
: path.join(
input.appPath,
'node_modules',
'@earendil-works',
'pi-coding-agent',
'dist',
'cli.js',
),
};
}
export function createCodingComposition(
options: CreateCodingCompositionOptions,
): CodingProductComposition {
const projectStore = createCodingProjectStore(options.storage);
const attachments = new CodingAttachmentStore(
path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'),
);
const productTools = new PiProductTools({
browser: options.browser,
attachments,
bundledSkillsDir: options.paths.bundledSkillsDir,
});
const extensionHost = new PiManagedExtensionHost();
extensionHost.configureProductTools(productTools);
const registry = new PiSessionRegistry({ projectStore });
const revisions = new PiManagedInputRevisionCoordinator();
const processBudget = new PiProcessBudget();
const loadProviderInput = async () => ({
accounts: await getProviderService().listAccounts(),
modelSummaries: [],
});
const workerPool = new PiWorkerPool({
processBudget,
revisionCoordinator: revisions,
openWorker: createPiManagedWorkerOpener({
registry,
executablePath: options.paths.executablePath,
cliPath: options.paths.cliPath,
userDataDir: options.paths.userDataDir,
bundledSkillsDir: options.paths.bundledSkillsDir,
loadProviderInput,
resolveCredential: resolvePiProviderCredentialFromSecretStore,
...(options.localProxyCredential
? { getLocalProxyCredential: async () => options.localProxyCredential }
: {}),
extensionHost,
}),
});
const childOpener = createPiManagedSubagentChildOpener({
projectStore,
executablePath: options.paths.executablePath,
cliPath: options.paths.cliPath,
userDataDir: options.paths.userDataDir,
bundledSkillsDir: options.paths.bundledSkillsDir,
extensionHost,
loadProviderInput,
resolveCredential: resolvePiProviderCredentialFromSecretStore,
getRevision: () => revisions.current,
...(options.localProxyCredential
? { getLocalProxyCredential: async () => options.localProxyCredential }
: {}),
});
const subagents = new PiSubagentScheduler({
openChild: childOpener,
processBudget,
reclaimProcessCapacity: (signal) => workerPool.reclaimIdleWorker(signal),
});
const runtime = new PiConversationRuntime({
pool: workerPool,
registry,
extensionHost,
subagentScheduler: subagents,
resolveModel: async (model) => selectPiProviderModel(
buildPiProviderCatalog(await loadProviderInput()),
model,
),
resolveImages: async (refs) => await Promise.all(refs.map(async ({ attachmentId }) => {
const record = await attachments.read(attachmentId);
return {
type: 'image',
data: record.data.toString('base64'),
mimeType: record.mime,
};
})),
});
const projects = new CodingProjectService(projectStore, {
onResourcesChanged: async (project) => {
runtime.markResourcesStale();
const conversations = await createCodingConversationStore(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
for (const conversation of conversations) registry.forget(conversation.id);
},
onProjectDeactivated: async (project) => {
const conversations = await createCodingConversationStore(project.path).read()
.then((file) => file.conversations)
.catch(() => []);
await Promise.allSettled([
options.browser.close(project.path),
...conversations.map(({ id }) => runtime.dispose(id)),
]);
},
});
const conversations = new CodingConversationService(projects, runtime);
const host = createCodingProductHost({
projects,
productTools,
listPiCommands: (conversationId) => conversations.listLiveCommands(conversationId),
});
return {
attachments,
productTools,
projects,
conversations,
runtime,
host,
async shutdown() {
await subagents.close();
await runtime.shutdown();
},
};
}

View File

@@ -7,10 +7,15 @@ import type {
ProductCodingSkill,
ProductPiCommandInput,
} from '../../shared/coding-product-tools';
import { createCodingConversationStore } from '../coding-projects/conversation-store';
import type { CodingAttachmentStore } from '../coding-projects/attachment-store';
import { readCodingProjectConfigV2 } from '../coding-projects/project-config';
import { CodingProjectFileService } from '../coding-projects/project-files';
import {
CodingProjectServiceError,
type CodingProjectService,
} from '../coding-projects/project-service';
import type { CodingConversationService } from '../coding-runtime/conversation-service';
import type { CodingConversationRuntime } from '../coding-runtime/contracts';
import type { PiProductTools } from '../coding-runtime/pi/product-tools';
export interface ActiveCodingProject {
@@ -31,7 +36,11 @@ export interface CodingProductHost {
export interface CodingProductComposition {
attachments: CodingAttachmentStore;
productTools: PiProductTools;
projects: CodingProjectService;
conversations: CodingConversationService;
runtime: CodingConversationRuntime;
host: CodingProductHost;
shutdown(): Promise<void>;
}
export class CodingProductHostError extends Error {
@@ -45,7 +54,7 @@ export class CodingProductHostError extends Error {
}
export interface CodingProductHostOptions {
getActiveProject(): Promise<ActiveCodingProject | null>;
projects: Pick<CodingProjectService, 'getActiveProject' | 'findActiveConversation'>;
productTools: PiProductTools;
files?: CodingProjectFileService;
listPiCommands?(conversationId: string): Promise<unknown>;
@@ -77,7 +86,7 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi
const files = options.files ?? new CodingProjectFileService();
async function activeProject(): Promise<ActiveCodingProject> {
const project = await options.getActiveProject();
const project = await options.projects.getActiveProject();
if (!project) {
throw new CodingProductHostError(
409,
@@ -118,15 +127,17 @@ export function createCodingProductHost(options: CodingProductHostOptions): Codi
project: ActiveCodingProject;
skillIds: readonly string[];
}> {
const project = await activeProject();
const conversation = await createCodingConversationStore(project.path).get(conversationId);
if (!conversation) {
throw new CodingProductHostError(
404,
'CODING_CONVERSATION_NOT_FOUND',
'Coding Conversation does not exist',
);
let context;
try {
context = await options.projects.findActiveConversation(conversationId);
} catch (error) {
if (error instanceof CodingProjectServiceError
&& (error.status === 404 || error.status === 409)) {
throw new CodingProductHostError(error.status, error.code, error.message);
}
throw error;
}
const { project, conversation } = context;
return {
project,
skillIds: await selectedSkillIds(project.path, conversation.agentId),

View File

@@ -6,7 +6,9 @@
export function shouldUseLoopbackHostApi(path: string, method = 'GET'): boolean {
const normalizedMethod = method.toUpperCase();
const pathname = path.split('?', 1)[0] || path;
if (pathname === '/api/events' || pathname === '/api/opencode/events') return true;
if (pathname === '/api/events'
|| pathname === '/api/opencode/events'
|| pathname === '/api/coding/events') return true;
if (pathname.startsWith('/api/ai-proxy/')) return true;
if (pathname.includes('/events') && pathname.startsWith('/api/image-workspace/')) return true;
if (

View File

@@ -18,6 +18,8 @@ import { handleFileRoutes } from './routes/files';
import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets';
import { handleAgentBrowserRoutes } from './routes/agent-browser';
import { handleCodingFileRoutes } from './routes/coding-files';
import { handleCodingProjectRoutes } from './routes/coding-projects';
import { handleCodingConversationRoutes } from './routes/coding-conversations';
export type HostApiRouteHandler = (
req: IncomingMessage,
@@ -43,6 +45,8 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
handleWorksRoutes,
handleAgentBrowserRoutes,
handleUserSyncRoutes,
handleCodingProjectRoutes,
handleCodingConversationRoutes,
handleCodingFileRoutes,
handleOpencodeRoutes,
handleSettingsRoutes,

View File

@@ -0,0 +1,218 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ConversationInteractionResponse } from '../../coding-runtime/contracts';
import { CodingConversationServiceError } from '../../coding-runtime/conversation-service';
import { normalizeProductModelRef } from '../../coding-projects/project-config';
import type { HostApiContext } from '../context';
import {
flushStreamingHeaders,
parseJsonBody,
sendJson,
sendNoContent,
writeStreamingChunk,
} from '../route-utils';
import { decodeRouteId, sendCodingRouteError } from './coding-route-errors';
const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high']);
function invalidRequest(message: string): never {
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', message);
}
function isConversationRoute(pathname: string, method: string | undefined): boolean {
if ((pathname === '/api/coding/events'
|| pathname === '/api/coding/interactions'
|| pathname === '/api/coding/runtime/diagnostics') && method === 'GET') return true;
if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(pathname)) return method === 'POST';
const match = pathname.match(
/^\/api\/coding\/conversations\/[^/]+(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/,
);
if (!match) return false;
if (!match[1]) return method === 'GET' || method === 'PATCH' || method === 'DELETE';
if (match[1] === 'snapshot') return method === 'GET';
return method === 'POST';
}
async function sendEvent(
res: ServerResponse,
event: string,
data: unknown,
id?: string,
): Promise<boolean> {
return await writeStreamingChunk(
res,
`${id ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`,
);
}
export async function handleCodingConversationRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (!isConversationRoute(url.pathname, req.method)) return false;
const service = ctx.codingProducts?.conversations;
if (!service) {
sendJson(res, 503, {
success: false,
code: 'CODING_CORE_UNAVAILABLE',
error: 'Coding services are unavailable',
});
return true;
}
try {
if (url.pathname === '/api/coding/events' && req.method === 'GET') {
const conversationId = url.searchParams.get('conversationId')?.trim() || undefined;
const stream = await service.openEventStream(conversationId);
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);
const close = () => stream.close();
req.once('close', close);
res.once('close', close);
try {
for (const snapshot of stream.snapshots) {
if (!await sendEvent(res, 'snapshot', {
type: 'snapshot',
conversationId: snapshot.conversation.id,
workerGeneration: snapshot.cursor.workerGeneration,
seq: snapshot.cursor.seq,
snapshot,
}, `${snapshot.conversation.id}:${snapshot.cursor.workerGeneration}:${snapshot.cursor.seq}`)) return true;
}
for await (const event of stream.events) {
if (!await sendEvent(
res,
event.type,
event,
`${event.conversationId}:${event.workerGeneration}:${event.seq}`,
)) break;
}
} finally {
req.off('close', close);
res.off('close', close);
stream.close();
if (!res.writableEnded) res.end();
}
return true;
}
if (url.pathname === '/api/coding/interactions' && req.method === 'GET') {
sendJson(res, 200, {
interactions: await service.listInteractions(
url.searchParams.get('conversationId')?.trim() || undefined,
),
});
return true;
}
const interactionMatch = url.pathname.match(/^\/api\/coding\/interactions\/([^/]+)\/respond$/);
if (interactionMatch && req.method === 'POST') {
const interactionId = decodeRouteId(interactionMatch[1]);
const body = await parseJsonBody<{
conversationId?: string;
cancelled?: unknown;
optionId?: unknown;
confirmed?: unknown;
value?: unknown;
}>(req);
const conversationId = typeof body.conversationId === 'string' ? body.conversationId.trim() : '';
let response: ConversationInteractionResponse;
if (body.cancelled === true) response = { interactionId, cancelled: true };
else if (typeof body.optionId === 'string') response = { interactionId, optionId: body.optionId };
else if (typeof body.confirmed === 'boolean') response = { interactionId, confirmed: body.confirmed };
else if (typeof body.value === 'string') response = { interactionId, value: body.value };
else invalidRequest('Interaction response is invalid');
await service.respondInteraction(conversationId, response);
sendNoContent(res);
return true;
}
if (url.pathname === '/api/coding/runtime/diagnostics' && req.method === 'GET') {
sendJson(res, 200, { runtime: service.getDiagnostics() });
return true;
}
const match = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)(?:\/(snapshot|prompt|abort|model|thinking|compact|fork|recover))?$/);
if (!match) return false;
const conversationId = decodeRouteId(match[1]);
const action = match[2];
if (!action && req.method === 'GET') {
sendJson(res, 200, { conversation: await service.getConversation(conversationId) });
return true;
}
if (!action && req.method === 'PATCH') {
sendJson(res, 200, {
conversation: await service.patchConversation(
conversationId,
await parseJsonBody(req),
),
});
return true;
}
if (!action && req.method === 'DELETE') {
await service.deleteConversation(conversationId);
sendNoContent(res);
return true;
}
if (action === 'snapshot' && req.method === 'GET') {
sendJson(res, 200, { snapshot: await service.getSnapshot(conversationId) });
return true;
}
if (action === 'prompt' && req.method === 'POST') {
const body = await parseJsonBody<{
clientRequestId?: unknown;
mode?: unknown;
text?: unknown;
attachments?: unknown;
}>(req);
sendJson(res, 202, { acceptance: await service.acceptPrompt({ conversationId, ...body }) });
return true;
}
if (action === 'abort' && req.method === 'POST') {
await service.abort(conversationId);
sendNoContent(res);
return true;
}
if (action === 'model' && req.method === 'POST') {
const body = await parseJsonBody<{ model?: unknown }>(req);
let model;
try { model = normalizeProductModelRef(body.model); } catch { invalidRequest('Product model is invalid'); }
sendJson(res, 200, { model: await service.setModel(conversationId, model) });
return true;
}
if (action === 'thinking' && req.method === 'POST') {
const body = await parseJsonBody<{ thinkingLevel?: unknown }>(req);
if (!THINKING_LEVELS.has(String(body.thinkingLevel))) invalidRequest('Thinking level is invalid');
sendJson(res, 200, {
model: await service.setThinking(
conversationId,
body.thinkingLevel as 'off' | 'minimal' | 'low' | 'medium' | 'high',
),
});
return true;
}
if (action === 'compact' && req.method === 'POST') {
await service.compact(conversationId);
sendNoContent(res);
return true;
}
if (action === 'recover' && req.method === 'POST') {
await service.recover(conversationId);
sendNoContent(res);
return true;
}
if (action === 'fork' && req.method === 'POST') {
const body = await parseJsonBody<{ sourceEntryId?: unknown }>(req);
const sourceEntryId = typeof body.sourceEntryId === 'string' && body.sourceEntryId.trim()
? body.sourceEntryId.trim()
: undefined;
sendJson(res, 201, { conversation: await service.fork(conversationId, sourceEntryId) });
return true;
}
} catch (error) {
sendCodingRouteError(res, error);
return true;
}
return false;
}

View File

@@ -0,0 +1,115 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { ProjectType } from '../../../shared/project-config';
import type { HostApiContext } from '../context';
import { parseJsonBody, sendJson, sendNoContent } from '../route-utils';
import { sendCodingRouteError } from './coding-route-errors';
function isProjectRoute(pathname: string): boolean {
return pathname === '/api/coding/projects'
|| pathname.startsWith('/api/coding/projects/');
}
export async function handleCodingProjectRoutes(
req: IncomingMessage,
res: ServerResponse,
url: URL,
ctx: HostApiContext,
): Promise<boolean> {
if (!isProjectRoute(url.pathname)) return false;
const projects = ctx.codingProducts?.projects;
const conversations = ctx.codingProducts?.conversations;
if (!projects || !conversations) {
sendJson(res, 503, {
success: false,
code: 'CODING_CORE_UNAVAILABLE',
error: 'Coding services are unavailable',
});
return true;
}
try {
if (url.pathname === '/api/coding/projects' && req.method === 'GET') {
const [items, activeProject] = await Promise.all([
projects.listProjects(),
projects.getActiveProject(),
]);
sendJson(res, 200, { projects: items, activeProjectId: activeProject?.id ?? null });
return true;
}
if (url.pathname === '/api/coding/projects/open' && req.method === 'POST') {
const body = await parseJsonBody<{ projectPath?: string }>(req);
sendJson(res, 200, { project: await projects.openProject(body.projectPath ?? '') });
return true;
}
if (url.pathname === '/api/coding/projects/create' && req.method === 'POST') {
const body = await parseJsonBody<{
projectPath?: string;
parentPath?: string;
projectName?: string;
projectType?: ProjectType;
}>(req);
sendJson(res, 201, { snapshot: await projects.createProject(body) });
return true;
}
if (url.pathname === '/api/coding/projects/remove' && req.method === 'POST') {
const body = await parseJsonBody<{ projectId?: string }>(req);
await projects.removeProject(body.projectId ?? '');
sendNoContent(res);
return true;
}
if (url.pathname === '/api/coding/projects/active' && req.method === 'GET') {
sendJson(res, 200, { project: await projects.getActiveProject() });
return true;
}
if (url.pathname === '/api/coding/projects/active' && req.method === 'POST') {
const body = await parseJsonBody<{ projectId?: string }>(req);
sendJson(res, 200, { project: await projects.setActiveProject(body.projectId ?? '') });
return true;
}
if (url.pathname === '/api/coding/projects/config' && req.method === 'GET') {
sendJson(res, 200, {
snapshot: await projects.getConfig(url.searchParams.get('projectId')?.trim() || undefined),
});
return true;
}
if (url.pathname === '/api/coding/projects/config' && req.method === 'PUT') {
const body = await parseJsonBody<{ projectId?: string; config?: unknown }>(req);
sendJson(res, 200, {
snapshot: await projects.saveConfig(body.projectId ?? '', body.config),
});
return true;
}
if (url.pathname === '/api/coding/projects/knowledge' && req.method === 'POST') {
const body = await parseJsonBody<{
projectId?: string;
fileName?: string;
contentBase64?: string;
}>(req);
sendJson(res, 201, {
knowledgeFiles: await projects.addKnowledgeFile({
projectId: body.projectId ?? '',
fileName: body.fileName ?? '',
contentBase64: body.contentBase64 ?? '',
}),
});
return true;
}
if (url.pathname === '/api/coding/projects/conversations' && req.method === 'GET') {
sendJson(res, 200, {
conversations: await conversations.listConversations(
url.searchParams.get('projectId')?.trim() || undefined,
),
});
return true;
}
if (url.pathname === '/api/coding/projects/conversations' && req.method === 'POST') {
const body = await parseJsonBody<{ projectId?: string; agentId?: unknown; title?: unknown }>(req);
sendJson(res, 201, { conversation: await conversations.createConversation(body) });
return true;
}
} catch (error) {
sendCodingRouteError(res, error);
return true;
}
return false;
}

View File

@@ -0,0 +1,42 @@
import type { ServerResponse } from 'node:http';
import { CodingProjectServiceError } from '../../coding-projects/project-service';
import { CodingConversationServiceError } from '../../coding-runtime/conversation-service';
import { sendJson } from '../route-utils';
export function sendCodingRouteError(res: ServerResponse, error: unknown): void {
if (error instanceof CodingProjectServiceError || error instanceof CodingConversationServiceError) {
sendJson(res, error.status, {
success: false,
code: error.code,
error: error.message,
});
return;
}
if (error instanceof SyntaxError) {
sendJson(res, 400, {
success: false,
code: 'CODING_REQUEST_INVALID',
error: 'Request JSON is invalid',
});
return;
}
sendJson(res, 500, {
success: false,
code: 'CODING_REQUEST_FAILED',
error: 'Coding request failed',
});
}
export function decodeRouteId(value: string): string {
try {
const decoded = decodeURIComponent(value).trim();
if (!decoded || decoded.length > 128 || decoded.includes('/')) throw new Error();
return decoded;
} catch {
throw new CodingConversationServiceError(
400,
'CODING_CONVERSATION_REQUEST_INVALID',
'Route identifier is invalid',
);
}
}

View File

@@ -268,6 +268,7 @@ async function fetchCurrentUserModelConfig(accessToken: string): Promise<Importe
}
async function refreshRunningRuntimeAfterProviderChange(ctx: HostApiContext): Promise<void> {
ctx.codingProducts?.conversations.markProviderStale();
if (ctx.opencodeManager.getStatus().state === 'stopped') return;
try {
@@ -398,6 +399,9 @@ export async function importCurrentUserModelConfig(
signal.throwIfAborted();
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (!runtimeRefreshRequired || options.runtimeRefresh === 'defer') {
ctx.codingProducts?.conversations.markProviderStale();
}
signal.throwIfAborted();
if (armStoppedApplyForNextFresh) {
lease.markRefreshPending();