diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..3fcb226 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -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). * Reads from inbound.db, checks processing_ack to skip already-handled responses. diff --git a/container/agent-runner/src/mcp-tools/cli.ts b/container/agent-runner/src/mcp-tools/cli.ts new file mode 100644 index 0000000..868eee3 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.ts @@ -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 { + 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) ?? {}; + 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]); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index bdaef5c..672a637 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -10,6 +10,7 @@ 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 { diff --git a/src/cli/delivery-action.ts b/src/cli/delivery-action.ts new file mode 100644 index 0000000..5c693be --- /dev/null +++ b/src/cli/delivery-action.ts @@ -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) ?? {}; + + 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 }); +}); diff --git a/src/index.ts b/src/index.ts index c13ef11..3d39dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,6 +56,7 @@ import './modules/index.js'; // CLI command barrel — populates the `nc` registry before the CLI server // accepts connections. import './cli/commands/index.js'; +import './cli/delivery-action.js'; import { startCliServer, stopCliServer } from './cli/socket-server.js'; import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js';