chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
# MACP OpenClaw Plugin
|
||||
|
||||
This plugin registers a new OpenClaw ACP runtime backend named `macp`.
|
||||
|
||||
When OpenClaw calls `sessions_spawn(runtime: "macp")`, the plugin now writes the prompt to a brief file, queues a MACP controller task in `.mosaic/orchestrator/tasks.json`, triggers `mosaic-orchestrator-run --once`, polls `.mosaic/orchestrator/results/<task-id>.json`, and streams the resulting output back as ACP runtime events.
|
||||
|
||||
## Current behavior
|
||||
|
||||
- Supports ACP `mode: "oneshot"` only
|
||||
- Accepts any `agentId` and maps it to the queued MACP task `runtime`
|
||||
- Defaults queued tasks to `dispatch: "yolo"` and `runtime: "codex"` when no override is provided
|
||||
- Rejects persistent ACP sessions
|
||||
- Keeps `src/pi-bridge.ts` for future `dispatch: "pi"` support
|
||||
|
||||
## 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",
|
||||
"repoRoot": "~/src/mosaic-mono-v1",
|
||||
"orchDir": "~/src/mosaic-mono-v1/.mosaic/orchestrator",
|
||||
"defaultDispatch": "yolo",
|
||||
"defaultRuntime": "codex"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime flow
|
||||
|
||||
1. OpenClaw ensures a oneshot `macp` session and preserves the requested `agentId`.
|
||||
2. `runTurn` writes the turn prompt to `~/.mosaic/macp-oc/<session>-<request>.md`.
|
||||
3. The plugin appends a pending MACP task to the configured orchestrator queue.
|
||||
4. The plugin triggers `~/.config/mosaic/bin/mosaic-orchestrator-run --once` in the configured repo root.
|
||||
5. The plugin polls for `.mosaic/orchestrator/results/<task-id>.json` and streams the result back to OpenClaw.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
pnpm --filter @mosaicstack/oc-macp-plugin typecheck || npx tsc --noEmit -p plugins/macp/tsconfig.json
|
||||
pnpm prettier --write "plugins/macp/**/*.{ts,json,md}"
|
||||
pnpm format:check
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"id": "macp",
|
||||
"name": "MACP Runtime",
|
||||
"description": "Registers the macp ACP runtime backend and routes turns through the MACP controller queue.",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"defaultModel": {
|
||||
"type": "string",
|
||||
"description": "Default Pi model in provider/model format. Retained for Pi bridge compatibility. Defaults to openai/gpt-5-mini."
|
||||
},
|
||||
"systemPrompt": {
|
||||
"type": "string",
|
||||
"description": "Optional system prompt retained for Pi bridge compatibility."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"description": "Maximum turn runtime in milliseconds. Defaults to 300000."
|
||||
},
|
||||
"logDir": {
|
||||
"type": "string",
|
||||
"description": "Directory for plugin state/log files. Defaults to the plugin state dir."
|
||||
},
|
||||
"repoRoot": {
|
||||
"type": "string",
|
||||
"description": "Repository root containing .mosaic/orchestrator. Defaults to ~/src/mosaic-stack-new."
|
||||
},
|
||||
"orchDir": {
|
||||
"type": "string",
|
||||
"description": "Override for the orchestrator directory. Defaults to <repoRoot>/.mosaic/orchestrator."
|
||||
},
|
||||
"defaultDispatch": {
|
||||
"type": "string",
|
||||
"description": "Dispatch type written into queued MACP tasks. Defaults to yolo."
|
||||
},
|
||||
"defaultRuntime": {
|
||||
"type": "string",
|
||||
"description": "Fallback runtime when agentId is unavailable. Defaults to codex."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mosaicstack/oc-macp-plugin",
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "plugins/macp"
|
||||
},
|
||||
"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": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* ACP Runtime type definitions.
|
||||
*
|
||||
* These mirror the OpenClaw plugin SDK AcpRuntime types.
|
||||
* Defined locally so the plugin compiles without hardcoded SDK paths.
|
||||
* The OC plugin loader provides the actual SDK at runtime.
|
||||
*/
|
||||
|
||||
export interface AcpRuntimeCapabilities {
|
||||
controls: string[];
|
||||
}
|
||||
|
||||
export interface AcpRuntimeEnsureInput {
|
||||
sessionKey: string;
|
||||
agent: string;
|
||||
mode: 'oneshot' | 'session';
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface AcpRuntimeHandle {
|
||||
sessionKey: string;
|
||||
backend: string;
|
||||
runtimeSessionName: string;
|
||||
cwd: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
}
|
||||
|
||||
export interface AcpRuntimeEvent {
|
||||
type: 'text_delta' | 'status' | 'done' | 'error';
|
||||
text?: string;
|
||||
stream?: string;
|
||||
tag?: string;
|
||||
stopReason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface AcpRuntimeTurnInput {
|
||||
handle: AcpRuntimeHandle;
|
||||
text: string;
|
||||
requestId: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface AcpRuntimeStatus {
|
||||
summary: string;
|
||||
backendSessionId: string;
|
||||
agentSessionId: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AcpRuntimeDoctorReport {
|
||||
ok: boolean;
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: string[];
|
||||
installCommand?: string;
|
||||
}
|
||||
|
||||
export interface AcpRuntime {
|
||||
ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle>;
|
||||
runTurn(input: AcpRuntimeTurnInput): AsyncIterable<AcpRuntimeEvent>;
|
||||
getCapabilities(): AcpRuntimeCapabilities;
|
||||
getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus>;
|
||||
doctor(): Promise<AcpRuntimeDoctorReport>;
|
||||
cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise<void>;
|
||||
close(input: { handle: AcpRuntimeHandle; reason: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { MacpRuntime } from './macp-runtime.js';
|
||||
|
||||
// Resolve OC plugin SDK dynamically — works on any machine with openclaw installed globally
|
||||
const ocRequire = createRequire(import.meta.url);
|
||||
const sdkRoot = path.dirname(ocRequire.resolve('openclaw/dist/plugin-sdk/index.js'));
|
||||
|
||||
// Dynamic imports for runtime SDK functions
|
||||
const { registerAcpRuntimeBackend, unregisterAcpRuntimeBackend } = (await import(
|
||||
`${sdkRoot}/acp-runtime.js`
|
||||
)) as {
|
||||
registerAcpRuntimeBackend: (backend: {
|
||||
id: string;
|
||||
runtime: any;
|
||||
healthy: () => boolean;
|
||||
}) => void;
|
||||
unregisterAcpRuntimeBackend: (id: string) => void;
|
||||
};
|
||||
|
||||
type PluginConfig = {
|
||||
defaultModel?: string;
|
||||
systemPrompt?: string;
|
||||
timeoutMs?: number;
|
||||
logDir?: string;
|
||||
repoRoot?: string;
|
||||
orchDir?: string;
|
||||
defaultDispatch?: string;
|
||||
defaultRuntime?: string;
|
||||
};
|
||||
|
||||
function expandHome(rawPath: string): string {
|
||||
if (rawPath === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
if (rawPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), rawPath.slice(2));
|
||||
}
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
function resolveConfig(pluginConfig?: Record<string, unknown>, stateDir?: string) {
|
||||
const config = (pluginConfig ?? {}) as PluginConfig;
|
||||
const repoRoot = config.repoRoot?.trim()
|
||||
? path.resolve(expandHome(config.repoRoot))
|
||||
: path.resolve(os.homedir(), 'src', 'mosaic-stack');
|
||||
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(expandHome(config.logDir))
|
||||
: (stateDir ?? process.cwd()),
|
||||
repoRoot,
|
||||
orchDir: config.orchDir?.trim()
|
||||
? path.resolve(expandHome(config.orchDir))
|
||||
: path.join(repoRoot, '.mosaic', 'orchestrator'),
|
||||
defaultDispatch: config.defaultDispatch?.trim() || 'yolo',
|
||||
defaultRuntime: config.defaultRuntime?.trim() || 'codex',
|
||||
};
|
||||
}
|
||||
|
||||
function createMacpRuntimeService(pluginConfig?: Record<string, unknown>) {
|
||||
let runtime: MacpRuntime | null = null;
|
||||
|
||||
return {
|
||||
id: 'macp-runtime',
|
||||
async start(ctx: { stateDir: string; logger: { info: (msg: string) => void } }) {
|
||||
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 (defaultRuntime: ${resolved.defaultRuntime}, defaultDispatch: ${resolved.defaultDispatch}, timeoutMs: ${resolved.timeoutMs})`,
|
||||
);
|
||||
},
|
||||
async stop() {
|
||||
if (runtime) {
|
||||
unregisterAcpRuntimeBackend('macp');
|
||||
runtime = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function register(api: any) {
|
||||
const service = createMacpRuntimeService(api.pluginConfig);
|
||||
api.registerService(service);
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { access, mkdir, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import type {
|
||||
AcpRuntime,
|
||||
AcpRuntimeCapabilities,
|
||||
AcpRuntimeDoctorReport,
|
||||
AcpRuntimeEnsureInput,
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeStatus,
|
||||
AcpRuntimeTurnInput,
|
||||
} from './acp-runtime-types.js';
|
||||
|
||||
export interface MacpRuntimeConfig {
|
||||
defaultModel: string;
|
||||
systemPrompt: string;
|
||||
timeoutMs: number;
|
||||
stateDir: string;
|
||||
repoRoot?: string;
|
||||
orchDir?: string;
|
||||
defaultDispatch?: string;
|
||||
defaultRuntime?: string;
|
||||
logger?: {
|
||||
info?: (message: string) => void;
|
||||
warn?: (message: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
type HandleState = {
|
||||
name: string;
|
||||
agent: string;
|
||||
runtime: string;
|
||||
cwd: string;
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
type OrchestratorTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'pending';
|
||||
dispatch: string;
|
||||
runtime: string;
|
||||
worktree: string;
|
||||
brief_path: string;
|
||||
_brief_temp_path: string;
|
||||
timeout_seconds: number;
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type QueueFile = {
|
||||
tasks: OrchestratorTask[];
|
||||
};
|
||||
|
||||
type TaskGateResult = {
|
||||
command?: string;
|
||||
exit_code?: number;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type TaskResult = {
|
||||
task_id: string;
|
||||
status: string;
|
||||
summary?: string;
|
||||
error?: unknown;
|
||||
escalation_reason?: unknown;
|
||||
branch?: string | null;
|
||||
pr?: string | null;
|
||||
files_changed?: string[];
|
||||
gate_results?: TaskGateResult[];
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const MACP_CAPABILITIES: AcpRuntimeCapabilities = {
|
||||
controls: [],
|
||||
};
|
||||
|
||||
const DEFAULT_REPO_ROOT = '~/src/mosaic-stack';
|
||||
const ORCHESTRATOR_RUN_PATH = '~/.config/mosaic/bin/mosaic-orchestrator-run';
|
||||
const PI_RUNNER_PATH = path.join(
|
||||
os.homedir(),
|
||||
'src',
|
||||
'mosaic-stack',
|
||||
'tools',
|
||||
'macp',
|
||||
'dispatcher',
|
||||
'pi_runner.ts',
|
||||
);
|
||||
|
||||
function expandHome(rawPath: string): string {
|
||||
if (rawPath === '~') {
|
||||
return os.homedir();
|
||||
}
|
||||
if (rawPath.startsWith('~/')) {
|
||||
return path.join(os.homedir(), rawPath.slice(2));
|
||||
}
|
||||
return rawPath;
|
||||
}
|
||||
|
||||
function resolveRepoRoot(config: MacpRuntimeConfig): string {
|
||||
return path.resolve(expandHome(config.repoRoot?.trim() || DEFAULT_REPO_ROOT));
|
||||
}
|
||||
|
||||
function resolveOrchDir(config: MacpRuntimeConfig): string {
|
||||
if (config.orchDir?.trim()) {
|
||||
return path.resolve(expandHome(config.orchDir));
|
||||
}
|
||||
return path.join(resolveRepoRoot(config), '.mosaic', 'orchestrator');
|
||||
}
|
||||
|
||||
function resolveOrchestratorRunPath(): string {
|
||||
return path.resolve(expandHome(ORCHESTRATOR_RUN_PATH));
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
return (
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'task'
|
||||
);
|
||||
}
|
||||
|
||||
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.runtime !== '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}`;
|
||||
}
|
||||
|
||||
function createTaskId(sessionKey: string, requestId: string): string {
|
||||
return `${sanitizeSegment(sessionKey)}-${sanitizeSegment(requestId)}-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
function createTaskTitle(prompt: string): string {
|
||||
const firstLine = prompt
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.length > 0);
|
||||
return (firstLine || 'MACP OpenClaw task').slice(0, 120);
|
||||
}
|
||||
|
||||
function buildWorktreePath(repoRoot: string, taskId: string): string {
|
||||
const repoName = path.basename(repoRoot);
|
||||
return path.join(path.dirname(repoRoot), `${repoName}-worktrees`, `macp-oc-${taskId}`);
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function chunkText(text: string, chunkSize = 4000): string[] {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
for (let index = 0; index < normalized.length; index += chunkSize) {
|
||||
chunks.push(normalized.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function toMessage(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
return value.message;
|
||||
}
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error('MACP turn aborted.');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
async function waitFor(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
reject(abortError());
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function writeJsonAtomic(filePath: string, value: unknown): Promise<void> {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf-8');
|
||||
await rename(tempPath, filePath);
|
||||
}
|
||||
|
||||
async function loadQueue(tasksPath: string): Promise<QueueFile> {
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(tasksPath, 'utf-8')) as unknown;
|
||||
if (Array.isArray(raw)) {
|
||||
return { tasks: raw as OrchestratorTask[] };
|
||||
}
|
||||
if (isRecord(raw) && Array.isArray(raw.tasks)) {
|
||||
return { tasks: raw.tasks as OrchestratorTask[] };
|
||||
}
|
||||
throw new Error('tasks.json must contain a tasks array.');
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code === 'ENOENT') {
|
||||
return { tasks: [] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function withFileLock<T>(
|
||||
lockPath: string,
|
||||
timeoutMs: number,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + Math.max(5_000, Math.min(timeoutMs, 30_000));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const handle = await open(lockPath, 'wx');
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
await handle.close();
|
||||
await rm(lockPath, { force: true });
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for orchestrator queue lock: ${lockPath}`);
|
||||
}
|
||||
await waitFor(200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function appendTaskToQueue(
|
||||
task: OrchestratorTask,
|
||||
orchDir: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const tasksPath = path.join(orchDir, 'tasks.json');
|
||||
const lockPath = `${tasksPath}.lock`;
|
||||
await mkdir(orchDir, { recursive: true });
|
||||
await withFileLock(lockPath, timeoutMs, async () => {
|
||||
const queue = await loadQueue(tasksPath);
|
||||
queue.tasks.push(task);
|
||||
await writeJsonAtomic(tasksPath, queue);
|
||||
});
|
||||
}
|
||||
|
||||
async function readOrchestratorConfig(orchDir: string): Promise<Record<string, unknown>> {
|
||||
const configPath = path.join(orchDir, 'config.json');
|
||||
const config = JSON.parse(await readFile(configPath, 'utf-8')) as unknown;
|
||||
if (!isRecord(config)) {
|
||||
throw new Error(`Invalid orchestrator config: ${configPath}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
async function ensureOrchestratorReady(orchDir: string): Promise<void> {
|
||||
const config = await readOrchestratorConfig(orchDir);
|
||||
if (config.enabled !== true) {
|
||||
throw new Error(`MACP orchestrator is disabled in ${path.join(orchDir, 'config.json')}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function triggerController(repoRoot: string): void {
|
||||
const child = spawn(
|
||||
'bash',
|
||||
['-lc', `cd ${shellQuote(repoRoot)} && ${shellQuote(resolveOrchestratorRunPath())} --once`],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function pollForResult(
|
||||
resultPath: string,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TaskResult> {
|
||||
const deadline = Date.now() + Math.max(timeoutMs, 2_000);
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
if (signal?.aborted) {
|
||||
throw abortError();
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(await readFile(resultPath, 'utf-8')) as unknown;
|
||||
if (!isRecord(raw) || typeof raw.task_id !== 'string' || typeof raw.status !== 'string') {
|
||||
throw new Error(`Invalid MACP result payload: ${resultPath}`);
|
||||
}
|
||||
return raw as TaskResult;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if (code !== 'ENOENT' && !(error instanceof SyntaxError)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
await waitFor(2_000, signal);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for MACP result: ${resultPath}`);
|
||||
}
|
||||
|
||||
async function resolveResultOutput(result: TaskResult, orchDir: string): Promise<string> {
|
||||
const metadata = isRecord(result.metadata) ? result.metadata : {};
|
||||
const outputCandidates = [metadata.result_output_path, metadata.output_path]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => (path.isAbsolute(value) ? value : path.resolve(orchDir, value)));
|
||||
|
||||
for (const candidate of outputCandidates) {
|
||||
try {
|
||||
return (await readFile(candidate, 'utf-8')).trim();
|
||||
} catch {
|
||||
// Fall back to formatted result details below.
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (result.summary) {
|
||||
lines.push(result.summary);
|
||||
}
|
||||
if (result.error) {
|
||||
lines.push(`Error: ${toMessage(result.error)}`);
|
||||
}
|
||||
if (result.escalation_reason) {
|
||||
lines.push(`Escalation: ${toMessage(result.escalation_reason)}`);
|
||||
}
|
||||
if (result.branch) {
|
||||
lines.push(`Branch: ${result.branch}`);
|
||||
}
|
||||
if (result.pr) {
|
||||
lines.push(`PR: ${result.pr}`);
|
||||
}
|
||||
if (Array.isArray(result.files_changed) && result.files_changed.length > 0) {
|
||||
lines.push(`Files changed:\n${result.files_changed.map((file) => `- ${file}`).join('\n')}`);
|
||||
}
|
||||
if (Array.isArray(result.gate_results) && result.gate_results.length > 0) {
|
||||
lines.push(
|
||||
`Quality gates:\n${result.gate_results
|
||||
.map((gate) => `- [${gate.exit_code ?? 0}] ${gate.command ?? 'unknown command'}`)
|
||||
.join('\n')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n\n').trim() || JSON.stringify(result, null, 2);
|
||||
}
|
||||
|
||||
export class MacpRuntime implements AcpRuntime {
|
||||
constructor(private readonly config: MacpRuntimeConfig) {}
|
||||
|
||||
async ensureSession(input: AcpRuntimeEnsureInput): Promise<AcpRuntimeHandle> {
|
||||
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,
|
||||
runtime: input.agent || this.config.defaultRuntime || 'codex',
|
||||
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 repoRoot = resolveRepoRoot(this.config);
|
||||
const orchDir = resolveOrchDir(this.config);
|
||||
const taskId = createTaskId(input.handle.sessionKey, input.requestId);
|
||||
const briefDir = path.join(os.homedir(), '.mosaic', 'macp-oc');
|
||||
const briefPath = path.join(briefDir, `${state.name}-${input.requestId}.md`);
|
||||
const resultPath = path.join(orchDir, 'results', `${taskId}.json`);
|
||||
|
||||
try {
|
||||
await access(resolveOrchestratorRunPath());
|
||||
await ensureOrchestratorReady(orchDir);
|
||||
await mkdir(briefDir, { recursive: true });
|
||||
await mkdir(path.dirname(resultPath), { recursive: true });
|
||||
await writeFile(briefPath, `${input.text.trimEnd()}\n`, 'utf-8');
|
||||
|
||||
const task: OrchestratorTask = {
|
||||
id: taskId,
|
||||
title: createTaskTitle(input.text),
|
||||
status: 'pending',
|
||||
dispatch: this.config.defaultDispatch || 'yolo',
|
||||
runtime: state.runtime || this.config.defaultRuntime || 'codex',
|
||||
worktree: buildWorktreePath(repoRoot, taskId),
|
||||
brief_path: briefPath,
|
||||
_brief_temp_path: briefPath,
|
||||
timeout_seconds: Math.max(1, Math.ceil(state.timeoutMs / 1_000)),
|
||||
metadata: {
|
||||
source: 'openclaw-macp-plugin',
|
||||
created_at: nowIso(),
|
||||
session_key: input.handle.sessionKey,
|
||||
request_id: input.requestId,
|
||||
agent_id: state.agent,
|
||||
cwd: state.cwd,
|
||||
},
|
||||
};
|
||||
|
||||
this.config.logger?.info?.(
|
||||
`Queueing MACP orchestrator task ${taskId} (${task.runtime}/${task.dispatch}).`,
|
||||
);
|
||||
yield {
|
||||
type: 'status',
|
||||
text: `Queued MACP task ${taskId}.`,
|
||||
tag: 'session_info_update',
|
||||
};
|
||||
|
||||
await appendTaskToQueue(task, orchDir, state.timeoutMs);
|
||||
triggerController(repoRoot);
|
||||
|
||||
const result = await pollForResult(resultPath, state.timeoutMs, input.signal);
|
||||
const output = await resolveResultOutput(result, orchDir);
|
||||
for (const chunk of chunkText(output)) {
|
||||
yield {
|
||||
type: 'text_delta',
|
||||
text: chunk,
|
||||
stream: 'output',
|
||||
tag: 'agent_message_chunk',
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: 'done',
|
||||
stopReason: result.status,
|
||||
};
|
||||
} catch (error) {
|
||||
yield {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await rm(briefPath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
getCapabilities(): AcpRuntimeCapabilities {
|
||||
return MACP_CAPABILITIES;
|
||||
}
|
||||
|
||||
async getStatus(input: { handle: AcpRuntimeHandle }): Promise<AcpRuntimeStatus> {
|
||||
const state = decodeHandleState(input.handle);
|
||||
return {
|
||||
summary: 'macp controller oneshot runtime ready',
|
||||
backendSessionId: state.name,
|
||||
agentSessionId: state.name,
|
||||
details: {
|
||||
mode: 'oneshot',
|
||||
agent: state.agent,
|
||||
runtime: state.runtime,
|
||||
cwd: state.cwd,
|
||||
repoRoot: resolveRepoRoot(this.config),
|
||||
orchDir: resolveOrchDir(this.config),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async doctor(): Promise<AcpRuntimeDoctorReport> {
|
||||
try {
|
||||
const repoRoot = resolveRepoRoot(this.config);
|
||||
const orchDir = resolveOrchDir(this.config);
|
||||
const orchestratorRunPath = resolveOrchestratorRunPath();
|
||||
await access(orchestratorRunPath);
|
||||
await access(repoRoot);
|
||||
await access(orchDir);
|
||||
await access(PI_RUNNER_PATH).catch(() => undefined);
|
||||
const orchestratorConfig = await readOrchestratorConfig(orchDir);
|
||||
if (orchestratorConfig.enabled !== true) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MACP_ORCH_DISABLED',
|
||||
message: 'MACP orchestrator is disabled for the configured repo.',
|
||||
details: [path.join(orchDir, 'config.json')],
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: 'MACP runtime is ready.',
|
||||
details: [orchestratorRunPath, repoRoot, orchDir],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'MACP_ORCH_MISSING',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
installCommand: 'pnpm install --frozen-lockfile',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user