Wrap the live coordinator in project Cursor capabilities so agents default to leases, docs apply, and daemon enqueue. Track skills/hooks/mcp via .gitignore exceptions; guard destructive git with ask permission.
311 lines
10 KiB
JavaScript
Executable File
311 lines
10 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
/**
|
|
* agentctl MCP (stdio, zero deps). Thin argv wrappers around scripts/agentctl.py.
|
|
* lease_run passes command as argv after `--` — no extra shell.
|
|
*/
|
|
import { spawnSync } from 'node:child_process';
|
|
import { createInterface } from 'node:readline';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO = process.env.AGENTCTL_REPO
|
|
? path.resolve(process.env.AGENTCTL_REPO)
|
|
: path.resolve(__dirname, '../..');
|
|
const AGENTCTL = path.join(REPO, 'scripts', 'agentctl.py');
|
|
|
|
function runAgentctl(args, { cwd = REPO, timeout = 120000 } = {}) {
|
|
if (!fs.existsSync(AGENTCTL)) {
|
|
return { ok: false, code: 127, stdout: '', stderr: `agentctl missing: ${AGENTCTL}` };
|
|
}
|
|
const r = spawnSync('python3', ['-B', AGENTCTL, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
timeout,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
env: process.env,
|
|
});
|
|
return {
|
|
ok: r.status === 0,
|
|
code: r.status ?? 1,
|
|
stdout: r.stdout || '',
|
|
stderr: r.stderr || '',
|
|
error: r.error ? String(r.error) : undefined,
|
|
};
|
|
}
|
|
|
|
function textResult(payload) {
|
|
const body = typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2);
|
|
return { content: [{ type: 'text', text: body }] };
|
|
}
|
|
|
|
function errResult(msg) {
|
|
return { content: [{ type: 'text', text: msg }], isError: true };
|
|
}
|
|
|
|
const TOOLS = [
|
|
{
|
|
name: 'status',
|
|
description: 'agentctl status + daemon status for session start checks',
|
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
},
|
|
{
|
|
name: 'task_register',
|
|
description: 'Register a task with scopes and optional capabilities',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
owner: { type: 'string' },
|
|
scopes: { type: 'array', items: { type: 'string' } },
|
|
capabilities: { type: 'array', items: { type: 'string' } },
|
|
},
|
|
required: ['id', 'owner', 'scopes'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'task_close',
|
|
description: 'Close a task as done or cancelled',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'string' },
|
|
status: { type: 'string', enum: ['done', 'cancelled'] },
|
|
},
|
|
required: ['id'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'lease_run',
|
|
description: 'Run argv under agentctl with lease (resources). Command is argv array, no shell.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
task: { type: 'string' },
|
|
resources: { type: 'array', items: { type: 'string' } },
|
|
command: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
cwd: { type: 'string', description: 'optional worktree absolute path' },
|
|
},
|
|
required: ['task', 'resources', 'command'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'docs_snapshot',
|
|
description: 'Snapshot a shared doc row for CAS apply',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
file: { type: 'string' },
|
|
record: { type: 'string' },
|
|
},
|
|
required: ['file', 'record'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'docs_apply',
|
|
description: 'Apply a docs request JSON file under a task',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
task: { type: 'string' },
|
|
request: { type: 'string', description: 'path to request JSON' },
|
|
},
|
|
required: ['task', 'request'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'validate',
|
|
description: 'Validate candidate HEAD with an explicit command argv',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
task: { type: 'string' },
|
|
command: { type: 'array', items: { type: 'string' }, minItems: 1 },
|
|
cwd: { type: 'string' },
|
|
},
|
|
required: ['task', 'command'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'queue_enqueue',
|
|
description: 'Enqueue a validated candidate for daemon ff-only integrate',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
task: { type: 'string' },
|
|
expected_dev: { type: 'string' },
|
|
head: { type: 'string' },
|
|
priority: { type: 'number' },
|
|
},
|
|
required: ['task', 'expected_dev'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'quarantine_status',
|
|
description: 'List quarantine / suspect runs',
|
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
},
|
|
];
|
|
|
|
function callTool(name, args = {}) {
|
|
switch (name) {
|
|
case 'status': {
|
|
const a = runAgentctl(['status']);
|
|
const b = runAgentctl(['daemon', 'status']);
|
|
return textResult({
|
|
repo: REPO,
|
|
status: { ok: a.ok, code: a.code, stdout: a.stdout.trim(), stderr: a.stderr.trim() },
|
|
daemon: { ok: b.ok, code: b.code, stdout: b.stdout.trim(), stderr: b.stderr.trim() },
|
|
});
|
|
}
|
|
case 'task_register': {
|
|
const argv = ['task', 'register', '--id', args.id, '--owner', args.owner];
|
|
for (const s of args.scopes || []) argv.push('--scope', s);
|
|
for (const c of args.capabilities || []) argv.push('--capability', c);
|
|
const r = runAgentctl(argv);
|
|
return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
case 'task_close': {
|
|
const argv = ['task', 'close', '--id', args.id, '--status', args.status || 'done'];
|
|
const r = runAgentctl(argv);
|
|
return r.ok ? textResult(r.stdout.trim() || r.stderr.trim()) : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
case 'lease_run': {
|
|
if (!Array.isArray(args.command) || args.command.length < 1) {
|
|
return errResult('lease_run.command must be a non-empty argv array');
|
|
}
|
|
const argv = ['with', '--task', args.task];
|
|
for (const res of args.resources || []) argv.push('--resource', res);
|
|
argv.push('--', ...args.command);
|
|
const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 });
|
|
const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
|
|
return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`);
|
|
}
|
|
case 'docs_snapshot': {
|
|
const r = runAgentctl(['docs', 'snapshot', '--file', args.file, '--record', args.record]);
|
|
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
case 'docs_apply': {
|
|
const r = runAgentctl(['docs', 'apply', '--task', args.task, '--request', args.request]);
|
|
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
case 'validate': {
|
|
if (!Array.isArray(args.command) || args.command.length < 1) {
|
|
return errResult('validate.command must be a non-empty argv array');
|
|
}
|
|
const argv = ['validate', '--task', args.task, '--', ...args.command];
|
|
const r = runAgentctl(argv, { cwd: args.cwd || REPO, timeout: 600000 });
|
|
const body = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
|
|
return r.ok ? textResult(body) : errResult(body || r.error || `exit ${r.code}`);
|
|
}
|
|
case 'queue_enqueue': {
|
|
const argv = ['queue', 'enqueue', '--task', args.task, '--expected-dev', args.expected_dev];
|
|
if (args.head) argv.push('--head', args.head);
|
|
if (args.priority != null) argv.push('--priority', String(args.priority));
|
|
const r = runAgentctl(argv, { cwd: args.cwd || REPO });
|
|
return r.ok ? textResult(r.stdout.trim()) : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
case 'quarantine_status': {
|
|
const r = runAgentctl(['quarantine', 'list']);
|
|
return r.ok ? textResult(r.stdout.trim() || '{"items":[]}') : errResult(r.stderr || r.stdout || r.error);
|
|
}
|
|
default:
|
|
return errResult(`unknown tool: ${name}`);
|
|
}
|
|
}
|
|
|
|
function send(msg) {
|
|
const body = JSON.stringify(msg);
|
|
process.stdout.write(`Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n${body}`);
|
|
}
|
|
|
|
function handle(msg) {
|
|
if (!msg || typeof msg !== 'object') return;
|
|
if (msg.method && msg.id === undefined) {
|
|
// notification — ignore
|
|
return;
|
|
}
|
|
const { id, method, params } = msg;
|
|
try {
|
|
if (method === 'initialize') {
|
|
send({
|
|
jsonrpc: '2.0',
|
|
id,
|
|
result: {
|
|
protocolVersion: params?.protocolVersion || '2024-11-05',
|
|
capabilities: { tools: {} },
|
|
serverInfo: { name: 'agentctl', version: '1.0.0' },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
if (method === 'tools/list') {
|
|
send({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
|
|
return;
|
|
}
|
|
if (method === 'tools/call') {
|
|
const name = params?.name;
|
|
const args = params?.arguments || {};
|
|
const result = callTool(name, args);
|
|
send({ jsonrpc: '2.0', id, result });
|
|
return;
|
|
}
|
|
if (method === 'ping') {
|
|
send({ jsonrpc: '2.0', id, result: {} });
|
|
return;
|
|
}
|
|
send({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } });
|
|
} catch (e) {
|
|
send({ jsonrpc: '2.0', id, error: { code: -32000, message: String(e?.stack || e) } });
|
|
}
|
|
}
|
|
|
|
// Support both Content-Length framed MCP and newline-delimited JSON (for tests).
|
|
let buf = Buffer.alloc(0);
|
|
process.stdin.on('data', (chunk) => {
|
|
buf = Buffer.concat([buf, chunk]);
|
|
while (true) {
|
|
const headerEnd = buf.indexOf('\r\n\r\n');
|
|
if (headerEnd !== -1) {
|
|
const header = buf.slice(0, headerEnd).toString('utf8');
|
|
const m = /Content-Length:\s*(\d+)/i.exec(header);
|
|
if (!m) {
|
|
buf = buf.slice(headerEnd + 4);
|
|
continue;
|
|
}
|
|
const len = Number(m[1]);
|
|
const start = headerEnd + 4;
|
|
if (buf.length < start + len) return;
|
|
const body = buf.slice(start, start + len).toString('utf8');
|
|
buf = buf.slice(start + len);
|
|
try {
|
|
handle(JSON.parse(body));
|
|
} catch (e) {
|
|
process.stderr.write(`parse error: ${e}\n`);
|
|
}
|
|
continue;
|
|
}
|
|
// newline-delimited fallback for harness
|
|
const nl = buf.indexOf('\n');
|
|
if (nl === -1) return;
|
|
const line = buf.slice(0, nl).toString('utf8').trim();
|
|
buf = buf.slice(nl + 1);
|
|
if (!line || line.startsWith('Content-Length')) continue;
|
|
try {
|
|
handle(JSON.parse(line));
|
|
} catch (e) {
|
|
process.stderr.write(`ndjson parse error: ${e}\n`);
|
|
}
|
|
}
|
|
});
|
|
|
|
process.stdin.on('end', () => process.exit(0));
|