import type { BackgroundCompletion, JsonValue, PluginCommandTask, PluginPublicEvent, PluginPublicEventType, } from "@/domain"; import type { BackgroundTaskAttachment, FocusedProject, FocusedProjectGateway, OpenTerminalOptions, PluginConfigGateway, PluginEventGateway, PluginProjectStructureQuery, PluginTaskGateway, PluginToolchainGateway, PluginWorkspaceGateway, ProjectGateway, TerminalGateway, WorkStateGateway, } from "@/ports"; export interface PluginServices { workspace: WorkspaceService; tasks: BackgroundTaskService; tooling: ToolingService; events: EventService; config: ConfigDocumentService; terminal: TerminalService; } export interface WorkspaceProject { id: string; name: string; root: string; } export interface WorkspaceService { getCurrentProject(): Promise; getProjectRoot(projectId?: string): Promise; readProjectContext(projectId?: string): Promise; updateProjectContext(content: string, projectId?: string): Promise; resolvePath(path: string, projectId?: string): Promise; readTextFile(path: string, projectId?: string): Promise; readBinaryFile(path: string, projectId?: string): Promise; writeTextFile(path: string, content: string, projectId?: string): Promise; writeBinaryFile(path: string, bytes: Uint8Array, projectId?: string): Promise; listDirectory(path?: string, projectId?: string): Promise; stat(path: string, projectId?: string): Promise; watch(path: string, handler: WorkspaceWatchHandler, projectId?: string): Promise; queryStructure(query?: WorkspaceStructureQuery): Promise; } export interface WorkspaceResolvedPath { projectId: string; root: string; path: string; } export interface WorkspaceTextFile { path: string; content: string; } export interface WorkspaceBinaryFile { path: string; bytes: Uint8Array; } export interface WorkspaceDirEntry { name: string; path: string; isDir: boolean; } export interface WorkspaceDirectoryListing { path: string; entries: WorkspaceDirEntry[]; } export interface WorkspaceStat { path: string; exists: boolean; isFile: boolean; isDir: boolean; len: number | null; } export interface WorkspaceWatchEvent { path: string; kind: "created" | "modified" | "deleted" | "renamed" | "unknown"; operation: string; projectId: string; } export type WorkspaceWatchHandler = (event: WorkspaceWatchEvent) => void; export interface WorkspaceWatch { dispose(): void; } export interface WorkspaceStructureQuery { projectId?: string; path?: string; maxDepth?: number; maxEntries?: number; } export type ProjectStructureEntryKind = "file" | "directory"; export interface ProjectStructureEntry { path: string; name: string; kind: ProjectStructureEntryKind; } export interface ProjectConvention { id: string; markerPath: string; } export interface ProjectModule { path: string; markerPath: string; conventionId: string; } export interface ProjectStructure { projectId: string; rootPath: string; entries: ProjectStructureEntry[]; conventions: ProjectConvention[]; modules: ProjectModule[]; truncated: boolean; } export interface BackgroundTaskStatus { taskId: string; ownerAgentId: string; projectId: string; kind: string; status: "pending" | "running" | "completed" | "failed" | "cancelled" | "delivered"; exitCode: number | null; summary: string | null; stdoutTail: string | null; stderrTail: string | null; updatedAtMs: number; } export interface BackgroundTaskOutputAttachment { taskId: string; scrollback: Uint8Array; live: boolean; detach(): void; } export interface BackgroundTaskRetryResult { taskId?: string; } export interface RunCommandTaskOptions { projectId?: string; ownerAgentId: string; label?: string; command: string; args?: string[]; cwd?: string; env?: Record | Array<[string, string]>; recordOnly?: boolean; deadlineMs?: number; } export interface CommandTaskStatus { taskId: string; ownerAgentId: string; projectId: string; kind: string; state: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "expired"; exitCode: number | null; summary: string | null; stdoutTail: string | null; stderrTail: string | null; createdAtMs: number; updatedAtMs: number; } export interface ToolRequirement { id: string; executable: string; versionArgs?: string[]; required?: boolean; env?: Record | Array<[string, string]>; } export interface EnvRequirement { name: string; required?: boolean; equals?: string; } export interface FileRequirement { path: string; required?: boolean; kind?: "file" | "directory" | "any"; } export interface ToolchainDiagnosticRequest { projectId?: string; cwd?: string; tools?: ToolRequirement[]; env?: EnvRequirement[]; files?: FileRequirement[]; } export interface ToolchainDiagnostic { projectId: string; cwd: string; ok: boolean; tools: ToolDiagnostic[]; env: EnvDiagnostic[]; files: FileDiagnostic[]; messages: DiagnosticMessage[]; } export interface ToolDiagnostic { id: string; executable: string; present: boolean; ok: boolean; status: "ok" | "failed" | "missing"; required: boolean; exitCode: number | null; version: string | null; stdout: string | null; stderr: string | null; error: string | null; } export interface EnvDiagnostic { name: string; present: boolean; ok: boolean; required: boolean; value: string | null; status: "ok" | "missing" | "mismatch"; } export interface FileDiagnostic { path: string; exists: boolean; ok: boolean; required: boolean; kind: "file" | "directory" | "other" | "missing"; expectedKind: "file" | "directory" | "any" | null; len: number | null; } export interface DiagnosticMessage { level: "info" | "warning" | "error"; message: string; } export interface ToolingService { diagnose(request: ToolchainDiagnosticRequest): Promise; } export type PublicEvent = PluginPublicEvent; export type PublicEventType = PluginPublicEventType; export interface EventSubscribeOptions { projectId?: string; eventTypes?: PublicEventType[]; capacity?: number; pollIntervalMs?: number; maxEventsPerPoll?: number; onDropped?: (count: number) => void; } export interface EventSubscription { readonly subscriptionId: string; readonly projectId: string; readonly eventTypes: PublicEventType[]; readonly retention: string; dispose(): void; } export type EventHandler = (event: PublicEvent) => void; export interface EventService { subscribe(options: EventSubscribeOptions, handler: EventHandler): Promise; } export type ConfigDocumentFormat = "json"; export type ConfigUpdateMode = "mergePatch" | "replace"; export interface ConfigDocumentReadOptions { projectId?: string; path: string; format?: ConfigDocumentFormat; } export interface ConfigDocumentUpdateOptions extends ConfigDocumentReadOptions { mode?: ConfigUpdateMode; value: JsonValue; } export interface ConfigDocument { projectId: string; path: string; format: ConfigDocumentFormat; value: T; } export interface ConfigDocumentWriteResult { projectId: string; path: string; format: ConfigDocumentFormat; mode: ConfigUpdateMode; bytesWritten: number; } export interface ConfigDocumentService { readDocument( options: ConfigDocumentReadOptions, ): Promise>; updateDocument(options: ConfigDocumentUpdateOptions): Promise; } export interface BackgroundTaskService { runCommand(options: RunCommandTaskOptions): Promise; getCommandStatus(taskId: string): Promise; list(projectId?: string): Promise; getStatus(taskId: string, projectId?: string): Promise; attachOutput( taskId: string, onData: (bytes: Uint8Array) => void, ): Promise; cancel(taskId: string): Promise; retry(taskId: string): Promise; } export interface TerminalOpenOptions { cwd?: string; rows?: number; cols?: number; onData?: (bytes: Uint8Array) => void; } export interface TerminalReattachOptions { onData?: (bytes: Uint8Array) => void; } export interface TerminalSession { readonly sessionId: string; write(data: Uint8Array): Promise; resize(rows: number, cols: number): Promise; detach(): void; close(): Promise; } export interface TerminalReattachResult { session: TerminalSession; scrollback: Uint8Array; } export interface TerminalService { open(options?: TerminalOpenOptions): Promise; reattach(sessionId: string, options?: TerminalReattachOptions): Promise; close(sessionId: string): Promise; } interface PluginServiceGatewaySet { project: ProjectGateway; terminal: TerminalGateway; workState: WorkStateGateway; focusedProject: FocusedProjectGateway; pluginWorkspace: PluginWorkspaceGateway; pluginTask: PluginTaskGateway; pluginToolchain: PluginToolchainGateway; pluginEvents: PluginEventGateway; pluginConfig: PluginConfigGateway; } const DEFAULT_ROWS = 24; const DEFAULT_COLS = 80; const DEFAULT_EVENT_POLL_INTERVAL_MS = 1000; const MIN_EVENT_POLL_INTERVAL_MS = 100; function noopDataHandler(): void { // Intentionally empty: plugin code may opt into output bytes per call. } function toWorkspaceProject(project: FocusedProject): WorkspaceProject { return { id: project.id, name: project.name, root: project.root }; } function toBackgroundTaskStatus(task: BackgroundCompletion): BackgroundTaskStatus { return { taskId: task.taskId, ownerAgentId: task.ownerAgentId, projectId: task.projectId, kind: task.kind, status: task.status, exitCode: task.exitCode, summary: task.summary, stdoutTail: task.stdoutTail, stderrTail: task.stderrTail, updatedAtMs: task.updatedAtMs, }; } function toCommandTaskStatus(task: PluginCommandTask): CommandTaskStatus { return { taskId: task.taskId, ownerAgentId: task.ownerAgentId, projectId: task.projectId, kind: task.kind, state: task.state, exitCode: task.exitCode, summary: task.summary, stdoutTail: task.stdoutTail, stderrTail: task.stderrTail, createdAtMs: task.createdAtMs, updatedAtMs: task.updatedAtMs, }; } function envEntries(env: RunCommandTaskOptions["env"]): Array<[string, string]> { if (!env) return []; return Array.isArray(env) ? env : Object.entries(env); } function toolEnvEntries(env: ToolRequirement["env"]): Array<[string, string]> { if (!env) return []; return Array.isArray(env) ? env : Object.entries(env); } function commandLabel(options: RunCommandTaskOptions): string { if (options.label?.trim()) return options.label; return [options.command, ...(options.args ?? [])].join(" "); } function eventPollIntervalMs(options: EventSubscribeOptions): number { return Math.max(options.pollIntervalMs ?? DEFAULT_EVENT_POLL_INTERVAL_MS, MIN_EVENT_POLL_INTERVAL_MS); } function workspaceWatchKind(operation: string): WorkspaceWatchEvent["kind"] { const normalized = operation.toLowerCase(); if (normalized.includes("create") || normalized.includes("write")) return "created"; if (normalized.includes("delete") || normalized.includes("remove")) return "deleted"; if (normalized.includes("rename") || normalized.includes("move")) return "renamed"; if (normalized.includes("modify") || normalized.includes("update")) return "modified"; return "unknown"; } function workspacePathMatches(watchedPath: string, eventPath: string): boolean { return ( watchedPath === "" || eventPath === watchedPath || eventPath.startsWith(`${watchedPath}/`) ); } export function createPluginServices(gateways: PluginServiceGatewaySet): PluginServices { async function currentProject(): Promise { const focused = await gateways.focusedProject.getFocusedProject(); return focused ? toWorkspaceProject(focused) : null; } async function requireProject(projectId?: string): Promise { if (projectId) { const project = (await gateways.project.listProjects()).find((p) => p.id === projectId); if (!project) throw new Error(`project not found: ${projectId}`); return { id: project.id, name: project.name, root: project.root }; } const focused = await currentProject(); if (!focused) throw new Error("no current project is focused"); return focused; } async function subscribeToEvents( options: EventSubscribeOptions, handler: EventHandler, ): Promise { const project = await requireProject(options.projectId); const subscription = await gateways.pluginEvents.subscribe({ projectId: project.id, eventTypes: options.eventTypes ?? [], capacity: options.capacity, }); let disposed = false; let polling = false; const poll = async () => { if (disposed || polling) return; polling = true; try { const batch = await gateways.pluginEvents.poll({ subscriptionId: subscription.subscriptionId, maxEvents: options.maxEventsPerPoll, }); if (disposed) return; if (batch.dropped > 0) options.onDropped?.(batch.dropped); for (const event of batch.events) { handler(event); } } catch (error) { if (!disposed) console.warn("[plugin-events] poll failed", error); } finally { polling = false; } }; void poll(); const timer = setInterval(() => void poll(), eventPollIntervalMs(options)); return { subscriptionId: subscription.subscriptionId, projectId: subscription.projectId, eventTypes: subscription.eventTypes, retention: subscription.retention, dispose() { if (disposed) return; disposed = true; clearInterval(timer); void gateways.pluginEvents.unsubscribe({ subscriptionId: subscription.subscriptionId, }); }, }; } const workspace: WorkspaceService = { getCurrentProject: currentProject, async getProjectRoot(projectId) { return (await requireProject(projectId)).root; }, async readProjectContext(projectId) { const project = await requireProject(projectId); return gateways.project.readProjectContext(project.id); }, async updateProjectContext(content, projectId) { const project = await requireProject(projectId); await gateways.project.updateProjectContext(project.id, content); }, async resolvePath(path, projectId) { const project = await requireProject(projectId); const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path }); return { projectId: project.id, root: project.root, path: stat.path }; }, async readTextFile(path, projectId) { const project = await requireProject(projectId); return gateways.pluginWorkspace.readText({ projectId: project.id, path }); }, async readBinaryFile(path, projectId) { const project = await requireProject(projectId); return gateways.pluginWorkspace.readBinary({ projectId: project.id, path }); }, async writeTextFile(path, content, projectId) { const project = await requireProject(projectId); await gateways.pluginWorkspace.writeText({ projectId: project.id, path, content }); }, async writeBinaryFile(path, bytes, projectId) { const project = await requireProject(projectId); await gateways.pluginWorkspace.writeBinary({ projectId: project.id, path, bytes }); }, async listDirectory(path = ".", projectId) { const project = await requireProject(projectId); return gateways.pluginWorkspace.listDir({ projectId: project.id, path }); }, async stat(path, projectId) { const project = await requireProject(projectId); return gateways.pluginWorkspace.stat({ projectId: project.id, path }); }, async watch(path, handler, projectId) { const project = await requireProject(projectId); const stat = await gateways.pluginWorkspace.stat({ projectId: project.id, path }); const subscription = await subscribeToEvents( { projectId: project.id, eventTypes: ["workspaceFileChanged"] }, (event) => { if (event.type !== "workspaceFileChanged") return; if (!workspacePathMatches(stat.path, event.path)) return; handler({ path: event.path, kind: workspaceWatchKind(event.operation), operation: event.operation, projectId: event.projectId, }); }, ); return { dispose: () => subscription.dispose() }; }, async queryStructure(query = {}) { const project = await requireProject(query.projectId); const input: PluginProjectStructureQuery = { projectId: project.id, path: query.path, maxDepth: query.maxDepth, maxEntries: query.maxEntries, }; return gateways.pluginWorkspace.queryProjectStructure(input); }, }; async function listTasks(projectId?: string): Promise { const project = await requireProject(projectId); const state = await gateways.workState.getProjectWorkState(project.id); return state.agents .flatMap((agent) => agent.backgroundTasks ?? []) .map(toBackgroundTaskStatus); } const tasks: BackgroundTaskService = { async runCommand(options) { const project = await requireProject(options.projectId); if (options.ownerAgentId.trim() === "") { throw new Error("ownerAgentId is required to correlate a plugin command task"); } const task = await gateways.pluginTask.runCommand({ projectId: project.id, ownerAgentId: options.ownerAgentId, label: commandLabel(options), command: options.command, args: options.args ?? [], cwd: options.cwd, env: envEntries(options.env), recordOnly: options.recordOnly ?? false, deadlineMs: options.deadlineMs, }); return toCommandTaskStatus(task); }, async getCommandStatus(taskId) { const task = await gateways.pluginTask.getStatus({ taskId }); return task ? toCommandTaskStatus(task) : null; }, list: listTasks, async getStatus(taskId, projectId) { return (await listTasks(projectId)).find((task) => task.taskId === taskId) ?? null; }, async attachOutput(taskId, onData) { const attachment: BackgroundTaskAttachment = await gateways.workState.attachBackgroundTask(taskId, onData); return { taskId: attachment.taskId, scrollback: attachment.scrollback, live: attachment.live, detach: () => attachment.detach(), }; }, async cancel(taskId) { await gateways.workState.cancelBackgroundTask(taskId); }, async retry(taskId) { await gateways.workState.retryBackgroundTask(taskId); return {}; }, }; const tooling: ToolingService = { async diagnose(request) { const project = await requireProject(request.projectId); return gateways.pluginToolchain.diagnose({ projectId: project.id, cwd: request.cwd, tools: (request.tools ?? []).map((tool) => ({ id: tool.id, executable: tool.executable, versionArgs: tool.versionArgs ?? [], required: tool.required ?? false, env: toolEnvEntries(tool.env), })), env: request.env ?? [], files: request.files ?? [], }); }, }; const events: EventService = { subscribe: subscribeToEvents, }; const config: ConfigDocumentService = { async readDocument( options: ConfigDocumentReadOptions, ): Promise> { const project = await requireProject(options.projectId); const document = await gateways.pluginConfig.readDocument({ projectId: project.id, path: options.path, format: options.format, }); return document as ConfigDocument; }, async updateDocument(options) { const project = await requireProject(options.projectId); return gateways.pluginConfig.updateDocument({ projectId: project.id, path: options.path, format: options.format, mode: options.mode, value: options.value, }); }, }; const terminal: TerminalService = { async open(options = {}) { const cwd = options.cwd ?? (await workspace.getProjectRoot()); const openOptions: OpenTerminalOptions = { cwd, rows: options.rows ?? DEFAULT_ROWS, cols: options.cols ?? DEFAULT_COLS, }; return gateways.terminal.openTerminal(openOptions, options.onData ?? noopDataHandler); }, async reattach(sessionId, options = {}) { const result = await gateways.terminal.reattach( sessionId, options.onData ?? noopDataHandler, ); return { session: result.handle, scrollback: result.scrollback }; }, async close(sessionId) { await gateways.terminal.closeTerminal(sessionId); }, }; return { workspace, tasks, tooling, events, config, terminal }; }