feat(cli): wire nc CLI commands into container agent
Add delivery action handler (cli_request) so the host dispatches CLI commands arriving from container agents via outbound.db and writes responses back to inbound.db. Add nc MCP tool in the agent-runner following the ask_user_question blocking pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -124,6 +124,30 @@ 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).
|
* Find a pending response to a question (by questionId in content).
|
||||||
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
|
* Reads from inbound.db, checks processing_ack to skip already-handled responses.
|
||||||
|
|||||||
97
container/agent-runner/src/mcp-tools/cli.ts
Normal file
97
container/agent-runner/src/mcp-tools/cli.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 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,6 +10,7 @@ import './scheduling.js';
|
|||||||
import './interactive.js';
|
import './interactive.js';
|
||||||
import './agents.js';
|
import './agents.js';
|
||||||
import './self-mod.js';
|
import './self-mod.js';
|
||||||
|
import './cli.js';
|
||||||
import { startMcpServer } from './server.js';
|
import { startMcpServer } from './server.js';
|
||||||
|
|
||||||
function log(msg: string): void {
|
function log(msg: string): void {
|
||||||
|
|||||||
59
src/cli/delivery-action.ts
Normal file
59
src/cli/delivery-action.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Delivery action handler for CLI requests from container agents.
|
||||||
|
*
|
||||||
|
* When an agent writes a `cli_request` system message to outbound.db,
|
||||||
|
* the delivery poll picks it up and calls this handler. We dispatch
|
||||||
|
* the command and write the response back to inbound.db.
|
||||||
|
*/
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { registerDeliveryAction } from '../delivery.js';
|
||||||
|
import { insertMessage } from '../db/session-db.js';
|
||||||
|
import { log } from '../log.js';
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
|
import type { RequestFrame } from './frame.js';
|
||||||
|
import type { Session } from '../types.js';
|
||||||
|
|
||||||
|
registerDeliveryAction('cli_request', async (content, session, inDb) => {
|
||||||
|
const requestId = content.requestId as string;
|
||||||
|
const command = content.command as string;
|
||||||
|
const args = (content.args as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
if (!requestId || !command) {
|
||||||
|
log.warn('cli_request missing requestId or command', { sessionId: session.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req: RequestFrame = { id: requestId, command, args };
|
||||||
|
const ctx = {
|
||||||
|
caller: 'agent' as const,
|
||||||
|
sessionId: session.id,
|
||||||
|
agentGroupId: session.agent_group_id,
|
||||||
|
messagingGroupId: session.messaging_group_id ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
|
||||||
|
|
||||||
|
const response = await dispatch(req, ctx);
|
||||||
|
|
||||||
|
// Write response to inbound.db so the container can read it.
|
||||||
|
// trigger=0: don't wake the agent — this is an inline response to a tool call.
|
||||||
|
insertMessage(inDb, {
|
||||||
|
id: `cli-resp-${requestId}`,
|
||||||
|
kind: 'system',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: null,
|
||||||
|
channelType: null,
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: 'cli_response',
|
||||||
|
requestId,
|
||||||
|
frame: response,
|
||||||
|
}),
|
||||||
|
processAfter: null,
|
||||||
|
recurrence: null,
|
||||||
|
trigger: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
|
||||||
|
});
|
||||||
@@ -56,6 +56,7 @@ import './modules/index.js';
|
|||||||
// CLI command barrel — populates the `nc` registry before the CLI server
|
// CLI command barrel — populates the `nc` registry before the CLI server
|
||||||
// accepts connections.
|
// accepts connections.
|
||||||
import './cli/commands/index.js';
|
import './cli/commands/index.js';
|
||||||
|
import './cli/delivery-action.js';
|
||||||
import { startCliServer, stopCliServer } from './cli/socket-server.js';
|
import { startCliServer, stopCliServer } from './cli/socket-server.js';
|
||||||
|
|
||||||
import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js';
|
import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js';
|
||||||
|
|||||||
Reference in New Issue
Block a user