diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md new file mode 100644 index 0000000..2205a58 --- /dev/null +++ b/.claude/skills/add-ollama-tool/SKILL.md @@ -0,0 +1,152 @@ +--- +name: add-ollama-tool +description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries. +--- + +# Add Ollama Integration + +This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. + +Tools added: +- `ollama_list_models` — lists installed Ollama models +- `ollama_generate` — sends a prompt to a specified model and returns the response + +## Phase 1: Pre-flight + +### Check if already applied + +Read `.nanoclaw/state.yaml`. If `ollama` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place. + +### Check prerequisites + +Verify Ollama is installed and running on the host: + +```bash +ollama list +``` + +If Ollama is not installed, direct the user to https://ollama.com/download. + +If no models are installed, suggest pulling one: + +> You need at least one model. I recommend: +> +> ```bash +> ollama pull gemma3:1b # Small, fast (1GB) +> ollama pull llama3.2 # Good general purpose (2GB) +> ollama pull qwen3-coder:30b # Best for code tasks (18GB) +> ``` + +## Phase 2: Apply Code Changes + +Run the skills engine to apply this skill's code package. + +### Initialize skills system (if needed) + +If `.nanoclaw/` directory doesn't exist yet: + +```bash +npx tsx scripts/apply-skill.ts --init +``` + +### Apply the skill + +```bash +npx tsx scripts/apply-skill.ts .claude/skills/add-ollama-tool +``` + +This deterministically: +- Adds `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) +- Adds `scripts/ollama-watch.sh` (macOS notification watcher) +- Three-way merges Ollama MCP config into `container/agent-runner/src/index.ts` (allowedTools + mcpServers) +- Three-way merges `[OLLAMA]` log surfacing into `src/container-runner.ts` +- Records the application in `.nanoclaw/state.yaml` + +If the apply reports merge conflicts, read the intent files: +- `modify/container/agent-runner/src/index.ts.intent.md` — what changed and invariants +- `modify/src/container-runner.ts.intent.md` — what changed and invariants + +### Copy to per-group agent-runner + +Existing groups have a cached copy of the agent-runner source. Copy the new files: + +```bash +for dir in data/sessions/*/agent-runner-src; do + cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/" + cp container/agent-runner/src/index.ts "$dir/" +done +``` + +### Validate code changes + +```bash +npm run build +./container/build.sh +``` + +Build must be clean before proceeding. + +## Phase 3: Configure + +### Set Ollama host (optional) + +By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`: + +```bash +OLLAMA_HOST=http://your-ollama-host:11434 +``` + +### Restart the service + +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +## Phase 4: Verify + +### Test via WhatsApp + +Tell the user: + +> Send a message like: "use ollama to tell me the capital of France" +> +> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. + +### Monitor activity (optional) + +Run the watcher script for macOS notifications when Ollama is used: + +```bash +./scripts/ollama-watch.sh +``` + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log | grep -i ollama +``` + +Look for: +- `Agent output: ... Ollama ...` — agent used Ollama successfully +- `[OLLAMA] >>> Generating` — generation started (if log surfacing works) +- `[OLLAMA] <<< Done` — generation completed + +## Troubleshooting + +### Agent says "Ollama is not installed" + +The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means: +1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` +2. The per-group source wasn't updated — re-copy files (see Phase 2) +3. The container wasn't rebuilt — run `./container/build.sh` + +### "Failed to connect to Ollama" + +1. Verify Ollama is running: `ollama list` +2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags` +3. If using a custom host, check `OLLAMA_HOST` in `.env` + +### Agent doesn't use Ollama tools + +The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." diff --git a/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts b/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts new file mode 100644 index 0000000..7d29bb2 --- /dev/null +++ b/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts @@ -0,0 +1,147 @@ +/** + * Ollama MCP Server for NanoClaw + * Exposes local Ollama models as tools for the container agent. + * Uses host.docker.internal to reach the host's Ollama instance from Docker. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import fs from 'fs'; +import path from 'path'; + +const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434'; +const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json'; + +function log(msg: string): void { + console.error(`[OLLAMA] ${msg}`); +} + +function writeStatus(status: string, detail?: string): void { + try { + const data = { status, detail, timestamp: new Date().toISOString() }; + const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`; + fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data)); + fs.renameSync(tmpPath, OLLAMA_STATUS_FILE); + } catch { /* best-effort */ } +} + +async function ollamaFetch(path: string, options?: RequestInit): Promise { + const url = `${OLLAMA_HOST}${path}`; + try { + return await fetch(url, options); + } catch (err) { + // Fallback to localhost if host.docker.internal fails + if (OLLAMA_HOST.includes('host.docker.internal')) { + const fallbackUrl = url.replace('host.docker.internal', 'localhost'); + return await fetch(fallbackUrl, options); + } + throw err; + } +} + +const server = new McpServer({ + name: 'ollama', + version: '1.0.0', +}); + +server.tool( + 'ollama_list_models', + 'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.', + {}, + async () => { + log('Listing models...'); + writeStatus('listing', 'Listing available models'); + try { + const res = await ollamaFetch('/api/tags'); + if (!res.ok) { + return { + content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }], + isError: true, + }; + } + + const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> }; + const models = data.models || []; + + if (models.length === 0) { + return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull ` on the host to install one.' }] }; + } + + const list = models + .map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`) + .join('\n'); + + log(`Found ${models.length} models`); + return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +server.tool( + 'ollama_generate', + 'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.', + { + model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'), + prompt: z.string().describe('The prompt to send to the model'), + system: z.string().optional().describe('Optional system prompt to set model behavior'), + }, + async (args) => { + log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); + writeStatus('generating', `Generating with ${args.model}`); + try { + const body: Record = { + model: args.model, + prompt: args.prompt, + stream: false, + }; + if (args.system) { + body.system = args.system; + } + + const res = await ollamaFetch('/api/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + return { + content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }], + isError: true, + }; + } + + const data = await res.json() as { response: string; total_duration?: number; eval_count?: number }; + + let meta = ''; + if (data.total_duration) { + const secs = (data.total_duration / 1e9).toFixed(1); + meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`; + log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`); + writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`); + } else { + log(`<<< Done: ${args.model} | ${data.response.length} chars`); + writeStatus('done', `${args.model} | ${data.response.length} chars`); + } + + return { content: [{ type: 'text' as const, text: data.response + meta }] }; + } catch (err) { + return { + content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }], + isError: true, + }; + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh b/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh new file mode 100755 index 0000000..1aa4a93 --- /dev/null +++ b/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Watch NanoClaw IPC for Ollama activity and show macOS notifications +# Usage: ./scripts/ollama-watch.sh + +cd "$(dirname "$0")/.." || exit 1 + +echo "Watching for Ollama activity..." +echo "Press Ctrl+C to stop" +echo "" + +LAST_TIMESTAMP="" + +while true; do + # Check all group IPC dirs for ollama_status.json + for status_file in data/ipc/*/ollama_status.json; do + [ -f "$status_file" ] || continue + + TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null) + [ -z "$TIMESTAMP" ] && continue + [ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue + + LAST_TIMESTAMP="$TIMESTAMP" + STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null) + DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null) + + case "$STATUS" in + generating) + osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null + echo "$(date +%H:%M:%S) 🔄 $DETAIL" + ;; + done) + osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null + echo "$(date +%H:%M:%S) ✅ $DETAIL" + ;; + listing) + echo "$(date +%H:%M:%S) 📋 Listing models..." + ;; + esac + done + sleep 0.5 +done diff --git a/.claude/skills/add-ollama-tool/manifest.yaml b/.claude/skills/add-ollama-tool/manifest.yaml new file mode 100644 index 0000000..6ce813a --- /dev/null +++ b/.claude/skills/add-ollama-tool/manifest.yaml @@ -0,0 +1,17 @@ +skill: ollama +version: 1.0.0 +description: "Local Ollama model inference via MCP server" +core_version: 0.1.0 +adds: + - container/agent-runner/src/ollama-mcp-stdio.ts + - scripts/ollama-watch.sh +modifies: + - container/agent-runner/src/index.ts + - src/container-runner.ts +structured: + npm_dependencies: {} + env_additions: + - OLLAMA_HOST +conflicts: [] +depends: [] +test: "npm run build" diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts new file mode 100644 index 0000000..7432393 --- /dev/null +++ b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts @@ -0,0 +1,593 @@ +/** + * NanoClaw Agent Runner + * Runs inside a container, receives config via stdin, outputs result to stdout + * + * Input protocol: + * Stdin: Full ContainerInput JSON (read until EOF, like before) + * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ + * Files: {type:"message", text:"..."}.json — polled and consumed + * Sentinel: /workspace/ipc/input/_close — signals session end + * + * Stdout protocol: + * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. + * Multiple results may be emitted (one per agent teams result). + * Final marker after loop ends signals completion. + */ + +import fs from 'fs'; +import path from 'path'; +import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk'; +import { fileURLToPath } from 'url'; + +interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + secrets?: Record; +} + +interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +interface SessionEntry { + sessionId: string; + fullPath: string; + summary: string; + firstPrompt: string; +} + +interface SessionsIndex { + entries: SessionEntry[]; +} + +interface SDKUserMessage { + type: 'user'; + message: { role: 'user'; content: string }; + parent_tool_use_id: null; + session_id: string; +} + +const IPC_INPUT_DIR = '/workspace/ipc/input'; +const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); +const IPC_POLL_MS = 500; + +/** + * Push-based async iterable for streaming user messages to the SDK. + * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. + */ +class MessageStream { + private queue: SDKUserMessage[] = []; + private waiting: (() => void) | null = null; + private done = false; + + push(text: string): void { + this.queue.push({ + type: 'user', + message: { role: 'user', content: text }, + parent_tool_use_id: null, + session_id: '', + }); + this.waiting?.(); + } + + end(): void { + this.done = true; + this.waiting?.(); + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + while (true) { + while (this.queue.length > 0) { + yield this.queue.shift()!; + } + if (this.done) return; + await new Promise(r => { this.waiting = r; }); + this.waiting = null; + } + } +} + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); +} + +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + +function writeOutput(output: ContainerOutput): void { + console.log(OUTPUT_START_MARKER); + console.log(JSON.stringify(output)); + console.log(OUTPUT_END_MARKER); +} + +function log(message: string): void { + console.error(`[agent-runner] ${message}`); +} + +function getSessionSummary(sessionId: string, transcriptPath: string): string | null { + const projectDir = path.dirname(transcriptPath); + const indexPath = path.join(projectDir, 'sessions-index.json'); + + if (!fs.existsSync(indexPath)) { + log(`Sessions index not found at ${indexPath}`); + return null; + } + + try { + const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); + const entry = index.entries.find(e => e.sessionId === sessionId); + if (entry?.summary) { + return entry.summary; + } + } catch (err) { + log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); + } + + return null; +} + +/** + * Archive the full transcript to conversations/ before compaction. + */ +function createPreCompactHook(assistantName?: string): HookCallback { + return async (input, _toolUseId, _context) => { + const preCompact = input as PreCompactHookInput; + const transcriptPath = preCompact.transcript_path; + const sessionId = preCompact.session_id; + + if (!transcriptPath || !fs.existsSync(transcriptPath)) { + log('No transcript found for archiving'); + return {}; + } + + try { + const content = fs.readFileSync(transcriptPath, 'utf-8'); + const messages = parseTranscript(content); + + if (messages.length === 0) { + log('No messages to archive'); + return {}; + } + + const summary = getSessionSummary(sessionId, transcriptPath); + const name = summary ? sanitizeFilename(summary) : generateFallbackName(); + + const conversationsDir = '/workspace/group/conversations'; + fs.mkdirSync(conversationsDir, { recursive: true }); + + const date = new Date().toISOString().split('T')[0]; + const filename = `${date}-${name}.md`; + const filePath = path.join(conversationsDir, filename); + + const markdown = formatTranscriptMarkdown(messages, summary, assistantName); + fs.writeFileSync(filePath, markdown); + + log(`Archived conversation to ${filePath}`); + } catch (err) { + log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); + } + + return {}; + }; +} + +// Secrets to strip from Bash tool subprocess environments. +// These are needed by claude-code for API auth but should never +// be visible to commands Kit runs. +const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN']; + +function createSanitizeBashHook(): HookCallback { + return async (input, _toolUseId, _context) => { + const preInput = input as PreToolUseHookInput; + const command = (preInput.tool_input as { command?: string })?.command; + if (!command) return {}; + + const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `; + return { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + updatedInput: { + ...(preInput.tool_input as Record), + command: unsetPrefix + command, + }, + }, + }; + }; +} + +function sanitizeFilename(summary: string): string { + return summary + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +function generateFallbackName(): string { + const time = new Date(); + return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; +} + +interface ParsedMessage { + role: 'user' | 'assistant'; + content: string; +} + +function parseTranscript(content: string): ParsedMessage[] { + const messages: ParsedMessage[] = []; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line); + if (entry.type === 'user' && entry.message?.content) { + const text = typeof entry.message.content === 'string' + ? entry.message.content + : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); + if (text) messages.push({ role: 'user', content: text }); + } else if (entry.type === 'assistant' && entry.message?.content) { + const textParts = entry.message.content + .filter((c: { type: string }) => c.type === 'text') + .map((c: { text: string }) => c.text); + const text = textParts.join(''); + if (text) messages.push({ role: 'assistant', content: text }); + } + } catch { + } + } + + return messages; +} + +function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { + const now = new Date(); + const formatDateTime = (d: Date) => d.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + + const lines: string[] = []; + lines.push(`# ${title || 'Conversation'}`); + lines.push(''); + lines.push(`Archived: ${formatDateTime(now)}`); + lines.push(''); + lines.push('---'); + lines.push(''); + + for (const msg of messages) { + const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); + const content = msg.content.length > 2000 + ? msg.content.slice(0, 2000) + '...' + : msg.content; + lines.push(`**${sender}**: ${content}`); + lines.push(''); + } + + return lines.join('\n'); +} + +/** + * Check for _close sentinel. + */ +function shouldClose(): boolean { + if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { + try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + return true; + } + return false; +} + +/** + * Drain all pending IPC input messages. + * Returns messages found, or empty array. + */ +function drainIpcInput(): string[] { + try { + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + const files = fs.readdirSync(IPC_INPUT_DIR) + .filter(f => f.endsWith('.json')) + .sort(); + + const messages: string[] = []; + for (const file of files) { + const filePath = path.join(IPC_INPUT_DIR, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + fs.unlinkSync(filePath); + if (data.type === 'message' && data.text) { + messages.push(data.text); + } + } catch (err) { + log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); + try { fs.unlinkSync(filePath); } catch { /* ignore */ } + } + } + return messages; + } catch (err) { + log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); + return []; + } +} + +/** + * Wait for a new IPC message or _close sentinel. + * Returns the messages as a single string, or null if _close. + */ +function waitForIpcMessage(): Promise { + return new Promise((resolve) => { + const poll = () => { + if (shouldClose()) { + resolve(null); + return; + } + const messages = drainIpcInput(); + if (messages.length > 0) { + resolve(messages.join('\n')); + return; + } + setTimeout(poll, IPC_POLL_MS); + }; + poll(); + }); +} + +/** + * Run a single query and stream results via writeOutput. + * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, + * allowing agent teams subagents to run to completion. + * Also pipes IPC messages into the stream during the query. + */ +async function runQuery( + prompt: string, + sessionId: string | undefined, + mcpServerPath: string, + containerInput: ContainerInput, + sdkEnv: Record, + resumeAt?: string, +): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { + const stream = new MessageStream(); + stream.push(prompt); + + // Poll IPC for follow-up messages and _close sentinel during the query + let ipcPolling = true; + let closedDuringQuery = false; + const pollIpcDuringQuery = () => { + if (!ipcPolling) return; + if (shouldClose()) { + log('Close sentinel detected during query, ending stream'); + closedDuringQuery = true; + stream.end(); + ipcPolling = false; + return; + } + const messages = drainIpcInput(); + for (const text of messages) { + log(`Piping IPC message into active query (${text.length} chars)`); + stream.push(text); + } + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + }; + setTimeout(pollIpcDuringQuery, IPC_POLL_MS); + + let newSessionId: string | undefined; + let lastAssistantUuid: string | undefined; + let messageCount = 0; + let resultCount = 0; + + // Load global CLAUDE.md as additional system context (shared across all groups) + const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; + let globalClaudeMd: string | undefined; + if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { + globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); + } + + // Discover additional directories mounted at /workspace/extra/* + // These are passed to the SDK so their CLAUDE.md files are loaded automatically + const extraDirs: string[] = []; + const extraBase = '/workspace/extra'; + if (fs.existsSync(extraBase)) { + for (const entry of fs.readdirSync(extraBase)) { + const fullPath = path.join(extraBase, entry); + if (fs.statSync(fullPath).isDirectory()) { + extraDirs.push(fullPath); + } + } + } + if (extraDirs.length > 0) { + log(`Additional directories: ${extraDirs.join(', ')}`); + } + + for await (const message of query({ + prompt: stream, + options: { + cwd: '/workspace/group', + additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, + resume: sessionId, + resumeSessionAt: resumeAt, + systemPrompt: globalClaudeMd + ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } + : undefined, + allowedTools: [ + 'Bash', + 'Read', 'Write', 'Edit', 'Glob', 'Grep', + 'WebSearch', 'WebFetch', + 'Task', 'TaskOutput', 'TaskStop', + 'TeamCreate', 'TeamDelete', 'SendMessage', + 'TodoWrite', 'ToolSearch', 'Skill', + 'NotebookEdit', + 'mcp__nanoclaw__*', + 'mcp__ollama__*' + ], + env: sdkEnv, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: ['project', 'user'], + mcpServers: { + nanoclaw: { + command: 'node', + args: [mcpServerPath], + env: { + NANOCLAW_CHAT_JID: containerInput.chatJid, + NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, + NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', + }, + }, + ollama: { + command: 'node', + args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')], + }, + }, + hooks: { + PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], + PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }], + }, + } + })) { + messageCount++; + const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; + log(`[msg #${messageCount}] type=${msgType}`); + + if (message.type === 'assistant' && 'uuid' in message) { + lastAssistantUuid = (message as { uuid: string }).uuid; + } + + if (message.type === 'system' && message.subtype === 'init') { + newSessionId = message.session_id; + log(`Session initialized: ${newSessionId}`); + } + + if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { + const tn = message as { task_id: string; status: string; summary: string }; + log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); + } + + if (message.type === 'result') { + resultCount++; + const textResult = 'result' in message ? (message as { result?: string }).result : null; + log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); + writeOutput({ + status: 'success', + result: textResult || null, + newSessionId + }); + } + } + + ipcPolling = false; + log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); + return { newSessionId, lastAssistantUuid, closedDuringQuery }; +} + +async function main(): Promise { + let containerInput: ContainerInput; + + try { + const stdinData = await readStdin(); + containerInput = JSON.parse(stdinData); + // Delete the temp file the entrypoint wrote — it contains secrets + try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } + log(`Received input for group: ${containerInput.groupFolder}`); + } catch (err) { + writeOutput({ + status: 'error', + result: null, + error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` + }); + process.exit(1); + } + + // Build SDK env: merge secrets into process.env for the SDK only. + // Secrets never touch process.env itself, so Bash subprocesses can't see them. + const sdkEnv: Record = { ...process.env }; + for (const [key, value] of Object.entries(containerInput.secrets || {})) { + sdkEnv[key] = value; + } + + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); + + let sessionId = containerInput.sessionId; + fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); + + // Clean up stale _close sentinel from previous container runs + try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } + + // Build initial prompt (drain any pending IPC messages too) + let prompt = containerInput.prompt; + if (containerInput.isScheduledTask) { + prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; + } + const pending = drainIpcInput(); + if (pending.length > 0) { + log(`Draining ${pending.length} pending IPC messages into initial prompt`); + prompt += '\n' + pending.join('\n'); + } + + // Query loop: run query → wait for IPC message → run new query → repeat + let resumeAt: string | undefined; + try { + while (true) { + log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); + + const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); + if (queryResult.newSessionId) { + sessionId = queryResult.newSessionId; + } + if (queryResult.lastAssistantUuid) { + resumeAt = queryResult.lastAssistantUuid; + } + + // If _close was consumed during the query, exit immediately. + // Don't emit a session-update marker (it would reset the host's + // idle timer and cause a 30-min delay before the next _close). + if (queryResult.closedDuringQuery) { + log('Close sentinel consumed during query, exiting'); + break; + } + + // Emit session update so host can track it + writeOutput({ status: 'success', result: null, newSessionId: sessionId }); + + log('Query ended, waiting for next IPC message...'); + + // Wait for the next message or _close sentinel + const nextMessage = await waitForIpcMessage(); + if (nextMessage === null) { + log('Close sentinel received, exiting'); + break; + } + + log(`Got new message (${nextMessage.length} chars), starting new query`); + prompt = nextMessage; + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + log(`Agent error: ${errorMessage}`); + writeOutput({ + status: 'error', + result: null, + newSessionId: sessionId, + error: errorMessage + }); + process.exit(1); + } +} + +main(); diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md new file mode 100644 index 0000000..a657ef5 --- /dev/null +++ b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md @@ -0,0 +1,23 @@ +# Intent: container/agent-runner/src/index.ts modifications + +## What changed +Added Ollama MCP server configuration so the container agent can call local Ollama models as tools. + +## Key sections + +### allowedTools array (inside runQuery → options) +- Added: `'mcp__ollama__*'` to the allowedTools array (after `'mcp__nanoclaw__*'`) + +### mcpServers object (inside runQuery → options) +- Added: `ollama` entry as a stdio MCP server + - command: `'node'` + - args: resolves to `ollama-mcp-stdio.js` in the same directory as `ipc-mcp-stdio.js` + - Uses `path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')` to compute the path + +## Invariants (must-keep) +- All existing allowedTools entries unchanged +- nanoclaw MCP server config unchanged +- All other query options (permissionMode, hooks, env, etc.) unchanged +- MessageStream class unchanged +- IPC polling logic unchanged +- Session management unchanged diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts new file mode 100644 index 0000000..2324cde --- /dev/null +++ b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts @@ -0,0 +1,708 @@ +/** + * Container Runner for NanoClaw + * Spawns agent execution in containers and handles IPC + */ +import { ChildProcess, exec, spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { + CONTAINER_IMAGE, + CONTAINER_MAX_OUTPUT_SIZE, + CONTAINER_TIMEOUT, + DATA_DIR, + GROUPS_DIR, + IDLE_TIMEOUT, + TIMEZONE, +} from './config.js'; +import { readEnvFile } from './env.js'; +import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { logger } from './logger.js'; +import { + CONTAINER_RUNTIME_BIN, + readonlyMountArgs, + stopContainer, +} from './container-runtime.js'; +import { validateAdditionalMounts } from './mount-security.js'; +import { RegisteredGroup } from './types.js'; + +// Sentinel markers for robust output parsing (must match agent-runner) +const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; +const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; + +export interface ContainerInput { + prompt: string; + sessionId?: string; + groupFolder: string; + chatJid: string; + isMain: boolean; + isScheduledTask?: boolean; + assistantName?: string; + secrets?: Record; +} + +export interface ContainerOutput { + status: 'success' | 'error'; + result: string | null; + newSessionId?: string; + error?: string; +} + +interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +function buildVolumeMounts( + group: RegisteredGroup, + isMain: boolean, +): VolumeMount[] { + const mounts: VolumeMount[] = []; + const projectRoot = process.cwd(); + const groupDir = resolveGroupFolderPath(group.folder); + + if (isMain) { + // Main gets the project root read-only. Writable paths the agent needs + // (group folder, IPC, .claude/) are mounted separately below. + // Read-only prevents the agent from modifying host application code + // (src/, dist/, package.json, etc.) which would bypass the sandbox + // entirely on next restart. + mounts.push({ + hostPath: projectRoot, + containerPath: '/workspace/project', + readonly: true, + }); + + // Shadow .env so the agent cannot read secrets from the mounted project root. + // Secrets are passed via stdin instead (see readSecrets()). + const envFile = path.join(projectRoot, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ + hostPath: '/dev/null', + containerPath: '/workspace/project/.env', + readonly: true, + }); + } + + // Main also gets its group folder as the working directory + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + } else { + // Other groups only get their own folder + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + + // Global memory directory (read-only for non-main) + // Only directory mounts are supported, not file mounts + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ + hostPath: globalDir, + containerPath: '/workspace/global', + readonly: true, + }); + } + } + + // Per-group Claude sessions directory (isolated from other groups) + // Each group gets their own .claude/ to prevent cross-group session access + const groupSessionsDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + '.claude', + ); + fs.mkdirSync(groupSessionsDir, { recursive: true }); + const settingsFile = path.join(groupSessionsDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + // Enable agent swarms (subagent orchestration) + // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + // Load CLAUDE.md from additional mounted directories + // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + // Enable Claude's memory feature (persists user preferences between sessions) + // https://code.claude.com/docs/en/memory#manage-auto-memory + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); + } + + // Sync skills from container/skills/ into each group's .claude/skills/ + const skillsSrc = path.join(process.cwd(), 'container', 'skills'); + const skillsDst = path.join(groupSessionsDir, 'skills'); + if (fs.existsSync(skillsSrc)) { + for (const skillDir of fs.readdirSync(skillsSrc)) { + const srcDir = path.join(skillsSrc, skillDir); + if (!fs.statSync(srcDir).isDirectory()) continue; + const dstDir = path.join(skillsDst, skillDir); + fs.cpSync(srcDir, dstDir, { recursive: true }); + } + } + mounts.push({ + hostPath: groupSessionsDir, + containerPath: '/home/node/.claude', + readonly: false, + }); + + // Per-group IPC namespace: each group gets its own IPC directory + // This prevents cross-group privilege escalation via IPC + const groupIpcDir = resolveGroupIpcPath(group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + mounts.push({ + hostPath: groupIpcDir, + containerPath: '/workspace/ipc', + readonly: false, + }); + + // Copy agent-runner source into a per-group writable location so agents + // can customize it (add tools, change behavior) without affecting other + // groups. Recompiled on container startup via entrypoint.sh. + const agentRunnerSrc = path.join( + projectRoot, + 'container', + 'agent-runner', + 'src', + ); + const groupAgentRunnerDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + 'agent-runner-src', + ); + if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } + mounts.push({ + hostPath: groupAgentRunnerDir, + containerPath: '/app/src', + readonly: false, + }); + + // Additional mounts validated against external allowlist (tamper-proof from containers) + if (group.containerConfig?.additionalMounts) { + const validatedMounts = validateAdditionalMounts( + group.containerConfig.additionalMounts, + group.name, + isMain, + ); + mounts.push(...validatedMounts); + } + + return mounts; +} + +/** + * Read allowed secrets from .env for passing to the container via stdin. + * Secrets are never written to disk or mounted as files. + */ +function readSecrets(): Record { + return readEnvFile([ + 'CLAUDE_CODE_OAUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + ]); +} + +function buildContainerArgs( + mounts: VolumeMount[], + containerName: string, +): string[] { + const args: string[] = ['run', '-i', '--rm', '--name', containerName]; + + // Pass host timezone so container's local time matches the user's + args.push('-e', `TZ=${TIMEZONE}`); + + // Run as host user so bind-mounted files are accessible. + // Skip when running as root (uid 0), as the container's node user (uid 1000), + // or when getuid is unavailable (native Windows without WSL). + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + for (const mount of mounts) { + if (mount.readonly) { + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); + } else { + args.push('-v', `${mount.hostPath}:${mount.containerPath}`); + } + } + + args.push(CONTAINER_IMAGE); + + return args; +} + +export async function runContainerAgent( + group: RegisteredGroup, + input: ContainerInput, + onProcess: (proc: ChildProcess, containerName: string) => void, + onOutput?: (output: ContainerOutput) => Promise, +): Promise { + const startTime = Date.now(); + + const groupDir = resolveGroupFolderPath(group.folder); + fs.mkdirSync(groupDir, { recursive: true }); + + const mounts = buildVolumeMounts(group, input.isMain); + const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); + const containerName = `nanoclaw-${safeName}-${Date.now()}`; + const containerArgs = buildContainerArgs(mounts, containerName); + + logger.debug( + { + group: group.name, + containerName, + mounts: mounts.map( + (m) => + `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, + ), + containerArgs: containerArgs.join(' '), + }, + 'Container mount configuration', + ); + + logger.info( + { + group: group.name, + containerName, + mountCount: mounts.length, + isMain: input.isMain, + }, + 'Spawning container agent', + ); + + const logsDir = path.join(groupDir, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + return new Promise((resolve) => { + const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + onProcess(container, containerName); + + let stdout = ''; + let stderr = ''; + let stdoutTruncated = false; + let stderrTruncated = false; + + // Pass secrets via stdin (never written to disk or mounted as files) + input.secrets = readSecrets(); + container.stdin.write(JSON.stringify(input)); + container.stdin.end(); + // Remove secrets from input so they don't appear in logs + delete input.secrets; + + // Streaming output: parse OUTPUT_START/END marker pairs as they arrive + let parseBuffer = ''; + let newSessionId: string | undefined; + let outputChain = Promise.resolve(); + + container.stdout.on('data', (data) => { + const chunk = data.toString(); + + // Always accumulate for logging + if (!stdoutTruncated) { + const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; + if (chunk.length > remaining) { + stdout += chunk.slice(0, remaining); + stdoutTruncated = true; + logger.warn( + { group: group.name, size: stdout.length }, + 'Container stdout truncated due to size limit', + ); + } else { + stdout += chunk; + } + } + + // Stream-parse for output markers + if (onOutput) { + parseBuffer += chunk; + let startIdx: number; + while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { + const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); + if (endIdx === -1) break; // Incomplete pair, wait for more data + + const jsonStr = parseBuffer + .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) + .trim(); + parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); + + try { + const parsed: ContainerOutput = JSON.parse(jsonStr); + if (parsed.newSessionId) { + newSessionId = parsed.newSessionId; + } + hadStreamingOutput = true; + // Activity detected — reset the hard timeout + resetTimeout(); + // Call onOutput for all markers (including null results) + // so idle timers start even for "silent" query completions. + outputChain = outputChain.then(() => onOutput(parsed)); + } catch (err) { + logger.warn( + { group: group.name, error: err }, + 'Failed to parse streamed output chunk', + ); + } + } + } + }); + + container.stderr.on('data', (data) => { + const chunk = data.toString(); + const lines = chunk.trim().split('\n'); + for (const line of lines) { + if (!line) continue; + // Surface Ollama MCP activity at info level for visibility + if (line.includes('[OLLAMA]')) { + logger.info({ container: group.folder }, line); + } else { + logger.debug({ container: group.folder }, line); + } + } + // Don't reset timeout on stderr — SDK writes debug logs continuously. + // Timeout only resets on actual output (OUTPUT_MARKER in stdout). + if (stderrTruncated) return; + const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; + if (chunk.length > remaining) { + stderr += chunk.slice(0, remaining); + stderrTruncated = true; + logger.warn( + { group: group.name, size: stderr.length }, + 'Container stderr truncated due to size limit', + ); + } else { + stderr += chunk; + } + }); + + let timedOut = false; + let hadStreamingOutput = false; + const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; + // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the + // graceful _close sentinel has time to trigger before the hard kill fires. + const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); + + const killOnTimeout = () => { + timedOut = true; + logger.error( + { group: group.name, containerName }, + 'Container timeout, stopping gracefully', + ); + exec(stopContainer(containerName), { timeout: 15000 }, (err) => { + if (err) { + logger.warn( + { group: group.name, containerName, err }, + 'Graceful stop failed, force killing', + ); + container.kill('SIGKILL'); + } + }); + }; + + let timeout = setTimeout(killOnTimeout, timeoutMs); + + // Reset the timeout whenever there's activity (streaming output) + const resetTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout(killOnTimeout, timeoutMs); + }; + + container.on('close', (code) => { + clearTimeout(timeout); + const duration = Date.now() - startTime; + + if (timedOut) { + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const timeoutLog = path.join(logsDir, `container-${ts}.log`); + fs.writeFileSync( + timeoutLog, + [ + `=== Container Run Log (TIMEOUT) ===`, + `Timestamp: ${new Date().toISOString()}`, + `Group: ${group.name}`, + `Container: ${containerName}`, + `Duration: ${duration}ms`, + `Exit Code: ${code}`, + `Had Streaming Output: ${hadStreamingOutput}`, + ].join('\n'), + ); + + // Timeout after output = idle cleanup, not failure. + // The agent already sent its response; this is just the + // container being reaped after the idle period expired. + if (hadStreamingOutput) { + logger.info( + { group: group.name, containerName, duration, code }, + 'Container timed out after output (idle cleanup)', + ); + outputChain.then(() => { + resolve({ + status: 'success', + result: null, + newSessionId, + }); + }); + return; + } + + logger.error( + { group: group.name, containerName, duration, code }, + 'Container timed out with no output', + ); + + resolve({ + status: 'error', + result: null, + error: `Container timed out after ${configTimeout}ms`, + }); + return; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logFile = path.join(logsDir, `container-${timestamp}.log`); + const isVerbose = + process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; + + const logLines = [ + `=== Container Run Log ===`, + `Timestamp: ${new Date().toISOString()}`, + `Group: ${group.name}`, + `IsMain: ${input.isMain}`, + `Duration: ${duration}ms`, + `Exit Code: ${code}`, + `Stdout Truncated: ${stdoutTruncated}`, + `Stderr Truncated: ${stderrTruncated}`, + ``, + ]; + + const isError = code !== 0; + + if (isVerbose || isError) { + logLines.push( + `=== Input ===`, + JSON.stringify(input, null, 2), + ``, + `=== Container Args ===`, + containerArgs.join(' '), + ``, + `=== Mounts ===`, + mounts + .map( + (m) => + `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, + ) + .join('\n'), + ``, + `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, + stderr, + ``, + `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, + stdout, + ); + } else { + logLines.push( + `=== Input Summary ===`, + `Prompt length: ${input.prompt.length} chars`, + `Session ID: ${input.sessionId || 'new'}`, + ``, + `=== Mounts ===`, + mounts + .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) + .join('\n'), + ``, + ); + } + + fs.writeFileSync(logFile, logLines.join('\n')); + logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); + + if (code !== 0) { + logger.error( + { + group: group.name, + code, + duration, + stderr, + stdout, + logFile, + }, + 'Container exited with error', + ); + + resolve({ + status: 'error', + result: null, + error: `Container exited with code ${code}: ${stderr.slice(-200)}`, + }); + return; + } + + // Streaming mode: wait for output chain to settle, return completion marker + if (onOutput) { + outputChain.then(() => { + logger.info( + { group: group.name, duration, newSessionId }, + 'Container completed (streaming mode)', + ); + resolve({ + status: 'success', + result: null, + newSessionId, + }); + }); + return; + } + + // Legacy mode: parse the last output marker pair from accumulated stdout + try { + // Extract JSON between sentinel markers for robust parsing + const startIdx = stdout.indexOf(OUTPUT_START_MARKER); + const endIdx = stdout.indexOf(OUTPUT_END_MARKER); + + let jsonLine: string; + if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { + jsonLine = stdout + .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) + .trim(); + } else { + // Fallback: last non-empty line (backwards compatibility) + const lines = stdout.trim().split('\n'); + jsonLine = lines[lines.length - 1]; + } + + const output: ContainerOutput = JSON.parse(jsonLine); + + logger.info( + { + group: group.name, + duration, + status: output.status, + hasResult: !!output.result, + }, + 'Container completed', + ); + + resolve(output); + } catch (err) { + logger.error( + { + group: group.name, + stdout, + stderr, + error: err, + }, + 'Failed to parse container output', + ); + + resolve({ + status: 'error', + result: null, + error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }); + + container.on('error', (err) => { + clearTimeout(timeout); + logger.error( + { group: group.name, containerName, error: err }, + 'Container spawn error', + ); + resolve({ + status: 'error', + result: null, + error: `Container spawn error: ${err.message}`, + }); + }); + }); +} + +export function writeTasksSnapshot( + groupFolder: string, + isMain: boolean, + tasks: Array<{ + id: string; + groupFolder: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string | null; + }>, +): void { + // Write filtered tasks to the group's IPC directory + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all tasks, others only see their own + const filteredTasks = isMain + ? tasks + : tasks.filter((t) => t.groupFolder === groupFolder); + + const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); + fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); +} + +export interface AvailableGroup { + jid: string; + name: string; + lastActivity: string; + isRegistered: boolean; +} + +/** + * Write available groups snapshot for the container to read. + * Only main group can see all available groups (for activation). + * Non-main groups only see their own registration status. + */ +export function writeGroupsSnapshot( + groupFolder: string, + isMain: boolean, + groups: AvailableGroup[], + registeredJids: Set, +): void { + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all groups; others see nothing (they can't activate groups) + const visibleGroups = isMain ? groups : []; + + const groupsFile = path.join(groupIpcDir, 'available_groups.json'); + fs.writeFileSync( + groupsFile, + JSON.stringify( + { + groups: visibleGroups, + lastSync: new Date().toISOString(), + }, + null, + 2, + ), + ); +} diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md new file mode 100644 index 0000000..498ac6c --- /dev/null +++ b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md @@ -0,0 +1,18 @@ +# Intent: src/container-runner.ts modifications + +## What changed +Surface Ollama MCP server log lines at info level so they appear in `nanoclaw.log` for the monitoring watcher script. + +## Key sections + +### container.stderr handler (inside runContainerAgent) +- Changed: empty line check from `if (line)` to `if (!line) continue;` +- Added: `[OLLAMA]` tag detection — lines containing `[OLLAMA]` are logged at `logger.info` instead of `logger.debug` +- All other stderr lines remain at `logger.debug` level + +## Invariants (must-keep) +- Stderr truncation logic unchanged +- Timeout reset logic unchanged (stderr doesn't reset timeout) +- Stdout parsing logic unchanged +- Volume mount logic unchanged +- All other container lifecycle unchanged