diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 8c377c8..f3a103a 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -18,7 +18,7 @@ interface ContainerInput { } interface AgentResponse { - status: 'responded' | 'silent'; + outputType: 'message' | 'log'; userMessage?: string; internalLog?: string; } @@ -26,21 +26,21 @@ interface AgentResponse { const AGENT_RESPONSE_SCHEMA = { type: 'object', properties: { - status: { + outputType: { type: 'string', - enum: ['responded', 'silent'], - description: 'Use "responded" when you have a message for the user. Use "silent" when the messages don\'t require a response (e.g. the conversation is between other people and doesn\'t involve you, or no trigger/mention was directed at you).', + enum: ['message', 'log'], + description: '"message": the userMessage field contains a message to send to the user or group. "log": the output will not be sent to the user or group.', }, userMessage: { type: 'string', - description: 'The message to send to the user. Required when status is "responded".', + description: 'A message to send to the user or group. Include when outputType is "message".', }, internalLog: { type: 'string', - description: 'Optional internal note about why you chose this status (for logging, not shown to users).', + description: 'Information that will be logged internally but not sent to the user or group.', }, }, - required: ['status'], + required: ['outputType'], } as const; interface ContainerOutput { @@ -254,7 +254,7 @@ async function main(): Promise { // Add context for scheduled tasks let prompt = input.prompt; if (input.isScheduledTask) { - prompt = `[SCHEDULED TASK - You are running automatically, not in response to a user message. Use mcp__nanoclaw__send_message if needed to communicate with the user.]\n\n${input.prompt}`; + prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${input.prompt}`; } try { @@ -294,13 +294,13 @@ async function main(): Promise { if (message.type === 'result') { if (message.subtype === 'success' && message.structured_output) { result = message.structured_output as AgentResponse; - log(`Agent result: status=${result.status}${result.internalLog ? `, log=${result.internalLog}` : ''}`); + log(`Agent result: outputType=${result.outputType}${result.internalLog ? `, log=${result.internalLog}` : ''}`); } else if (message.subtype === 'error_max_structured_output_retries') { // Agent couldn't produce valid structured output — fall back to text result log('Agent failed to produce structured output, falling back to text'); const textResult = 'result' in message ? (message as { result?: string }).result : null; if (textResult) { - result = { status: 'responded', userMessage: textResult }; + result = { outputType: 'message', userMessage: textResult }; } } } @@ -309,7 +309,7 @@ async function main(): Promise { log('Agent completed successfully'); writeOutput({ status: 'success', - result: result ?? { status: 'silent' }, + result: result ?? { outputType: 'log' }, newSessionId }); diff --git a/container/agent-runner/src/ipc-mcp.ts b/container/agent-runner/src/ipc-mcp.ts index d6c1fc8..23c74be 100644 --- a/container/agent-runner/src/ipc-mcp.ts +++ b/container/agent-runner/src/ipc-mcp.ts @@ -42,7 +42,7 @@ export function createIpcMcp(ctx: IpcMcpContext) { tools: [ tool( 'send_message', - 'Send a message to the current WhatsApp group. Use this to proactively share information or updates.', + 'Send a message to the user or group. The message is delivered immediately while you\'re still running. You can call this multiple times to send multiple messages.', { text: z.string().describe('The message text to send') }, @@ -55,12 +55,12 @@ export function createIpcMcp(ctx: IpcMcpContext) { timestamp: new Date().toISOString() }; - const filename = writeIpcFile(MESSAGES_DIR, data); + writeIpcFile(MESSAGES_DIR, data); return { content: [{ type: 'text', - text: `Message queued for delivery (${filename})` + text: 'Message sent.' }] }; } @@ -71,10 +71,10 @@ export function createIpcMcp(ctx: IpcMcpContext) { `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. CONTEXT MODE - Choose based on task type: -• "group" (recommended for most tasks): Task runs in the group's conversation context, with access to chat history and memory. Use for tasks that need context about ongoing discussions, user preferences, or previous interactions. +• "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions. • "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. -If unsure which mode to use, ask the user. Examples: +If unsure which mode to use, you can ask the user. Examples: - "Remind me about our discussion" → group (needs conversation context) - "Check the weather every morning" → isolated (self-contained task) - "Follow up on my request" → group (needs to know what was requested) @@ -89,7 +89,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), - target_group: z.string().optional().describe('Target group folder (main only, defaults to current group)') + ...(isMain ? { target_group_jid: z.string().optional().describe('JID of the group to schedule the task for. The group must be registered — look up JIDs in /workspace/project/data/registered_groups.json (the keys are JIDs). If the group is not registered, let the user know and ask if they want to activate it. Defaults to the current group.') } : {}), }, async (args) => { // Validate schedule_value before writing IPC @@ -121,7 +121,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): } // Non-main groups can only schedule for themselves - const targetGroup = isMain && args.target_group ? args.target_group : groupFolder; + const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; const data = { type: 'schedule_task', @@ -129,8 +129,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_type: args.schedule_type, schedule_value: args.schedule_value, context_mode: args.context_mode || 'group', - groupFolder: targetGroup, - chatJid, + targetJid, createdBy: groupFolder, timestamp: new Date().toISOString() }; diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 0865d9f..687e65e 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -11,24 +11,16 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Schedule tasks to run later or on a recurring basis - Send messages back to the chat -## Long Tasks +## Communication -If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: +You have two ways to send messages to the user or group: -1. Send a brief message: what you understood and what you'll do -2. Do the work -3. Exit with the final answer +- **mcp__nanoclaw__send_message tool** — Sends a message to the user or group immediately, while you're still running. You can call it multiple times. +- **Output userMessage** — When your outputType is "message", this is sent to the user or group. -This keeps users informed instead of waiting in silence. +Your output **internalLog** is information that will be logged internally but not sent to the user or group. -## Scheduled Tasks - -When you run as a scheduled task (no direct user message), use `mcp__nanoclaw__send_message` if needed to communicate with the user. Your return value is only logged internally - it won't be sent to the user. - -Example: If your task is "Share the weather forecast", you should: -1. Get the weather data -2. Call `mcp__nanoclaw__send_message` with the formatted forecast -3. Return a brief summary for the logs +For requests that involve significant work, consider sending a quick acknowledgment via mcp__nanoclaw__send_message so the user knows you're working on it. ## Your Workspace diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index cb210fa..33dae63 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -11,15 +11,16 @@ You are Andy, a personal assistant. You help with tasks, answer questions, and c - Schedule tasks to run later or on a recurring basis - Send messages back to the chat -## Long Tasks +## Communication -If a request requires significant work (research, multiple steps, file operations), use `mcp__nanoclaw__send_message` to acknowledge first: +You have two ways to send messages to the user or group: -1. Send a brief message: what you understood and what you'll do -2. Do the work -3. Exit with the final answer +- **mcp__nanoclaw__send_message tool** — Sends a message to the user or group immediately, while you're still running. You can call it multiple times. +- **Output userMessage** — When your outputType is "message", this is sent to the user or group. -This keeps users informed instead of waiting in silence. +Your output **internalLog** is information that will be logged internally but not sent to the user or group. + +For requests that involve significant work, consider sending a quick acknowledgment via mcp__nanoclaw__send_message so the user knows you're working on it. ## Memory @@ -188,7 +189,7 @@ You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts ## Scheduling for Other Groups -When scheduling tasks for other groups, use the `target_group` parameter: -- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group: "family-chat")` +When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`: +- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` The task will run in that group's context with access to their files and memory. diff --git a/src/container-runner.ts b/src/container-runner.ts index 19a4e28..2bad7ed 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -41,7 +41,7 @@ export interface ContainerInput { } export interface AgentResponse { - status: 'responded' | 'silent'; + outputType: 'message' | 'log'; userMessage?: string; internalLog?: string; } diff --git a/src/group-queue.ts b/src/group-queue.ts index 25cb621..a476123 100644 --- a/src/group-queue.ts +++ b/src/group-queue.ts @@ -256,8 +256,16 @@ export class GroupQueue { // Stop all active containers gracefully for (const { jid, proc, containerName } of activeProcs) { if (containerName) { - logger.info({ jid, containerName }, 'Stopping container'); - exec(`container stop ${containerName}`); + // Defense-in-depth: re-sanitize before shell interpolation. + // Primary sanitization is in container-runner.ts when building the name, + // but we sanitize again here since exec() runs through a shell. + const safeName = containerName.replace(/[^a-zA-Z0-9-]/g, ''); + logger.info({ jid, containerName: safeName }, 'Stopping container'); + exec(`container stop ${safeName}`, (err) => { + if (err) { + logger.warn({ jid, containerName: safeName, err: err.message }, 'container stop failed'); + } + }); } else { logger.info({ jid, pid: proc.pid }, 'Sending SIGTERM to process'); proc.kill('SIGTERM'); diff --git a/src/index.ts b/src/index.ts index 47158a0..c13a488 100644 --- a/src/index.ts +++ b/src/index.ts @@ -247,13 +247,13 @@ async function processGroupMessages(chatJid: string): Promise { missedMessages[missedMessages.length - 1].timestamp; saveState(); - if (response.status === 'responded' && response.userMessage) { + if (response.outputType === 'message' && response.userMessage) { await sendMessage(chatJid, `${ASSISTANT_NAME}: ${response.userMessage}`); } if (response.internalLog) { logger.info( - { group: group.name, agentStatus: response.status }, + { group: group.name, outputType: response.outputType }, `Agent: ${response.internalLog}`, ); } @@ -320,7 +320,7 @@ async function runAgent( return 'error'; } - return output.result ?? { status: 'silent' }; + return output.result ?? { outputType: 'log' }; } catch (err) { logger.error({ group: group.name, err }, 'Agent error'); return 'error'; @@ -468,6 +468,7 @@ async function processTaskIpc( context_mode?: string; groupFolder?: string; chatJid?: string; + targetJid?: string; // For register_group jid?: string; name?: string; @@ -484,27 +485,27 @@ async function processTaskIpc( data.prompt && data.schedule_type && data.schedule_value && - data.groupFolder + data.targetJid ) { - // Authorization: non-main groups can only schedule for themselves - const targetGroup = data.groupFolder; - if (!isMain && targetGroup !== sourceGroup) { + // Resolve the target group from JID + const targetJid = data.targetJid as string; + const targetGroupEntry = registeredGroups[targetJid]; + + if (!targetGroupEntry) { logger.warn( - { sourceGroup, targetGroup }, - 'Unauthorized schedule_task attempt blocked', + { targetJid }, + 'Cannot schedule task: target group not registered', ); break; } - // Resolve the correct JID for the target group (don't trust IPC payload) - const targetJid = Object.entries(registeredGroups).find( - ([, group]) => group.folder === targetGroup, - )?.[0]; + const targetFolder = targetGroupEntry.folder; - if (!targetJid) { + // Authorization: non-main groups can only schedule for themselves + if (!isMain && targetFolder !== sourceGroup) { logger.warn( - { targetGroup }, - 'Cannot schedule task: target group not registered', + { sourceGroup, targetFolder }, + 'Unauthorized schedule_task attempt blocked', ); break; } @@ -554,7 +555,7 @@ async function processTaskIpc( : 'isolated'; createTask({ id: taskId, - group_folder: targetGroup, + group_folder: targetFolder, chat_jid: targetJid, prompt: data.prompt, schedule_type: scheduleType, @@ -565,7 +566,7 @@ async function processTaskIpc( created_at: new Date().toISOString(), }); logger.info( - { taskId, sourceGroup, targetGroup, contextMode }, + { taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC', ); } diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 4dc5750..ba9e621 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import { + ASSISTANT_NAME, GROUPS_DIR, MAIN_GROUP_FOLDER, SCHEDULER_POLL_INTERVAL, @@ -104,6 +105,9 @@ async function runTask( if (output.status === 'error') { error = output.error || 'Unknown error'; } else if (output.result) { + if (output.result.outputType === 'message' && output.result.userMessage) { + await deps.sendMessage(task.chat_jid, `${ASSISTANT_NAME}: ${output.result.userMessage}`); + } result = output.result.userMessage || output.result.internalLog || null; }