feat(oc-plugin): add MACP ACP runtime backend
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed

This commit is contained in:
Jarvis
2026-03-29 22:48:55 -05:00
parent 472f046a85
commit 01259f56cd
8 changed files with 957 additions and 0 deletions

46
plugins/macp/README.md Normal file
View File

@@ -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
```

View File

@@ -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."
}
}
}
}

20
plugins/macp/package.json Normal file
View File

@@ -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": "*"
}
}

73
plugins/macp/src/index.ts Normal file
View File

@@ -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<string, unknown>, 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<string, unknown>): 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;

View File

@@ -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<HandleState>;
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<AcpRuntimeHandle> {
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<AcpRuntimeEvent> {
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<void>((resolve) => waiters.push(resolve));
continue;
}
yield streamQueue.shift() as AcpRuntimeEvent;
}
} finally {
await resultPromise;
}
}
getCapabilities(): AcpRuntimeCapabilities {
return MACP_CAPABILITIES;
}
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
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<AcpRuntimeDoctorReport> {
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<void> {
this.config.logger?.info?.('macp runtime cancel requested');
}
async close(_input: { handle: AcpRuntimeHandle; reason: string }): Promise<void> {
this.config.logger?.info?.('macp runtime close requested');
}
}

View File

@@ -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<void>;
}
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<string, unknown> {
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<any> {
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<any>[] {
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<typeof readFileSchema> = {
name: 'read_file',
label: 'Read File',
description: 'Read a UTF-8 text file from disk.',
parameters: readFileSchema,
async execute(_toolCallId, params: Static<typeof readFileSchema>) {
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<typeof writeFileSchema> = {
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<typeof editFileSchema> = {
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<typeof execShellSchema> = {
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<typeof listDirSchema> = {
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<typeof gitSchema> = {
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<string, JsonValue> = {};
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<PiBridgeResult> {
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);
}
}

View File

@@ -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"]
}