Files
makelore/electron/coding-runtime/pi/extensions/makelore-runtime.ts

190 lines
6.6 KiB
TypeScript

import path from 'node:path';
import { atomicWriteText } from '../../../coding-projects/atomic-json';
export const MAKELORE_PI_EXTENSION_VERSION = 2;
export const MAKELORE_PI_EXTENSION_FILENAME = `makelore-runtime-v${MAKELORE_PI_EXTENSION_VERSION}.mjs`;
const BUNDLE_SOURCE = String.raw`
import { readFile } from 'node:fs/promises';
const MUTATION_TOOLS = new Set(['bash', 'edit', 'write']);
const WORKER_ROLE = process.env.MAKELORE_PI_WORKER_ROLE || 'parent';
const leases = new Map();
async function runtimeContext() {
const value = JSON.parse(await readFile(process.env.MAKELORE_PI_CONTEXT_FILE, 'utf8'));
if (!value.runId) throw new Error('Makelore run context is unavailable');
return value;
}
async function bridge(action, body, signal) {
const context = await runtimeContext();
const response = await fetch(process.env.MAKELORE_PI_BRIDGE_URL, {
method: 'POST',
headers: {
authorization: 'Bearer ' + process.env.MAKELORE_PI_WORKER_TOKEN,
'content-type': 'application/json',
},
body: JSON.stringify({ ...context, action, ...body }),
signal,
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || 'Makelore runtime bridge rejected the request');
return result;
}
async function bridgeStream(action, body, signal, onUpdate) {
const context = await runtimeContext();
const response = await fetch(process.env.MAKELORE_PI_BRIDGE_URL, {
method: 'POST',
headers: {
authorization: 'Bearer ' + process.env.MAKELORE_PI_WORKER_TOKEN,
'content-type': 'application/json',
},
body: JSON.stringify({ ...context, action, ...body }),
signal,
});
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(result.error || 'Makelore runtime bridge rejected the request');
}
if (!response.body) throw new Error('Makelore runtime bridge returned no stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffered = '';
let details;
while (true) {
const { value, done } = await reader.read();
buffered += decoder.decode(value || new Uint8Array(), { stream: !done });
let newline = buffered.indexOf('\n');
while (newline >= 0) {
const line = buffered.slice(0, newline);
buffered = buffered.slice(newline + 1);
if (line) {
const item = JSON.parse(line);
if (item.details?.schema === 'subagent.v1') {
details = item.details;
onUpdate?.({ content: [], details });
}
}
newline = buffered.indexOf('\n');
}
if (done) break;
}
if (!details) throw new Error('Makelore subagent stream returned no details');
return details;
}
async function releaseLease(toolCallId) {
const leaseId = leases.get(toolCallId);
if (!leaseId) return;
leases.delete(toolCallId);
await bridge('lease.release', { leaseId, resourceId: toolCallId }).catch(() => undefined);
}
async function releaseAll() {
await Promise.all([...leases.keys()].map(releaseLease));
}
export default function makeloreRuntime(pi) {
if (WORKER_ROLE === 'parent') pi.registerTool({
name: 'ask_user',
label: 'Ask user',
description: 'Ask the user for a selection, confirmation, short input, or editor text.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['kind', 'title'],
properties: {
kind: { type: 'string', enum: ['select', 'confirm', 'input', 'editor'] },
title: { type: 'string' },
message: { type: 'string' },
options: { type: 'array', items: { type: 'string' } },
},
},
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
let value;
if (params.kind === 'select') {
value = await ctx.ui.select(params.title, Array.isArray(params.options) ? params.options : [], { signal });
} else if (params.kind === 'confirm') {
value = await ctx.ui.confirm(params.title, params.message || '', { signal });
} else if (params.kind === 'editor') {
value = await ctx.ui.editor(params.title, params.message || '', { signal });
} else {
value = await ctx.ui.input(params.title, params.message || '', { signal });
}
return {
content: [{ type: 'text', text: value === undefined ? 'User cancelled' : String(value) }],
details: { kind: params.kind, cancelled: value === undefined },
};
},
});
if (WORKER_ROLE === 'parent') pi.registerTool({
name: 'subagent',
label: 'Subagent',
description: 'Dispatch one or more managed project Agents in single, parallel, or chain mode.',
parameters: {
type: 'object',
additionalProperties: false,
required: ['mode', 'tasks'],
properties: {
mode: { type: 'string', enum: ['single', 'parallel', 'chain'] },
tasks: {
type: 'array', minItems: 1, maxItems: 8,
items: {
type: 'object',
additionalProperties: false,
required: ['agentId', 'task', 'toolProfile'],
properties: {
agentId: { type: 'string' },
task: { type: 'string' },
toolProfile: { type: 'string', enum: ['read-only', 'coding'] },
},
},
},
},
},
async execute(toolCallId, params, signal, onUpdate) {
const details = await bridgeStream(
'subagent.dispatch',
{ resourceId: toolCallId, request: params },
signal,
onUpdate,
);
const complete = details.tasks.filter((task) => task.status === 'complete').length;
const failed = details.tasks.length - complete;
return {
content: [{
type: 'text',
text: failed === 0
? complete + ' subagent task(s) completed'
: complete + ' completed; ' + failed + ' did not complete',
}],
details,
};
},
});
pi.on('tool_call', async (event, ctx) => {
if (!MUTATION_TOOLS.has(event.toolName)) return;
ctx.ui.setStatus('makelore.write-lease', '等待项目写入');
try {
const result = await bridge('lease.acquire', { resourceId: event.toolCallId }, ctx.signal);
leases.set(event.toolCallId, result.leaseId);
} finally {
ctx.ui.setStatus('makelore.write-lease', undefined);
}
});
pi.on('tool_result', async (event) => releaseLease(event.toolCallId));
pi.on('agent_end', releaseAll);
pi.on('session_shutdown', releaseAll);
}
`;
export async function materializeMakelorePiExtension(extensionsDir: string): Promise<string> {
const extensionPath = path.join(extensionsDir, MAKELORE_PI_EXTENSION_FILENAME);
await atomicWriteText(extensionPath, BUNDLE_SOURCE.trimStart());
return extensionPath;
}