mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-29 00:42:30 +00:00
109 lines
8.8 KiB
JavaScript
Executable File
109 lines
8.8 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// fleet-mcp — stdio MCP server for agent-side fleet operations.
|
|
// Speaks newline-delimited JSON-RPC. Logs go to stderr. Tools call bin/core/ops.mjs,
|
|
// the same functions as the backlog, mailbox, and landq CLIs.
|
|
import readline from 'node:readline';
|
|
import { FleetError, loadConfig, throwFleetErrors, runningAsMain } from './lib.mjs';
|
|
import {
|
|
listBacklog, claimBacklog, updateBacklog, readMailbox, sendMailbox,
|
|
submitLanding, landingStatus, submissionStatus, areaInfo, commitWork,
|
|
} from './core/ops.mjs';
|
|
|
|
export const SERVER = { name: 'agent-fleet', version: '0.2.0' };
|
|
const INSTRUCTIONS = [
|
|
'Agent-fleet operations. These tools are conveniences. Editing the backlog JSON, mailbox files, and other fleet files directly is fine. Do not hand-edit the landing queue while a drain is merging.',
|
|
'area_info names the owner of a path. backlog_claim marks one item in-progress; pass its id. Idle, when you pass no id, means you have not chosen one yet.',
|
|
'mailbox_read at the start of a turn. Mail is always delivered; the prompt says when to act on it. Sandboxed agents commit with the commit tool (message plus paths, or all), not git commit. submit on that tool also queues the tip. Then go back to work. At your next natural checkpoint, land_status can report that submission. Waiting is optional. Never infer the lander or the land result from silence. landq_submit queues the committed tip (a dirty worktree warns and still queues HEAD) and starts a drain. Do not push main.',
|
|
'A scarce machine is a prompt convention: say so in .task and wait if a peer already has it. There is no slot lock.',
|
|
'Sessions, worktrees, and cron stay on the fleet CLI. This server does not start them.',
|
|
].join(' ');
|
|
|
|
const cwdProp = { type: 'string', description: 'Directory to resolve the fleet from. Defaults to this server\'s cwd.' };
|
|
|
|
export const TOOLS = [
|
|
{ name: 'area_info', description: 'List fleet areas, or one area: id, provider, owned paths, branch, worktree.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, cwd: cwdProp } } },
|
|
{ name: 'backlog_list', description: 'List backlog items. Done items are omitted unless status is set.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, status: { type: 'string' }, cwd: cwdProp } } },
|
|
{ name: 'backlog_claim', description: 'Atomically mark a named backlog item in-progress. Pass id. Without id, returns idle and does not pick one for you. Does not refuse blocked, done, or another area.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, id: { type: 'string' }, cwd: cwdProp }, required: ['area'] } },
|
|
{ name: 'backlog_update', description: 'Set fields on a backlog item. status must be open, in-progress, blocked, done, or deferred.', inputSchema: { type: 'object', properties: { id: { type: 'string' }, fields: { type: 'object' }, cwd: cwdProp }, required: ['id', 'fields'] } },
|
|
{ name: 'mailbox_read', description: 'Read unread mailbox messages for an area, oldest first.', inputSchema: { type: 'object', properties: { area: { type: 'string' }, cwd: cwdProp }, required: ['area'] } },
|
|
{ name: 'mailbox_send', description: 'Send a mailbox message to an area id or "all". Always delivers. The prompt says when to act; urgent does not change delivery.', inputSchema: { type: 'object', properties: { to: { type: 'string' }, from: { type: 'string' }, body: { type: 'string' }, subject: { type: 'string' }, urgent: { type: 'boolean' }, cwd: cwdProp }, required: ['to', 'from', 'body'] } },
|
|
{ name: 'commit', description: 'Stage and commit in the calling area worktree, in this server (outside an agent sandbox). Refuses the main checkout and paths outside the worktree. doesNotOwn paths warn. submit also queues the tip.', inputSchema: { type: 'object', properties: { message: { type: 'string' }, paths: { type: 'array', items: { type: 'string' }, description: 'Files to stage, relative to the worktree. Name them. Omit only when all is true.' }, all: { type: 'boolean', description: 'Stage every change in the worktree.' }, area: { type: 'string', description: 'Area id. Inferred when cwd is that area\'s worktree.' }, submit: { type: 'boolean', description: 'After the commit, queue the tip the way landq_submit does.' }, cwd: cwdProp }, required: ['message'] } },
|
|
{ name: 'landq_submit', description: 'Queue the committed branch tip and start a drain. A dirty worktree warns; only HEAD is queued. Refuses main. Returns the submission id.', inputSchema: { type: 'object', properties: { cwd: cwdProp } } },
|
|
{ name: 'land_status', description: 'Quick lookup of one submission: queued, gating, landed (landedSha), rejected (reason), or unknown. Says whether a drain is alive and when it last ticked. If it is dead, the hint is the command that starts it. wait is optional and polls until landed, rejected, or timeoutSec (default 120, max 600).', inputSchema: { type: 'object', properties: { id: { type: 'string', description: 'Submission id: the commit sha, or a unique prefix of at least 4 hex characters.' }, wait: { type: 'boolean' }, timeoutSec: { type: 'number', description: 'Seconds to poll when wait is true. Default 120. Capped at 600.' }, cwd: cwdProp }, required: ['id'] } },
|
|
{ name: 'landq_status', description: 'Landing queue overview, or one submission by sha prefix (same durable state as land_status, including the optional wait).', inputSchema: { type: 'object', properties: { sha: { type: 'string' }, id: { type: 'string' }, wait: { type: 'boolean' }, timeoutSec: { type: 'number' }, cwd: cwdProp } } },
|
|
];
|
|
|
|
function configFor(args) {
|
|
const cwd = args.cwd || process.cwd();
|
|
return { c: loadConfig(process.argv, cwd), cwd };
|
|
}
|
|
|
|
export function dispatchTool(name, args = {}) {
|
|
const { c, cwd } = configFor(args);
|
|
switch (name) {
|
|
case 'area_info': return areaInfo(c, args.id);
|
|
case 'backlog_list': return listBacklog(c, { area: args.area, status: args.status, cwd });
|
|
case 'backlog_claim': return claimBacklog(c, { area: args.area, id: args.id, cwd });
|
|
case 'backlog_update': return updateBacklog(c, args.id, args.fields ?? {}, cwd);
|
|
case 'mailbox_read': return readMailbox(c, args.area);
|
|
case 'mailbox_send': return sendMailbox(c, { to: args.to, from: args.from, body: args.body, subject: args.subject ?? '', urgent: Boolean(args.urgent), cwd });
|
|
case 'commit': return commitWork(c, { cwd, area: args.area, message: args.message, paths: args.paths, all: Boolean(args.all), submit: Boolean(args.submit) });
|
|
case 'landq_submit': return submitLanding(c, cwd);
|
|
case 'land_status': return submissionStatus(c, args.id, { wait: Boolean(args.wait), timeoutSec: args.timeoutSec });
|
|
case 'landq_status': return (args.sha || args.id)
|
|
? submissionStatus(c, args.sha || args.id, { wait: Boolean(args.wait), timeoutSec: args.timeoutSec })
|
|
: landingStatus(c);
|
|
default: throw new FleetError(`unknown tool ${name}`);
|
|
}
|
|
}
|
|
|
|
function toolResult(payload, isError = false) {
|
|
return { content: [{ type: 'text', text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) }], ...(isError ? { isError: true } : {}) };
|
|
}
|
|
|
|
export async function handleMessage(msg) {
|
|
if (!msg || msg.jsonrpc !== '2.0' || msg.method == null) return null;
|
|
if (msg.id == null) return null;
|
|
const reply = (result) => ({ jsonrpc: '2.0', id: msg.id, result });
|
|
const fail = (code, message) => ({ jsonrpc: '2.0', id: msg.id, error: { code, message } });
|
|
if (msg.method === 'initialize') {
|
|
const requested = msg.params?.protocolVersion;
|
|
return reply({
|
|
protocolVersion: typeof requested === 'string' && requested ? requested : '2024-11-05',
|
|
capabilities: { tools: { listChanged: false } },
|
|
serverInfo: SERVER,
|
|
instructions: INSTRUCTIONS,
|
|
});
|
|
}
|
|
if (msg.method === 'ping') return reply({});
|
|
if (msg.method === 'tools/list') return reply({ tools: TOOLS });
|
|
if (msg.method === 'tools/call') {
|
|
const name = msg.params?.name;
|
|
const args = msg.params?.arguments ?? {};
|
|
try {
|
|
throwFleetErrors(true);
|
|
return reply(toolResult(await dispatchTool(name, args)));
|
|
} catch (e) {
|
|
return reply(toolResult(e instanceof Error ? e.message : String(e), true));
|
|
}
|
|
}
|
|
return fail(-32601, `method not found: ${msg.method}`);
|
|
}
|
|
|
|
function serve() {
|
|
throwFleetErrors(true);
|
|
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity, terminal: false });
|
|
const send = (msg) => { if (msg) process.stdout.write(`${JSON.stringify(msg)}\n`); };
|
|
rl.on('line', (line) => {
|
|
const text = line.trim();
|
|
if (!text) return;
|
|
let msg;
|
|
try { msg = JSON.parse(text); } catch { send({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }); return; }
|
|
Promise.resolve(handleMessage(msg)).then(send).catch((e) => {
|
|
send({ jsonrpc: '2.0', id: msg?.id ?? null, error: { code: -32603, message: e instanceof Error ? e.message : String(e) } });
|
|
});
|
|
});
|
|
}
|
|
|
|
if (runningAsMain(import.meta.url)) serve();
|