587 lines
20 KiB
JavaScript
587 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* pi-subagents MCP (stdio, zero deps).
|
|
* Tools: pi_subagent_roles, pi_subagent_run, pi_subagent_result.
|
|
*
|
|
* - Trusted executor: INSTALL_ROOT/scripts/pi_subagents.py (this server install).
|
|
* - Authoritative agentctl: discovered main worktree scripts/agentctl.py (governance SSOT).
|
|
* - worktree must share git-common-dir with INSTALL_ROOT.
|
|
* - Request JSON only under private OS tmp (no pre-lease repo writes).
|
|
*/
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import crypto from 'node:crypto';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const INSTALL_ROOT = path.resolve(__dirname, '../..');
|
|
const MAX_PAYLOAD = 2 * 1024 * 1024;
|
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
|
|
const active = new Map(); // key -> { child, digest, stdout, stderr, exitCode, rejection }
|
|
|
|
function gitCommonDir(cwd) {
|
|
const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'], {
|
|
encoding: 'utf8',
|
|
});
|
|
if (r.status !== 0) return null;
|
|
return r.stdout.trim();
|
|
}
|
|
|
|
function gitMainWorktree(cwd) {
|
|
const list = spawnSync('git', ['-C', cwd, 'worktree', 'list', '--porcelain'], { encoding: 'utf8' });
|
|
if (list.status !== 0) return null;
|
|
const first = /^worktree (.+)$/m.exec(list.stdout || '');
|
|
return first ? first[1] : null;
|
|
}
|
|
|
|
function validateId(value, label) {
|
|
if (typeof value !== 'string' || !ID_RE.test(value) || value === '.' || value === '..') {
|
|
throw new Error(`invalid ${label}`);
|
|
}
|
|
}
|
|
|
|
function resolveRepo(worktree) {
|
|
if (!worktree || typeof worktree !== 'string') {
|
|
throw new Error('worktree is required');
|
|
}
|
|
const wt = path.resolve(worktree);
|
|
const common = gitCommonDir(wt);
|
|
if (!common) throw new Error(`not a git worktree: ${wt}`);
|
|
const installCommon = gitCommonDir(INSTALL_ROOT);
|
|
if (!installCommon) throw new Error('INSTALL_ROOT is not a git worktree');
|
|
if (common !== installCommon) {
|
|
throw new Error('worktree does not share INSTALL_ROOT git-common-dir (cross-repo refused)');
|
|
}
|
|
// Authoritative main = first worktree list entry; executor stays at INSTALL_ROOT.
|
|
const discovered = gitMainWorktree(INSTALL_ROOT) || INSTALL_ROOT;
|
|
return { main: discovered, discoveredMain: discovered, worktree: wt, common };
|
|
}
|
|
|
|
function agentctlPath(main) {
|
|
return path.join(main, 'scripts', 'agentctl.py');
|
|
}
|
|
|
|
function executorPath() {
|
|
return path.join(INSTALL_ROOT, 'scripts', 'pi_subagents.py');
|
|
}
|
|
|
|
function safeConfigPath(worktree, config) {
|
|
const defaultRel = '.doc/ops/2026-09-24-subagent-model-config.md';
|
|
const rel = config || defaultRel;
|
|
if (typeof rel !== 'string' || !rel.trim()) throw new Error('invalid config path');
|
|
if (path.isAbsolute(rel)) throw new Error('config must be worktree-relative');
|
|
if (rel.includes('\0') || rel.split(path.sep).includes('..')) {
|
|
throw new Error('config path traversal refused');
|
|
}
|
|
const abs = path.resolve(worktree, rel);
|
|
const normWt = worktree.endsWith(path.sep) ? worktree : worktree + path.sep;
|
|
if (!(abs === worktree || abs.startsWith(normWt))) {
|
|
throw new Error('config escapes worktree');
|
|
}
|
|
// refuse symlink config
|
|
try {
|
|
if (fs.lstatSync(abs).isSymbolicLink()) throw new Error('config symlink refused');
|
|
} catch (e) {
|
|
if (e.message === 'config symlink refused') throw e;
|
|
// missing checked by executor
|
|
}
|
|
// only the registered config file name
|
|
if (path.basename(abs) !== '2026-09-24-subagent-model-config.md') {
|
|
throw new Error('config must be the registered model config file');
|
|
}
|
|
return abs;
|
|
}
|
|
|
|
function payloadDigest(request) {
|
|
return crypto.createHash('sha256').update(JSON.stringify(request)).digest('hex');
|
|
}
|
|
|
|
function runSync(argv, { cwd, timeout = 120000, env } = {}) {
|
|
const r = spawnSync(argv[0], argv.slice(1), {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
timeout,
|
|
maxBuffer: 8 * 1024 * 1024,
|
|
env: 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) {
|
|
const body = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
return { content: [{ type: 'text', text: body }], isError: true };
|
|
}
|
|
|
|
function parseJsonLine(text) {
|
|
const lines = String(text || '').split(/\n/).map((l) => l.trim()).filter(Boolean);
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
try {
|
|
return JSON.parse(lines[i]);
|
|
} catch {
|
|
/* continue */
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function sanitizeErr(text) {
|
|
return String(text || '')
|
|
.replace(/(api[_-]?key|token|secret|password|authorization)\s*[:=]\s*\S+/gi, '$1=[REDACTED]')
|
|
.slice(0, 2000);
|
|
}
|
|
|
|
function killActiveTree(meta) {
|
|
if (!meta?.child) return;
|
|
const child = meta.child;
|
|
try {
|
|
if (child.pid) {
|
|
try { process.kill(-child.pid, 'SIGTERM'); } catch { /* not a group */ }
|
|
try { child.kill('SIGTERM'); } catch { /* */ }
|
|
}
|
|
} catch { /* */ }
|
|
setTimeout(() => {
|
|
try {
|
|
if (child.exitCode === null && child.pid) {
|
|
try { process.kill(-child.pid, 'SIGKILL'); } catch { /* */ }
|
|
try { child.kill('SIGKILL'); } catch { /* */ }
|
|
}
|
|
} catch { /* */ }
|
|
}, 2000);
|
|
}
|
|
|
|
function reapAll() {
|
|
for (const meta of active.values()) {
|
|
killActiveTree(meta);
|
|
}
|
|
}
|
|
|
|
const TOOLS = [
|
|
{
|
|
name: 'pi_subagent_roles',
|
|
description: 'Read-only: list PI subagent roles/models from markdown config (does not invoke PI)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
worktree: { type: 'string', description: 'Absolute path to registered task worktree' },
|
|
task_id: { type: 'string' },
|
|
config: { type: 'string', description: 'Optional config path relative to worktree' },
|
|
},
|
|
required: ['worktree', 'task_id'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'pi_subagent_run',
|
|
description: 'Start one PI subagent run under main-worktree agentctl lease (non-blocking)',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
worktree: { type: 'string' },
|
|
task_id: { type: 'string' },
|
|
request: {
|
|
type: 'object',
|
|
description: 'PI subagent request (task_id optional; injected from outer task_id)',
|
|
properties: {
|
|
task_id: { type: 'string', description: 'Optional; must match outer task_id when set' },
|
|
role_id: { type: 'string' },
|
|
request_id: { type: 'string' },
|
|
goal: { type: 'string' },
|
|
authorization_ref: { type: 'string' },
|
|
source_sha: { type: 'string', description: 'Full commit hash' },
|
|
input_files: { type: 'array', items: { type: 'string' } },
|
|
timeout_seconds: { type: 'integer' },
|
|
approved_commands: {
|
|
type: 'array',
|
|
items: { type: 'array', items: { type: 'string' } },
|
|
description: 'test_run/acceptance_check only',
|
|
},
|
|
plan_digest: { type: 'string', description: 'sha256 hex for test roles' },
|
|
model_override: { type: 'object' },
|
|
},
|
|
required: [
|
|
'role_id', 'request_id', 'goal', 'authorization_ref',
|
|
'source_sha', 'input_files', 'timeout_seconds',
|
|
],
|
|
additionalProperties: false,
|
|
},
|
|
config: { type: 'string', description: 'Worktree-relative registered model config path' },
|
|
},
|
|
required: ['worktree', 'task_id', 'request'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
{
|
|
name: 'pi_subagent_result',
|
|
description: 'Read durable receipt for a request_id (does not invoke PI). running is not isError; terminal failure is.',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
worktree: { type: 'string' },
|
|
task_id: { type: 'string' },
|
|
request_id: { type: 'string' },
|
|
},
|
|
required: ['worktree', 'task_id', 'request_id'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
];
|
|
|
|
function callTool(name, args = {}) {
|
|
try {
|
|
if (name === 'pi_subagent_roles') {
|
|
validateId(args.task_id, 'task_id');
|
|
const { worktree } = resolveRepo(args.worktree);
|
|
const config = safeConfigPath(worktree, args.config);
|
|
const r = runSync(['python3', '-B', executorPath(), 'roles', '--config', config], {
|
|
cwd: worktree,
|
|
});
|
|
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
|
if (!r.ok) return errResult(payload || sanitizeErr(r.stderr || r.stdout || r.error));
|
|
return textResult(payload);
|
|
}
|
|
if (name === 'pi_subagent_result') {
|
|
validateId(args.task_id, 'task_id');
|
|
validateId(args.request_id, 'request_id');
|
|
const { worktree } = resolveRepo(args.worktree);
|
|
const key = `${args.task_id}/${args.request_id}`;
|
|
const meta = active.get(key);
|
|
const r = runSync([
|
|
'python3', '-B', executorPath(), 'result',
|
|
'--task', args.task_id, '--request-id', args.request_id,
|
|
], { cwd: worktree });
|
|
const payload = parseJsonLine(r.stdout) || parseJsonLine(r.stderr);
|
|
|
|
const surfaceRejection = () => {
|
|
if (meta?.rejection) return errResult(meta.rejection);
|
|
if (meta && meta.exitCode !== null && meta.exitCode !== 0) {
|
|
const errPayload = parseJsonLine(meta.stderr()) || parseJsonLine(meta.stdout()) || {
|
|
ok: false,
|
|
code: 'SUPERVISOR_FAILED',
|
|
message: sanitizeErr(meta.stderr() || meta.stdout() || 'supervisor failed'),
|
|
exit_code: meta.exitCode,
|
|
};
|
|
return errResult(errPayload);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (!payload) {
|
|
const rej = surfaceRejection();
|
|
if (rej) return rej;
|
|
if (meta && meta.exitCode === null) {
|
|
return textResult({
|
|
ok: true,
|
|
run_status: 'running',
|
|
task_id: args.task_id,
|
|
request_id: args.request_id,
|
|
});
|
|
}
|
|
return errResult({
|
|
ok: false,
|
|
code: 'NOT_FOUND',
|
|
message: sanitizeErr(r.stderr || r.stdout || r.error || 'no receipt'),
|
|
});
|
|
}
|
|
// Settled supervisor rejection with no durable receipt (UNKNOWN_REQUEST).
|
|
if (payload.code === 'UNKNOWN_REQUEST') {
|
|
const rej = surfaceRejection();
|
|
if (rej) return rej;
|
|
// Child still in flight: do not treat as terminal unknown.
|
|
if (meta && meta.exitCode === null) {
|
|
return textResult({
|
|
ok: true,
|
|
run_status: 'running',
|
|
task_id: args.task_id,
|
|
request_id: args.request_id,
|
|
});
|
|
}
|
|
return errResult(payload);
|
|
}
|
|
if (!r.ok && payload.code && payload.code !== 'UNKNOWN_REQUEST') {
|
|
return errResult(payload);
|
|
}
|
|
// In-flight: never isError
|
|
if (payload.run_status === 'running') {
|
|
return textResult(payload);
|
|
}
|
|
if (payload.ok === false || (payload.run_status && payload.run_status !== 'succeeded')) {
|
|
return errResult(payload);
|
|
}
|
|
return textResult(payload);
|
|
}
|
|
if (name === 'pi_subagent_run') {
|
|
validateId(args.task_id, 'task_id');
|
|
const { main, worktree } = resolveRepo(args.worktree);
|
|
const request = args.request;
|
|
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
|
return errResult({ ok: false, code: 'INVALID_REQUEST', message: 'request object required' });
|
|
}
|
|
if (request.task_id && request.task_id !== args.task_id) {
|
|
return errResult({ ok: false, code: 'TASK_MISMATCH', message: 'request.task_id must match task_id' });
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(request, 'config_path')) {
|
|
return errResult({
|
|
ok: false,
|
|
code: 'INVALID_REQUEST',
|
|
message: 'request.config_path is not supported; use tool config argument',
|
|
});
|
|
}
|
|
request.task_id = args.task_id;
|
|
const requestId = request.request_id;
|
|
try {
|
|
validateId(requestId, 'request_id');
|
|
} catch (e) {
|
|
return errResult({ ok: false, code: 'INVALID_REQUEST', message: String(e.message || e) });
|
|
}
|
|
const digest = payloadDigest(request);
|
|
const key = `${args.task_id}/${requestId}`;
|
|
const existing = active.get(key);
|
|
if (existing && existing.exitCode === null) {
|
|
if (existing.digest === digest) {
|
|
return textResult({
|
|
ok: true, accepted: true, running: true,
|
|
task_id: args.task_id, request_id: requestId, request_digest: digest,
|
|
});
|
|
}
|
|
return errResult({
|
|
ok: false,
|
|
code: 'REQUEST_DIGEST_MISMATCH',
|
|
message: 'active request_id with different payload',
|
|
});
|
|
}
|
|
let config;
|
|
try {
|
|
config = safeConfigPath(worktree, args.config);
|
|
} catch (e) {
|
|
return errResult({ ok: false, code: 'CONFIG_INVALID', message: String(e.message || e) });
|
|
}
|
|
const resource = `path:.doc/test-reports/assets/pi-subagents/${args.task_id}/${requestId}`;
|
|
// Private OS tmp only — never write into worktree before lease.
|
|
let tmpDir;
|
|
let reqPath;
|
|
try {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-subagent-mcp-'));
|
|
reqPath = path.join(tmpDir, `${requestId}.json`);
|
|
fs.writeFileSync(reqPath, JSON.stringify(request), { mode: 0o600 });
|
|
} catch (e) {
|
|
return errResult({
|
|
ok: false,
|
|
code: 'TMP_WRITE_FAILED',
|
|
message: sanitizeErr(String(e.message || e)),
|
|
});
|
|
}
|
|
let child;
|
|
try {
|
|
child = spawn(
|
|
'python3',
|
|
[
|
|
'-B', agentctlPath(main),
|
|
'with', '--task', args.task_id, '--resource', resource, '--',
|
|
'python3', '-B', executorPath(),
|
|
'run', '--request', reqPath, '--config', config,
|
|
],
|
|
{
|
|
cwd: worktree,
|
|
env: process.env,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
detached: true, // own process group for cleanup
|
|
},
|
|
);
|
|
} catch (e) {
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
|
return errResult({
|
|
ok: false,
|
|
code: 'SPAWN_FAILED',
|
|
message: sanitizeErr(String(e.message || e)),
|
|
});
|
|
}
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (c) => {
|
|
stdout += c.toString('utf8');
|
|
if (stdout.length > MAX_PAYLOAD) stdout = stdout.slice(-MAX_PAYLOAD);
|
|
});
|
|
child.stderr.on('data', (c) => {
|
|
stderr += c.toString('utf8');
|
|
if (stderr.length > MAX_PAYLOAD) stderr = stderr.slice(-MAX_PAYLOAD);
|
|
});
|
|
const meta = {
|
|
child,
|
|
digest,
|
|
stdout: () => stdout,
|
|
stderr: () => stderr,
|
|
exitCode: null,
|
|
rejection: null,
|
|
tmpDir,
|
|
reqPath,
|
|
};
|
|
child.on('error', (err) => {
|
|
meta.exitCode = 1;
|
|
meta.rejection = {
|
|
ok: false,
|
|
code: 'SPAWN_FAILED',
|
|
message: sanitizeErr(String(err.message || err)),
|
|
};
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
|
});
|
|
child.on('exit', (code) => {
|
|
meta.exitCode = code;
|
|
if (code && code !== 0) {
|
|
const parsed = parseJsonLine(stderr) || parseJsonLine(stdout);
|
|
if (parsed && parsed.code) {
|
|
meta.rejection = {
|
|
ok: false,
|
|
code: parsed.code,
|
|
message: sanitizeErr(parsed.message || parsed.code),
|
|
};
|
|
} else {
|
|
meta.rejection = {
|
|
ok: false,
|
|
code: 'SUPERVISOR_FAILED',
|
|
message: sanitizeErr(stderr || stdout || `exit ${code}`),
|
|
exit_code: code,
|
|
};
|
|
}
|
|
}
|
|
try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* */ }
|
|
});
|
|
active.set(key, meta);
|
|
return textResult({
|
|
ok: true,
|
|
accepted: true,
|
|
running: true,
|
|
task_id: args.task_id,
|
|
request_id: requestId,
|
|
request_digest: digest,
|
|
resource,
|
|
});
|
|
}
|
|
return errResult(`unknown tool: ${name}`);
|
|
} catch (e) {
|
|
return errResult({ ok: false, code: 'MCP_ERROR', message: sanitizeErr(String(e?.message || e)) });
|
|
}
|
|
}
|
|
|
|
function send(msg) {
|
|
const body = JSON.stringify(msg);
|
|
if (Buffer.byteLength(body, 'utf8') > MAX_PAYLOAD) {
|
|
const id = msg?.id;
|
|
process.stdout.write(JSON.stringify({
|
|
jsonrpc: '2.0', id, error: { code: -32600, message: 'response too large' },
|
|
}) + '\n');
|
|
return;
|
|
}
|
|
process.stdout.write(body + '\n');
|
|
}
|
|
|
|
function handle(msg) {
|
|
if (!msg || typeof msg !== 'object') return;
|
|
if (msg.method && msg.id === undefined) {
|
|
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: 'pi-subagents', 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 raw = JSON.stringify(args);
|
|
if (Buffer.byteLength(raw, 'utf8') > MAX_PAYLOAD) {
|
|
send({ jsonrpc: '2.0', id, result: errResult('arguments exceed payload bound') });
|
|
return;
|
|
}
|
|
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?.message || e) } });
|
|
}
|
|
}
|
|
|
|
let buf = Buffer.alloc(0);
|
|
process.stdin.on('data', (chunk) => {
|
|
if (buf.length + chunk.length > MAX_PAYLOAD * 4) {
|
|
buf = Buffer.alloc(0);
|
|
process.stderr.write('payload overflow; reset buffer\n');
|
|
return;
|
|
}
|
|
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]);
|
|
if (len > MAX_PAYLOAD) {
|
|
buf = buf.slice(headerEnd + 4);
|
|
continue;
|
|
}
|
|
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;
|
|
}
|
|
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`);
|
|
}
|
|
}
|
|
});
|
|
|
|
function shutdown(signal) {
|
|
process.stderr.write(`pi-subagents-mcp shutting down (${signal})\n`);
|
|
reapAll();
|
|
setTimeout(() => process.exit(0), 500).unref?.();
|
|
}
|
|
|
|
process.stdin.on('end', () => { reapAll(); process.exit(0); });
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => shutdown('SIGINT'));
|