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:
gavrielc
2026-05-06 00:07:37 +03:00
parent bc19b716bf
commit 5e2bf1cb54
5 changed files with 258 additions and 122 deletions

View 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);
}

View File

@@ -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.

View File

@@ -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]);

View File

@@ -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 {