feat(cli): replace MCP tool with standalone nc client in container
Drop the nc MCP tool in favor of a standalone Bun CLI script at container/agent-runner/src/cli/nc.ts. Same interface as host-side bin/nc — all three callers (operator, Claude on host, agent in container) now use the same nc CLI. Container transport: writes cli_request to outbound.db (BEGIN IMMEDIATE for seq safety), polls inbound.db for response, acks via processing_ack. Dockerfile adds a /usr/local/bin/nc wrapper that execs the mounted source. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
253
container/agent-runner/src/cli/nc.ts
Normal file
253
container/agent-runner/src/cli/nc.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* nc — NanoClaw CLI client (container edition).
|
||||
*
|
||||
* Same interface as the host-side `bin/nc`. Detects that it's inside a
|
||||
* container (the session DBs exist at /workspace/) and uses a DB transport
|
||||
* instead of the Unix socket transport.
|
||||
*
|
||||
* Writes a cli_request system message to outbound.db, polls inbound.db
|
||||
* for the response. Self-contained — no imports from agent-runner.
|
||||
*/
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame types (mirrors src/cli/frame.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RequestFrame = {
|
||||
id: string;
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
| { id: string; ok: false; error: { code: string; message: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INBOUND_DB = '/workspace/inbound.db';
|
||||
const OUTBOUND_DB = '/workspace/outbound.db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a cli_request to outbound.db.
|
||||
*
|
||||
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
|
||||
* preventing seq collisions with concurrent agent-runner writes.
|
||||
*/
|
||||
function writeRequest(req: RequestFrame): void {
|
||||
const db = new Database(OUTBOUND_DB);
|
||||
db.exec('PRAGMA journal_mode = DELETE');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
|
||||
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||
const max = Math.max(maxOut, maxIn);
|
||||
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||
).run({
|
||||
$id: req.id,
|
||||
$seq: nextSeq,
|
||||
$content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: req.id,
|
||||
command: req.command,
|
||||
args: req.args,
|
||||
}),
|
||||
});
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
inDb.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll inbound.db for a cli_response matching our requestId.
|
||||
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
|
||||
*/
|
||||
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
inDb.exec('PRAGMA mmap_size = 0');
|
||||
|
||||
try {
|
||||
const row = inDb
|
||||
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
|
||||
|
||||
if (row) {
|
||||
inDb.close();
|
||||
|
||||
// Mark as completed via processing_ack so agent-runner skips it
|
||||
const outDb = new Database(OUTBOUND_DB);
|
||||
outDb.exec('PRAGMA journal_mode = DELETE');
|
||||
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
)
|
||||
.run(row.id);
|
||||
outDb.close();
|
||||
|
||||
const parsed = JSON.parse(row.content);
|
||||
return parsed.frame as ResponseFrame;
|
||||
}
|
||||
} finally {
|
||||
try { inDb.close(); } catch {}
|
||||
}
|
||||
|
||||
Bun.sleepSync(500);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arg parsing (mirrors host-side client.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgv(argv: string[]): {
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
json: boolean;
|
||||
} {
|
||||
const positional: string[] = [];
|
||||
const args: Record<string, unknown> = {};
|
||||
let json = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--json') {
|
||||
json = true;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
if (positional.length === 0) {
|
||||
process.stderr.write('nc: missing command\n');
|
||||
printUsage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const command = positional.length >= 2 ? `${positional[0]}-${positional[1]}` : positional[0];
|
||||
return { command, args, json };
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
['Usage: nc <command> [--key value ...] [--json]', '', 'Run `nc help` to list available commands.', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting (mirrors src/cli/format.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatHuman(resp: ResponseFrame): string {
|
||||
if (!resp.ok) {
|
||||
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||
}
|
||||
|
||||
const data = resp.data;
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return JSON.stringify(data, null, 2) + '\n';
|
||||
}
|
||||
|
||||
const isFlat = data.every(
|
||||
(r) =>
|
||||
typeof r === 'object' &&
|
||||
r !== null &&
|
||||
!Array.isArray(r) &&
|
||||
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
|
||||
);
|
||||
|
||||
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const widths = keys.map((k) =>
|
||||
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
|
||||
);
|
||||
|
||||
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
|
||||
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
const rows = data.map((r) =>
|
||||
keys
|
||||
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
return [header, sep, ...rows, ''].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { command, args, json } = parseArgv(argv);
|
||||
const requestId = generateId();
|
||||
const req: RequestFrame = { id: requestId, command, args };
|
||||
|
||||
writeRequest(req);
|
||||
|
||||
const resp = pollResponse(requestId, 30_000);
|
||||
|
||||
if (!resp) {
|
||||
process.stderr.write('nc: command timed out after 30s\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||
} else {
|
||||
const output = formatHuman(resp);
|
||||
if (!resp.ok) {
|
||||
process.stderr.write(output);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(output);
|
||||
}
|
||||
@@ -124,30 +124,6 @@ export function getMessageIn(id: string): MessageInRow | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending CLI response (by requestId in content).
|
||||
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
|
||||
*/
|
||||
export function findCliResponse(requestId: string): MessageInRow | undefined {
|
||||
const inbound = openInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
try {
|
||||
const response = inbound
|
||||
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"requestId":"${requestId}"%`) as MessageInRow | undefined;
|
||||
|
||||
if (!response) return undefined;
|
||||
|
||||
const acked = outbound.prepare('SELECT 1 FROM processing_ack WHERE message_id = ?').get(response.id);
|
||||
if (acked) return undefined;
|
||||
|
||||
return response;
|
||||
} finally {
|
||||
inbound.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pending response to a question (by questionId in content).
|
||||
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* CLI MCP tool — lets the container agent invoke host CLI commands.
|
||||
*
|
||||
* Follows the ask_user_question blocking pattern: writes a system message
|
||||
* to outbound.db, polls inbound.db for the response.
|
||||
*/
|
||||
import { findCliResponse, markCompleted } from '../db/messages-in.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[mcp-tools] ${msg}`);
|
||||
}
|
||||
|
||||
function generateId(): string {
|
||||
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export const ncCommand: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'nc',
|
||||
description:
|
||||
'Run a NanoClaw CLI command on the host. Returns the command result as JSON. Use `nc list-groups` to see available agent groups. Run with command "help" to list all available commands.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
command: { type: 'string', description: 'Command name (e.g. "list-groups")' },
|
||||
args: {
|
||||
type: 'object',
|
||||
description: 'Command arguments (command-specific)',
|
||||
additionalProperties: true,
|
||||
},
|
||||
timeout: { type: 'number', description: 'Timeout in seconds (default: 30)' },
|
||||
},
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const command = args.command as string;
|
||||
const commandArgs = (args.args as Record<string, unknown>) ?? {};
|
||||
const timeout = ((args.timeout as number) || 30) * 1000;
|
||||
|
||||
if (!command) {
|
||||
return { content: [{ type: 'text' as const, text: 'Error: command is required' }], isError: true };
|
||||
}
|
||||
|
||||
const requestId = generateId();
|
||||
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId,
|
||||
command,
|
||||
args: commandArgs,
|
||||
}),
|
||||
});
|
||||
|
||||
log(`nc: ${requestId} → ${command} ${JSON.stringify(commandArgs)}`);
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
while (Date.now() < deadline) {
|
||||
const response = findCliResponse(requestId);
|
||||
if (response) {
|
||||
markCompleted([response.id]);
|
||||
const parsed = JSON.parse(response.content);
|
||||
const frame = parsed.frame;
|
||||
|
||||
if (frame.ok) {
|
||||
log(`nc response: ${requestId} → ok`);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(frame.data, null, 2) }] };
|
||||
} else {
|
||||
log(`nc response: ${requestId} → error: ${frame.error.message}`);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Error (${frame.error.code}): ${frame.error.message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
log(`nc timeout: ${requestId}`);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `CLI command timed out after ${timeout / 1000}s` }],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([ncCommand]);
|
||||
@@ -10,7 +10,6 @@ import './scheduling.js';
|
||||
import './interactive.js';
|
||||
import './agents.js';
|
||||
import './self-mod.js';
|
||||
import './cli.js';
|
||||
import { startMcpServer } from './server.js';
|
||||
|
||||
function log(msg: string): void {
|
||||
|
||||
Reference in New Issue
Block a user