From 01259f56cdecc15360524052b9639791d8a28379 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Sun, 29 Mar 2026 22:48:55 -0500 Subject: [PATCH] feat(oc-plugin): add MACP ACP runtime backend --- plugins/macp/README.md | 46 +++ plugins/macp/openclaw.plugin.json | 28 ++ plugins/macp/package.json | 20 ++ plugins/macp/src/index.ts | 73 +++++ plugins/macp/src/macp-runtime.ts | 275 +++++++++++++++++ plugins/macp/src/pi-bridge.ts | 492 ++++++++++++++++++++++++++++++ plugins/macp/tsconfig.json | 10 + pnpm-workspace.yaml | 13 + 8 files changed, 957 insertions(+) create mode 100644 plugins/macp/README.md create mode 100644 plugins/macp/openclaw.plugin.json create mode 100644 plugins/macp/package.json create mode 100644 plugins/macp/src/index.ts create mode 100644 plugins/macp/src/macp-runtime.ts create mode 100644 plugins/macp/src/pi-bridge.ts create mode 100644 plugins/macp/tsconfig.json diff --git a/plugins/macp/README.md b/plugins/macp/README.md new file mode 100644 index 0000000..2dd4669 --- /dev/null +++ b/plugins/macp/README.md @@ -0,0 +1,46 @@ +# MACP OpenClaw Plugin + +This plugin registers a new OpenClaw ACP runtime backend named `macp`. + +When OpenClaw calls `sessions_spawn(runtime: "macp", agentId: "pi")`, the plugin runs a Pi agent turn in-process using the MACP Pi runner logic and streams Pi output back as ACP runtime events. + +## Current behavior + +- Supports `agentId: "pi"` only +- Supports ACP `mode: "oneshot"` only +- Rejects persistent ACP sessions +- Reuses the Pi default tools from `tools/macp/dispatcher/pi_runner.ts` + +## Install in OpenClaw + +Add the plugin entry to your OpenClaw config: + +```json +{ + "plugins": ["~/src/mosaic-mono-v1/plugins/macp/src/index.ts"] +} +``` + +## Optional config + +```json +{ + "plugins": [ + { + "source": "~/src/mosaic-mono-v1/plugins/macp/src/index.ts", + "config": { + "defaultModel": "openai/gpt-5-mini", + "systemPrompt": "You are Pi running via MACP.", + "timeoutMs": 300000, + "logDir": "~/.openclaw/state/macp" + } + } + ] +} +``` + +## Verification + +```bash +pnpm exec tsc --noEmit -p plugins/macp/tsconfig.json +``` diff --git a/plugins/macp/openclaw.plugin.json b/plugins/macp/openclaw.plugin.json new file mode 100644 index 0000000..30e9379 --- /dev/null +++ b/plugins/macp/openclaw.plugin.json @@ -0,0 +1,28 @@ +{ + "id": "macp", + "name": "MACP Runtime", + "description": "Registers the macp ACP runtime backend and dispatches Pi turns through the MACP Pi runner.", + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "defaultModel": { + "type": "string", + "description": "Default Pi model in provider/model format. Defaults to openai/gpt-5-mini." + }, + "systemPrompt": { + "type": "string", + "description": "Optional system prompt prepended to each Pi turn." + }, + "timeoutMs": { + "type": "number", + "minimum": 1, + "description": "Maximum turn runtime in milliseconds. Defaults to 300000." + }, + "logDir": { + "type": "string", + "description": "Directory for Pi runtime logs. Defaults to the plugin state dir." + } + } + } +} diff --git a/plugins/macp/package.json b/plugins/macp/package.json new file mode 100644 index 0000000..60849d7 --- /dev/null +++ b/plugins/macp/package.json @@ -0,0 +1,20 @@ +{ + "name": "@mosaic/oc-macp-plugin", + "version": "0.1.0", + "type": "module", + "main": "src/index.ts", + "description": "OpenClaw ACP runtime backend that routes sessions_spawn(runtime:\"macp\") to the Pi MACP runner.", + "openclaw": { + "extensions": [ + "./src/index.ts" + ] + }, + "dependencies": { + "@mariozechner/pi-agent-core": "^0.63.1", + "@mariozechner/pi-ai": "^0.63.1", + "@sinclair/typebox": "^0.34.41" + }, + "devDependencies": { + "openclaw": "*" + } +} diff --git a/plugins/macp/src/index.ts b/plugins/macp/src/index.ts new file mode 100644 index 0000000..0d34d4d --- /dev/null +++ b/plugins/macp/src/index.ts @@ -0,0 +1,73 @@ +import path from 'node:path'; + +import { + registerAcpRuntimeBackend, + unregisterAcpRuntimeBackend, +} from '/home/jarvis/.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/acp-runtime.js'; +import type { OpenClawPluginApi } from '/home/jarvis/.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/index.js'; +import type { + OpenClawPluginService, + OpenClawPluginServiceContext, +} from '/home/jarvis/.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/src/plugins/types.js'; + +import { MacpRuntime } from './macp-runtime.js'; + +type PluginConfig = { + defaultModel?: string; + systemPrompt?: string; + timeoutMs?: number; + logDir?: string; +}; + +function resolveConfig(pluginConfig?: Record, stateDir?: string) { + const config = (pluginConfig ?? {}) as PluginConfig; + return { + defaultModel: config.defaultModel?.trim() || 'openai/gpt-5-mini', + systemPrompt: config.systemPrompt ?? '', + timeoutMs: + typeof config.timeoutMs === 'number' && + Number.isFinite(config.timeoutMs) && + config.timeoutMs > 0 + ? config.timeoutMs + : 300_000, + stateDir: config.logDir?.trim() ? path.resolve(config.logDir) : (stateDir ?? process.cwd()), + }; +} + +function createMacpRuntimeService(pluginConfig?: Record): OpenClawPluginService { + let runtime: MacpRuntime | null = null; + + return { + id: 'macp-runtime', + async start(ctx: OpenClawPluginServiceContext) { + const resolved = resolveConfig(pluginConfig, ctx.stateDir); + runtime = new MacpRuntime({ + ...resolved, + logger: ctx.logger, + }); + registerAcpRuntimeBackend({ + id: 'macp', + runtime, + healthy: () => runtime !== null, + }); + ctx.logger.info( + `macp runtime backend registered (defaultModel: ${resolved.defaultModel}, timeoutMs: ${resolved.timeoutMs})`, + ); + }, + async stop(_ctx: OpenClawPluginServiceContext) { + unregisterAcpRuntimeBackend('macp'); + runtime = null; + }, + }; +} + +const plugin = { + id: 'macp', + name: 'MACP Runtime', + description: 'ACP runtime backend that dispatches Pi oneshot sessions through MACP.', + register(api: OpenClawPluginApi) { + api.registerService(createMacpRuntimeService(api.pluginConfig)); + }, +}; + +export default plugin; diff --git a/plugins/macp/src/macp-runtime.ts b/plugins/macp/src/macp-runtime.ts new file mode 100644 index 0000000..fc28525 --- /dev/null +++ b/plugins/macp/src/macp-runtime.ts @@ -0,0 +1,275 @@ +import path from 'node:path'; +import { access } from 'node:fs/promises'; + +import type { + AcpRuntime, + AcpRuntimeCapabilities, + AcpRuntimeDoctorReport, + AcpRuntimeEnsureInput, + AcpRuntimeEvent, + AcpRuntimeHandle, + AcpRuntimeStatus, + AcpRuntimeTurnInput, +} from '/home/jarvis/.npm-global/lib/node_modules/openclaw/dist/plugin-sdk/acp-runtime.js'; + +import { formatAssistantEvent, runPiTurn } from './pi-bridge.js'; + +export interface MacpRuntimeConfig { + defaultModel: string; + systemPrompt: string; + timeoutMs: number; + stateDir: string; + logger?: { + info?: (message: string) => void; + warn?: (message: string) => void; + }; +} + +type HandleState = { + name: string; + agent: string; + cwd: string; + model: string; + systemPrompt: string; + timeoutMs: number; +}; + +const MACP_CAPABILITIES: AcpRuntimeCapabilities = { + controls: [], +}; + +const PI_RUNNER_PATH = '/home/jarvis/src/mosaic-mono-v1/tools/macp/dispatcher/pi_runner.ts'; + +function encodeHandleState(state: HandleState): string { + return JSON.stringify(state); +} + +function decodeHandleState(handle: AcpRuntimeHandle): HandleState { + const parsed = JSON.parse(handle.runtimeSessionName) as Partial; + if ( + typeof parsed.name !== 'string' || + typeof parsed.agent !== 'string' || + typeof parsed.cwd !== 'string' || + typeof parsed.model !== 'string' || + typeof parsed.systemPrompt !== 'string' || + typeof parsed.timeoutMs !== 'number' + ) { + throw new Error('Invalid MACP runtime handle state.'); + } + return parsed as HandleState; +} + +function toSessionName(input: AcpRuntimeEnsureInput): string { + return `${input.agent}-${input.sessionKey}`; +} + +export class MacpRuntime implements AcpRuntime { + constructor(private readonly config: MacpRuntimeConfig) {} + + async ensureSession(input: AcpRuntimeEnsureInput): Promise { + if (input.agent !== 'pi') { + throw new Error(`macp runtime only supports agentId "pi"; received "${input.agent}".`); + } + if (input.mode !== 'oneshot') { + throw new Error(`macp runtime only supports oneshot sessions; received "${input.mode}".`); + } + + const cwd = path.resolve(input.cwd ?? process.cwd()); + const state: HandleState = { + name: toSessionName(input), + agent: input.agent, + cwd, + model: this.config.defaultModel, + systemPrompt: this.config.systemPrompt, + timeoutMs: this.config.timeoutMs, + }; + + return { + sessionKey: input.sessionKey, + backend: 'macp', + runtimeSessionName: encodeHandleState(state), + cwd, + backendSessionId: state.name, + agentSessionId: state.name, + }; + } + + async *runTurn(input: AcpRuntimeTurnInput): AsyncIterable { + const state = decodeHandleState(input.handle); + const logPath = path.join( + this.config.stateDir, + 'macp', + `${state.name}-${Date.now()}-${input.requestId}.json`, + ); + + const streamQueue: AcpRuntimeEvent[] = []; + const waiters: Array<() => void> = []; + let finished = false; + + const push = (event: AcpRuntimeEvent) => { + streamQueue.push(event); + const waiter = waiters.shift(); + waiter?.(); + }; + + const resultPromise = runPiTurn({ + model: state.model, + systemPrompt: state.systemPrompt, + prompt: input.text, + workDir: state.cwd, + timeoutMs: state.timeoutMs, + logPath, + ...(input.signal ? { signal: input.signal } : {}), + onEvent: async (event) => { + switch (event.type) { + case 'message_update': { + const formatted = formatAssistantEvent(event.assistantMessageEvent); + if (formatted?.text) { + push({ + type: 'text_delta', + text: formatted.text, + ...(formatted.stream ? { stream: formatted.stream } : {}), + ...(formatted.tag ? { tag: formatted.tag } : {}), + }); + } + break; + } + case 'tool_execution_start': + push({ + type: 'tool_call', + text: JSON.stringify(event.args ?? {}), + tag: 'tool_call', + toolCallId: event.toolCallId, + title: event.toolName, + status: 'running', + }); + break; + case 'tool_execution_update': + push({ + type: 'tool_call', + text: JSON.stringify(event.partialResult ?? {}), + tag: 'tool_call_update', + toolCallId: event.toolCallId, + title: event.toolName, + status: 'running', + }); + break; + case 'tool_execution_end': + push({ + type: 'tool_call', + text: JSON.stringify(event.result ?? {}), + tag: 'tool_call_update', + toolCallId: event.toolCallId, + title: event.toolName, + status: event.isError ? 'error' : 'completed', + }); + break; + case 'turn_end': + push({ + type: 'status', + text: 'Pi turn completed.', + tag: 'usage_update', + }); + break; + } + }, + }) + .then((result) => { + if (result.output) { + push({ + type: 'status', + text: result.output, + tag: 'session_info_update', + }); + } + push({ + type: 'done', + stopReason: result.stopReason, + }); + }) + .catch((error) => { + push({ + type: 'error', + message: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + finished = true; + while (waiters.length > 0) { + waiters.shift()?.(); + } + }); + + try { + while (!finished || streamQueue.length > 0) { + if (streamQueue.length === 0) { + await new Promise((resolve) => waiters.push(resolve)); + continue; + } + yield streamQueue.shift() as AcpRuntimeEvent; + } + } finally { + await resultPromise; + } + } + + getCapabilities(): AcpRuntimeCapabilities { + return MACP_CAPABILITIES; + } + + async getStatus(input: { handle: AcpRuntimeHandle }): Promise { + const state = decodeHandleState(input.handle); + return { + summary: 'macp Pi oneshot runtime ready', + backendSessionId: state.name, + agentSessionId: state.name, + details: { + mode: 'oneshot', + model: state.model, + cwd: state.cwd, + }, + }; + } + + async doctor(): Promise { + try { + await access(PI_RUNNER_PATH); + const importErrors: string[] = []; + await import('@mariozechner/pi-agent-core').catch((error) => { + importErrors.push(error instanceof Error ? error.message : String(error)); + }); + await import('@mariozechner/pi-ai').catch((error) => { + importErrors.push(error instanceof Error ? error.message : String(error)); + }); + if (importErrors.length > 0) { + return { + ok: false, + code: 'MACP_DEPS_MISSING', + message: 'MACP runtime dependencies are not available.', + installCommand: 'pnpm install --frozen-lockfile', + details: importErrors, + }; + } + return { + ok: true, + message: 'MACP runtime is ready.', + details: [PI_RUNNER_PATH], + }; + } catch (error) { + return { + ok: false, + code: 'MACP_PI_RUNNER_MISSING', + message: 'Pi runner was not found.', + details: [error instanceof Error ? error.message : String(error)], + }; + } + } + + async cancel(_input: { handle: AcpRuntimeHandle; reason?: string }): Promise { + this.config.logger?.info?.('macp runtime cancel requested'); + } + + async close(_input: { handle: AcpRuntimeHandle; reason: string }): Promise { + this.config.logger?.info?.('macp runtime close requested'); + } +} diff --git a/plugins/macp/src/pi-bridge.ts b/plugins/macp/src/pi-bridge.ts new file mode 100644 index 0000000..fde5171 --- /dev/null +++ b/plugins/macp/src/pi-bridge.ts @@ -0,0 +1,492 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { + runAgentLoop, + type AgentContext, + type AgentEvent, + type AgentLoopConfig, + type AgentMessage, + type AgentTool, +} from '@mariozechner/pi-agent-core'; +import { + getModel, + Type, + type AssistantMessage, + type AssistantMessageEvent, + type Model, + type Static, +} from '@mariozechner/pi-ai'; + +const execFileAsync = promisify(execFile); + +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +export interface PiBridgeOptions { + model: string; + systemPrompt: string; + prompt: string; + workDir: string; + timeoutMs: number; + logPath: string; + signal?: AbortSignal; + onEvent?: (event: AgentEvent) => void | Promise; +} + +export interface PiBridgeResult { + exitCode: number; + output: string; + messages: AgentMessage[]; + tokenUsage: { input: number; output: number }; + stopReason: string; +} + +type TranscriptEvent = { + timestamp: string; + type: string; + data?: JsonValue; +}; + +function nowIso(): string { + return new Date().toISOString(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asJsonValue(value: unknown): JsonValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((item) => asJsonValue(item)); + } + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, asJsonValue(item)])); + } + return String(value); +} + +function resolvePath(workDir: string, targetPath: string): string { + if (path.isAbsolute(targetPath)) { + return path.normalize(targetPath); + } + return path.resolve(workDir, targetPath); +} + +async function runCommand( + command: string, + workDir: string, + timeoutMs: number, +): Promise<{ stdout: string; stderr: string }> { + const result = await execFileAsync('bash', ['-lc', command], { + cwd: workDir, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + timeout: Math.max(1, timeoutMs), + }); + return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; +} + +function extractText(message: AgentMessage | undefined): string { + if ( + !message || + !('role' in message) || + message.role !== 'assistant' || + !Array.isArray(message.content) + ) { + return ''; + } + return message.content + .filter( + (part): part is { type: 'text'; text: string } => + isRecord(part) && part.type === 'text' && typeof part.text === 'string', + ) + .map((part) => part.text) + .join('\n') + .trim(); +} + +function getFinalAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined { + return [...messages] + .reverse() + .find( + (message): message is AssistantMessage => 'role' in message && message.role === 'assistant', + ); +} + +function resolveModel(modelRef: string): Model { + const slashIndex = modelRef.indexOf('/'); + if (slashIndex < 1) { + throw new Error(`Invalid Pi model "${modelRef}". Expected provider/model.`); + } + const provider = modelRef.slice(0, slashIndex); + const modelId = modelRef.slice(slashIndex + 1); + if (!modelId) { + throw new Error(`Invalid Pi model "${modelRef}". Expected provider/model.`); + } + + const isOpenAiOAuth = + provider === 'openai' && (process.env.OPENAI_API_KEY?.startsWith('eyJ') ?? false); + + try { + const model = getModel(provider as never, modelId as never); + if (isOpenAiOAuth && model.api === 'openai-responses') { + return { ...model, api: 'openai-completions' }; + } + return model; + } catch { + const fallbackApi = + provider === 'anthropic' + ? 'anthropic-messages' + : provider === 'openai' + ? isOpenAiOAuth + ? 'openai-completions' + : 'openai-responses' + : 'openai-completions'; + + return { + id: modelId, + name: modelId, + api: fallbackApi, + provider, + baseUrl: '', + reasoning: true, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 16384, + }; + } +} + +function createDefaultTools(workDir: string): AgentTool[] { + const readFileSchema = Type.Object({ + path: Type.String({ description: 'Relative or absolute path to read.' }), + }); + const writeFileSchema = Type.Object({ + path: Type.String({ description: 'Relative or absolute path to write.' }), + content: Type.String({ description: 'UTF-8 file content.' }), + append: Type.Optional(Type.Boolean({ description: 'Append instead of overwrite.' })), + }); + const editFileSchema = Type.Object({ + path: Type.String({ description: 'Relative or absolute path to edit.' }), + search: Type.String({ description: 'The exact text to replace.' }), + replace: Type.String({ description: 'Replacement text.' }), + replaceAll: Type.Optional(Type.Boolean({ description: 'Replace every occurrence.' })), + }); + const execShellSchema = Type.Object({ + command: Type.String({ description: 'Shell command to execute in the worktree.' }), + timeoutMs: Type.Optional( + Type.Number({ description: 'Optional timeout override in milliseconds.' }), + ), + }); + const listDirSchema = Type.Object({ + path: Type.Optional(Type.String({ description: 'Directory path relative to the worktree.' })), + }); + const gitSchema = Type.Object({ + args: Type.Array(Type.String({ description: 'Git CLI argument.' }), { + description: 'Arguments passed to git.', + minItems: 1, + }), + }); + + const readFileTool: AgentTool = { + name: 'read_file', + label: 'Read File', + description: 'Read a UTF-8 text file from disk.', + parameters: readFileSchema, + async execute(_toolCallId, params: Static) { + const filePath = resolvePath(workDir, params.path); + const content = await fs.readFile(filePath, 'utf-8'); + return { + content: [{ type: 'text', text: content }], + details: { path: filePath }, + }; + }, + }; + + const writeFileTool: AgentTool = { + name: 'write_file', + label: 'Write File', + description: 'Write or append a UTF-8 text file.', + parameters: writeFileSchema, + async execute(_toolCallId, params) { + const filePath = resolvePath(workDir, params.path); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + if (params.append) { + await fs.appendFile(filePath, params.content, 'utf-8'); + } else { + await fs.writeFile(filePath, params.content, 'utf-8'); + } + return { + content: [{ type: 'text', text: `Wrote ${filePath}` }], + details: { path: filePath, append: Boolean(params.append) }, + }; + }, + }; + + const editFileTool: AgentTool = { + name: 'edit_file', + label: 'Edit File', + description: 'Apply an exact-match text replacement to a UTF-8 text file.', + parameters: editFileSchema, + async execute(_toolCallId, params) { + const filePath = resolvePath(workDir, params.path); + const original = await fs.readFile(filePath, 'utf-8'); + if (!original.includes(params.search)) { + throw new Error(`Search text not found in ${filePath}`); + } + const updated = params.replaceAll + ? original.split(params.search).join(params.replace) + : original.replace(params.search, params.replace); + await fs.writeFile(filePath, updated, 'utf-8'); + return { + content: [{ type: 'text', text: `Updated ${filePath}` }], + details: { path: filePath, replaceAll: Boolean(params.replaceAll) }, + }; + }, + }; + + const execShellTool: AgentTool = { + name: 'exec_shell', + label: 'Exec Shell', + description: 'Execute an unrestricted shell command inside the worktree.', + parameters: execShellSchema, + async execute(_toolCallId, params) { + const result = await runCommand(params.command, workDir, params.timeoutMs ?? 300_000); + return { + content: [ + { + type: 'text', + text: + [result.stdout.trim(), result.stderr.trim()].filter(Boolean).join('\n') || + '(no output)', + }, + ], + details: { command: params.command, stdout: result.stdout, stderr: result.stderr }, + }; + }, + }; + + const listDirTool: AgentTool = { + name: 'list_dir', + label: 'List Dir', + description: 'List directory entries for a relative or absolute path.', + parameters: listDirSchema, + async execute(_toolCallId, params) { + const dirPath = resolvePath(workDir, params.path ?? '.'); + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const lines = entries + .sort((left, right) => left.name.localeCompare(right.name)) + .map((entry) => `${entry.isDirectory() ? 'dir ' : 'file'} ${entry.name}`); + return { + content: [{ type: 'text', text: lines.join('\n') }], + details: { path: dirPath, entries: lines }, + }; + }, + }; + + const gitTool: AgentTool = { + name: 'git', + label: 'Git', + description: 'Run git commands such as status, diff, add, or commit in the worktree.', + parameters: gitSchema, + async execute(_toolCallId, params) { + const result = await execFileAsync('git', params.args, { + cwd: workDir, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + timeout: 300_000, + }); + const text = + [result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join('\n') || '(no output)'; + return { + content: [{ type: 'text', text }], + details: { args: params.args, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }, + }; + }, + }; + + return [readFileTool, writeFileTool, editFileTool, execShellTool, listDirTool, gitTool]; +} + +function buildLogEntry(event: unknown): TranscriptEvent { + if (!isRecord(event) || typeof event.type !== 'string') { + return { timestamp: nowIso(), type: 'unknown', data: asJsonValue(event) }; + } + + const summary: Record = {}; + for (const [key, value] of Object.entries(event)) { + if (key === 'message' || key === 'toolResults' || key === 'messages') { + summary[key] = asJsonValue(value); + continue; + } + if (key !== 'type') { + summary[key] = asJsonValue(value); + } + } + + return { timestamp: nowIso(), type: event.type, data: summary }; +} + +function inferExitCode(finalMessage: AssistantMessage | undefined, output: string): number { + if (!finalMessage) { + return 1; + } + if (finalMessage.stopReason === 'error' || finalMessage.stopReason === 'aborted') { + return 1; + } + if (/^(failed|failure|blocked)\b/i.test(output)) { + return 1; + } + return 0; +} + +export function formatAssistantEvent(event: AssistantMessageEvent): { + text?: string; + stream?: 'output' | 'thought'; + tag?: string; +} | null { + switch (event.type) { + case 'text_delta': + return { text: event.delta, stream: 'output', tag: 'agent_message_chunk' }; + case 'thinking_delta': + return { text: event.delta, stream: 'thought', tag: 'agent_thought_chunk' }; + case 'toolcall_start': + return { + text: JSON.stringify(event.partial.content[event.contentIndex] ?? {}), + tag: 'tool_call', + }; + case 'toolcall_delta': + return { text: event.delta, tag: 'tool_call_update' }; + case 'done': + return null; + case 'error': + return null; + default: + return null; + } +} + +export async function runPiTurn(options: PiBridgeOptions): Promise { + const transcript: TranscriptEvent[] = []; + const workDir = path.resolve(options.workDir); + const logPath = path.resolve(options.logPath); + const timeoutController = new AbortController(); + const combinedSignal = options.signal + ? AbortSignal.any([options.signal, timeoutController.signal]) + : timeoutController.signal; + const timeoutHandle = setTimeout(() => timeoutController.abort(), Math.max(1, options.timeoutMs)); + + const context: AgentContext = { + systemPrompt: options.systemPrompt, + messages: [], + tools: createDefaultTools(workDir), + }; + + const config: AgentLoopConfig = { + model: resolveModel(options.model), + reasoning: 'medium', + convertToLlm: async (messages) => + messages.filter( + (message): message is AgentMessage => + isRecord(message) && + typeof message.role === 'string' && + ['user', 'assistant', 'toolResult'].includes(message.role), + ), + }; + + const prompts: AgentMessage[] = [ + { + role: 'user', + content: options.prompt, + timestamp: Date.now(), + }, + ]; + + try { + transcript.push({ + timestamp: nowIso(), + type: 'runner_start', + data: { + model: options.model, + workDir, + timeoutMs: options.timeoutMs, + }, + }); + + const messages = await runAgentLoop( + prompts, + context, + config, + async (event) => { + transcript.push(buildLogEntry(event)); + await options.onEvent?.(event); + }, + combinedSignal, + ); + + const finalMessage = getFinalAssistantMessage(messages); + const output = extractText(finalMessage); + const tokenUsage = finalMessage + ? { + input: finalMessage.usage?.input ?? 0, + output: finalMessage.usage?.output ?? 0, + } + : { input: 0, output: 0 }; + + const result: PiBridgeResult = { + exitCode: inferExitCode(finalMessage, output), + output, + messages, + tokenUsage, + stopReason: finalMessage?.stopReason ?? 'stop', + }; + + transcript.push({ + timestamp: nowIso(), + type: 'runner_end', + data: { + exitCode: result.exitCode, + output: result.output, + tokenUsage: result.tokenUsage, + stopReason: result.stopReason, + }, + }); + + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.writeFile(logPath, `${JSON.stringify({ transcript, result }, null, 2)}\n`, 'utf-8'); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + transcript.push({ + timestamp: nowIso(), + type: 'runner_error', + data: { error: message }, + }); + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.writeFile( + logPath, + `${JSON.stringify({ transcript, result: { exitCode: 1, output: message, tokenUsage: { input: 0, output: 0 }, stopReason: 'error' } }, null, 2)}\n`, + 'utf-8', + ); + return { + exitCode: 1, + output: message, + messages: [], + tokenUsage: { input: 0, output: 0 }, + stopReason: combinedSignal.aborted ? 'aborted' : 'error', + }; + } finally { + clearTimeout(timeoutHandle); + } +} diff --git a/plugins/macp/tsconfig.json b/plugins/macp/tsconfig.json new file mode 100644 index 0000000..99e7d76 --- /dev/null +++ b/plugins/macp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../packages/config/typescript/library.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 93e6fa0..1e3145c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,16 @@ packages: - 'apps/*' - 'packages/*' - 'plugins/*' + +ignoredBuiltDependencies: + - '@nestjs/core' + - '@swc/core' + - better-sqlite3 + - esbuild + - sharp + +onlyBuiltDependencies: + - '@prisma/client' + - '@prisma/engines' + - prisma + - node-pty