feat: skills as branches, channels as forks

Replace the custom skills engine with standard git operations.
Feature skills are now git branches (on upstream or channel forks)
applied via `git merge`. Channels are separate fork repos.

- Remove skills-engine/ (6,300+ lines), apply/uninstall/rebase scripts
- Remove old skill format (add/, modify/, manifest.yaml) from all skills
- Remove old CI (skill-drift.yml, skill-pr.yml)
- Add merge-forward CI for upstream skill branches
- Add fork notification (repository_dispatch to channel forks)
- Add marketplace config (.claude/settings.json)
- Add /update-skills operational skill
- Update /setup and /customize for marketplace plugin install
- Add docs/skills-as-branches.md architecture doc

Channel forks created: nanoclaw-whatsapp (with 5 skill branches),
nanoclaw-telegram, nanoclaw-discord, nanoclaw-slack, nanoclaw-gmail.

Upstream retains: skill/ollama-tool, skill/apple-container, skill/compact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-03-10 00:18:25 +02:00
parent e7852a45a5
commit 5118239cea
182 changed files with 1065 additions and 36205 deletions

View File

@@ -1,152 +0,0 @@
---
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: ..."

View File

@@ -1,147 +0,0 @@
/**
* 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<Response> {
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 <model>` 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<string, unknown> = {
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);

View File

@@ -1,41 +0,0 @@
#!/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

View File

@@ -1,17 +0,0 @@
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"

View File

@@ -1,593 +0,0 @@
/**
* 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<string, string>;
}
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<SDKUserMessage> {
while (true) {
while (this.queue.length > 0) {
yield this.queue.shift()!;
}
if (this.done) return;
await new Promise<void>(r => { this.waiting = r; });
this.waiting = null;
}
}
}
async function readStdin(): Promise<string> {
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<string, unknown>),
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<string | null> {
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<string, string | undefined>,
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<void> {
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<string, string | undefined> = { ...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();

View File

@@ -1,23 +0,0 @@
# 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

View File

@@ -1,708 +0,0 @@
/**
* 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<string, string>;
}
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<string, string> {
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<void>,
): Promise<ContainerOutput> {
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<string>,
): 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,
),
);
}

View File

@@ -1,18 +0,0 @@
# 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