diff --git a/container/CLAUDE.md b/container/CLAUDE.md new file mode 100644 index 0000000..c4428ff --- /dev/null +++ b/container/CLAUDE.md @@ -0,0 +1,166 @@ +# Main + +You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. + +## What You Can Do + +- Answer questions and have conversations +- Search the web and fetch content from URLs +- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open ` to start, then `agent-browser snapshot -i` to see interactive elements) +- Read and write files in your workspace +- Run bash commands in your sandbox +- Schedule tasks to run later or on a recurring basis +- Send messages back to the chat + +## Communication + +Be concise — every message costs the reader's attention. + +### Destinations + +Each turn, your system prompt lists the destinations available to you. If you only have one destination, just write your response directly — it goes there automatically. If you have multiple, wrap each message in a `...` block: + +``` +On my way home, 15 minutes +kick off the pipeline +``` + +Inbound messages are labeled with `from="name"` so you can tell which destination they came from and reply using that same name. + +### Mid-turn updates + +Use the `mcp__nanoclaw__send_message` tool to send a message mid-work (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work: + +- **Short work (a few seconds, ≤2 quick tool calls):** Don't narrate. Just do it and put the result in your final response. +- **Longer work (many tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it — checking the logs now") so the user knows you got the message. +- **Long-running work (many minutes, multi-step tasks):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages. + +**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call. + +**Outcomes, not play-by-play.** When the work is done, the final message should be about the result, not a transcript of what you did. + +### Internal thoughts + +Wrap reasoning in `...` tags to mark it as scratchpad — logged but not sent. With multiple destinations, any text outside of `` blocks is also treated as scratchpad. With a single destination, only explicit `` tags are scratchpad; the rest of your response is sent. + +``` +Compiled all three reports, ready to summarize. + +Here are the key findings from the research… +``` + +### Sub-agents and teammates + +When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent. + +## Your Workspace + +Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist. + +## Memory + +The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions. + +When you learn something important: +- Create files for structured data (e.g., `customers.md`, `preferences.md`) +- Split files larger than 500 lines into folders +- Keep an index in your memory for the files you create + +## Message Formatting + +Format messages based on the channel you're responding to. Check your group folder name: + +### Slack channels (folder starts with `slack_`) + +Use Slack mrkdwn syntax. Run `/slack-formatting` for the full reference. Key rules: +- `*bold*` (single asterisks) +- `_italic_` (underscores) +- `` for links (NOT `[text](url)`) +- `•` bullets (no numbered lists) +- `:emoji:` shortcodes +- `>` for block quotes +- No `##` headings — use `*Bold text*` instead + +### WhatsApp/Telegram channels (folder starts with `whatsapp_` or `telegram_`) + +- `*bold*` (single asterisks, NEVER **double**) +- `_italic_` (underscores) +- `•` bullet points +- ` ``` ` code blocks + +No `##` headings. No `[links](url)`. No `**double stars**`. + +### Discord channels (folder starts with `discord_`) + +Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`. + +--- + +## Installing Packages & Tools + +Your container is ephemeral — anything installed via `apt-get` or `pnpm install -g` is lost on restart. To install packages that persist, use the self-modification tools: + +1. **`install_packages`** — request system (apt) or global npm packages. Requires admin approval. +2. **`request_rebuild`** — rebuild your container image so approved packages are baked in. Always call this after `install_packages` to apply the changes. + +Example flow: +``` +install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" }) +# → Admin gets an approval card → approves +request_rebuild({ reason: "Apply ffmpeg + transformers" }) +# → Admin approves → image rebuilt with the packages +``` + +**When to use this vs workspace pnpm install:** +- `pnpm install` in `/workspace/agent/` persists on disk (it's mounted) but isn't on the global PATH — use it for project-level dependencies +- `install_packages` is for system tools (ffmpeg, imagemagick) and global npm packages that need to be on PATH + +### MCP Servers + +Use **`add_mcp_server`** to add an MCP server to your configuration, then **`request_rebuild`** to apply. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.: + +``` +add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] }) +request_rebuild({ reason: "Add memory MCP server" }) +``` + +## Task Scripts + +For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below. Other scheduling tools you might discover (e.g. `CronCreate`, `ScheduleWakeup`) are session-scoped SDK builtins and won't behave the way NanoClaw users expect, so stick with `schedule_task`. + +To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule — it preserves the series id the user already knows. + +Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency diff --git a/container/Dockerfile b/container/Dockerfile index be37638..f492f1c 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -3,8 +3,12 @@ # Runs Claude Agent SDK in isolated Linux VM with browser automation. # # Runtime split: -# - agent-runner (our TypeScript code): Bun +# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host # - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node +# +# Source is never baked in — /app/src is provided by a shared read-only +# bind mount at runtime (see src/container-runner.ts). Source-only changes +# never require an image rebuild. FROM node:22-slim @@ -66,43 +70,46 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ rm -rf /root/.bun -# ---- pnpm + global Node CLIs ------------------------------------------------- -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME:$PATH" -RUN corepack enable - -# agent-browser has a postinstall build script — pnpm skips these by default. -# Allowlist it via .npmrc so the install doesn't silently produce a broken -# package. Pinned versions so every rebuild is reproducible. -RUN --mount=type=cache,target=/root/.cache/pnpm \ - echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ - echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ - pnpm install -g \ - "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" - -# ---- agent-runner ------------------------------------------------------------ +# ---- agent-runner deps ------------------------------------------------------- +# Deps are cached independently of CLI versions. Source is NOT baked in — +# it's provided by the shared RO mount at runtime. WORKDIR /app -# Copy manifest + lockfile first so the install layer caches independently of -# source edits. COPY agent-runner/package.json agent-runner/bun.lock ./ RUN --mount=type=cache,target=/root/.bun/install/cache \ bun install --frozen-lockfile -# Source. Bun runs TS directly — no tsc build step. The host remounts this -# path at runtime via `src/container-runner.ts` so source edits on the host -# take effect without rebuilding the image; the baked copy is the fallback. -COPY agent-runner/ ./ +# ---- pnpm + global Node CLIs ------------------------------------------------- +# Most stable first, most frequently bumped last. Bumping claude-code +# (the most common change) only invalidates one layer. +# +# only-built-dependencies gates pnpm's supply-chain policy: +# - agent-browser has a postinstall build step. +# - @anthropic-ai/claude-code's postinstall downloads the native Claude +# binary (linux-arm64 variant on our image). Without the allowlist +# the SDK fails at spawn time with "native binary not found". +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \ + pnpm install -g "vercel@${VERCEL_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" + +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" # ---- Entrypoint -------------------------------------------------------------- COPY entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh # ---- Workspace + permissions ------------------------------------------------- -RUN mkdir -p /workspace/group /workspace/global /workspace/extra && \ +RUN mkdir -p /workspace/group /workspace/extra && \ chown -R node:node /workspace && \ chmod 755 /home/node diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts new file mode 100644 index 0000000..3a022ab --- /dev/null +++ b/container/agent-runner/src/config.ts @@ -0,0 +1,55 @@ +/** + * Runner config — reads /workspace/agent/container.json at startup. + * + * This file is mounted read-only inside the container. The host writes it; + * the runner only reads. All NanoClaw-specific configuration lives here + * instead of environment variables. + */ +import fs from 'fs'; + +const CONFIG_PATH = '/workspace/agent/container.json'; + +export interface RunnerConfig { + provider: string; + assistantName: string; + groupName: string; + agentGroupId: string; + maxMessagesPerPrompt: number; + mcpServers: Record }>; +} + +const DEFAULT_MAX_MESSAGES = 10; + +let _config: RunnerConfig | null = null; + +/** + * Load config from container.json. Called once at startup. + * Falls back to sensible defaults for any missing field. + */ +export function loadConfig(): RunnerConfig { + if (_config) return _config; + + let raw: Record = {}; + try { + raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); + } catch { + console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`); + } + + _config = { + provider: (raw.provider as string) || 'claude', + assistantName: (raw.assistantName as string) || '', + groupName: (raw.groupName as string) || '', + agentGroupId: (raw.agentGroupId as string) || '', + maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES, + mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {}, + }; + + return _config; +} + +/** Get the loaded config. Throws if loadConfig() hasn't been called. */ +export function getConfig(): RunnerConfig { + if (!_config) throw new Error('Config not loaded — call loadConfig() first'); + return _config; +} diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3c0fffd..3f0e73b 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -31,8 +31,7 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; /** Inbound DB — container opens read-only (host is the sole writer). */ export function getInboundDb(): Database { if (!_inbound) { - const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH; - _inbound = new Database(dbPath, { readonly: true }); + _inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); _inbound.exec('PRAGMA busy_timeout = 5000'); } return _inbound; @@ -41,8 +40,7 @@ export function getInboundDb(): Database { /** Outbound DB — container owns this file (sole writer). */ export function getOutboundDb(): Database { if (!_outbound) { - const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH; - _outbound = new Database(dbPath); + _outbound = new Database(DEFAULT_OUTBOUND_PATH); _outbound.exec('PRAGMA journal_mode = DELETE'); _outbound.exec('PRAGMA busy_timeout = 5000'); _outbound.exec('PRAGMA foreign_keys = ON'); @@ -122,7 +120,7 @@ export function clearContainerToolInFlight(): void { * A file touch is cheaper and avoids cross-boundary DB write contention. */ export function touchHeartbeat(): void { - const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath; + const p = _heartbeatPath; const now = new Date(); try { fs.utimesSync(p, now, now); diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index a152a5e..4ecf818 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -7,6 +7,7 @@ * The container never writes to inbound.db — all status tracking goes through * processing_ack. The host reads processing_ack to sync message lifecycle. */ +import { getConfig } from '../config.js'; import { getInboundDb, getOutboundDb } from './connection.js'; export interface MessageInRow { @@ -26,14 +27,16 @@ export interface MessageInRow { content: string; } -// Cap on how many messages reach the agent in one prompt, including any -// accumulated-but-not-triggered context. Host controls the cap via the -// NANOCLAW_MAX_MESSAGES_PER_PROMPT env var; default mirrors the host's -// config.ts default of 10. -const MAX_MESSAGES_PER_PROMPT = Math.max( - 1, - parseInt(process.env.NANOCLAW_MAX_MESSAGES_PER_PROMPT || '10', 10) || 10, -); +// Cap on how many messages reach the agent in one prompt. Read from +// container.json; falls back to 10. +function getMaxMessagesPerPrompt(): number { + try { + return getConfig().maxMessagesPerPrompt; + } catch { + // Config not loaded yet (e.g. test harness) — use default + return 10; + } +} /** * Fetch pending messages that are due for processing. @@ -58,7 +61,7 @@ export function getPendingMessages(): MessageInRow[] { ORDER BY seq DESC LIMIT ?`, ) - .all(MAX_MESSAGES_PER_PROMPT) as MessageInRow[]; + .all(getMaxMessagesPerPrompt()) as MessageInRow[]; if (pending.length === 0) return []; diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 2e90720..c0475b2 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -55,6 +55,17 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo { return { category: 'passthrough', command, text, senderId }; } +/** + * Narrow check for /clear — the only command the runner handles directly. + * All other command gating (filtered, admin) is done by the host router + * before messages reach the container. + */ +export function isClearCommand(msg: MessageInRow): boolean { + const content = parseContent(msg.content); + const text = (content.text || '').trim(); + return text.toLowerCase().startsWith('/clear'); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractSenderId(msg: MessageInRow, content: any): string | null { const raw: string | null = content?.senderId || content?.author?.userId || null; diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index a0b0dc8..9e68968 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -4,14 +4,8 @@ * Runs inside a container. All IO goes through the session DB. * No stdin, no stdout markers, no IPC files. * - * Config: - * - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db) - * - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db) - * - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat) - * - AGENT_PROVIDER: any registered provider name (default: claude). The - * set of registered providers is whatever `providers/index.ts` imports. - * - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving - * - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands + * Config is read from /workspace/agent/container.json (mounted RO). + * Only TZ and OneCLI networking vars come from env. * * Mount structure: * /workspace/ @@ -19,14 +13,19 @@ * outbound.db ← container-owned session DB * .heartbeat ← container touches for liveness detection * outbox/ ← outbound files - * agent/ ← agent group folder (CLAUDE.md, skills, working files) - * .claude/ ← Claude SDK session data + * agent/ ← agent group folder (CLAUDE.md, container.json, working files) + * container.json ← per-group config (RO nested mount) + * global/ ← shared global memory (RO) + * /app/src/ ← shared agent-runner source (RO) + * /app/skills/ ← shared skills (RO) + * /home/node/.claude/ ← Claude SDK state + skill symlinks (RW) */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; +import { loadConfig } from './config.js'; import { buildSystemPromptAddendum } from './destinations.js'; // Providers barrel — each enabled provider self-registers on import. // Provider skills append imports to providers/index.ts. @@ -41,21 +40,16 @@ function log(msg: string): void { const CWD = '/workspace/agent'; async function main(): Promise { - const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName; - const assistantName = process.env.NANOCLAW_ASSISTANT_NAME; - const adminUserIds = new Set( - (process.env.NANOCLAW_ADMIN_USER_IDS || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - ); + const config = loadConfig(); + const providerName = config.provider.toLowerCase() as ProviderName; log(`Starting v2 agent-runner (provider: ${providerName})`); // Destinations addendum is the only runtime-generated context we inject. - // Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md - // (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to - // read it manually anymore. + // Agent instructions are loaded by Claude Code from /workspace/agent/CLAUDE.md + // (host-composed at spawn, imports /app/CLAUDE.md and fragments) plus + // /workspace/agent/CLAUDE.local.md (agent memory) — no need to read them + // manually. const instructions = buildSystemPromptAddendum(); // Discover additional directories mounted at /workspace/extra/* @@ -77,34 +71,22 @@ async function main(): Promise { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts'); - // Build MCP servers config: nanoclaw built-in + any additional from host + // Build MCP servers config: nanoclaw built-in + any from container.json const mcpServers: Record }> = { nanoclaw: { command: 'bun', args: ['run', mcpServerPath], - env: { - SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db', - SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db', - SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat', - }, + env: {}, }, }; - // Merge additional MCP servers from host configuration - if (process.env.NANOCLAW_MCP_SERVERS) { - try { - const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record }>; - for (const [name, config] of Object.entries(additional)) { - mcpServers[name] = config; - log(`Additional MCP server: ${name} (${config.command})`); - } - } catch (e) { - log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`); - } + for (const [name, serverConfig] of Object.entries(config.mcpServers)) { + mcpServers[name] = serverConfig; + log(`Additional MCP server: ${name} (${serverConfig.command})`); } const provider = createProvider(providerName, { - assistantName, + assistantName: config.assistantName || undefined, mcpServers, env: { ...process.env }, additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, @@ -114,7 +96,6 @@ async function main(): Promise { provider, cwd: CWD, systemContext: { instructions }, - adminUserIds, }); } diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 119b1d4..d93bdd3 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -3,7 +3,7 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } import { writeMessageOut } from './db/messages-out.js'; import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js'; import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js'; -import { formatMessages, extractRouting, categorizeMessage, stripInternalTags, type RoutingContext } from './formatter.js'; +import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -23,12 +23,6 @@ export interface PollLoopConfig { systemContext?: { instructions?: string; }; - /** - * Set of user IDs allowed to run admin commands (e.g. /clear) in this - * agent group. Host populates from owners + global admins + scoped admins - * at container wake time, so role changes take effect on next spawn. - */ - adminUserIds?: Set; } /** @@ -90,74 +84,36 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const routing = extractRouting(messages); - // Handle commands: categorize chat messages - const adminUserIds = config.adminUserIds ?? new Set(); - const normalMessages = []; + // Command handling: the host router gates filtered and unauthorized + // admin commands before they reach the container. The only command + // the runner handles directly is /clear (session reset). + const normalMessages: MessageInRow[] = []; const commandIds: string[] = []; for (const msg of messages) { - if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') { - normalMessages.push(msg); - continue; - } - - const cmdInfo = categorizeMessage(msg); - - if (cmdInfo.category === 'filtered') { - // Silently drop — mark completed, don't process - log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`); + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) { + log('Clearing session (resetting continuation)'); + continuation = undefined; + clearStoredSessionId(); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: 'Session cleared.' }), + }); commandIds.push(msg.id); continue; } - - if (cmdInfo.category === 'admin') { - if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) { - log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }), - }); - commandIds.push(msg.id); - continue; - } - // Handle admin commands directly - if (cmdInfo.command === '/clear') { - log('Clearing session (resetting continuation)'); - continuation = undefined; - clearStoredSessionId(); - writeMessageOut({ - id: generateId(), - kind: 'chat', - platform_id: routing.platformId, - channel_type: routing.channelType, - thread_id: routing.threadId, - content: JSON.stringify({ text: 'Session cleared.' }), - }); - commandIds.push(msg.id); - continue; - } - - // Other admin commands — pass through to agent - normalMessages.push(msg); - continue; - } - - // passthrough or none normalMessages.push(msg); } - // Mark filtered/denied command messages as completed immediately if (commandIds.length > 0) { markCompleted(commandIds); } - // If all messages were filtered commands, skip processing if (normalMessages.length === 0) { - // Mark remaining processing IDs as completed const remainingIds = ids.filter((id) => !commandIds.includes(id)); if (remainingIds.length > 0) markCompleted(remainingIds); log(`All ${messages.length} message(s) were commands, skipping query`); @@ -204,7 +160,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise { const skippedSet = new Set(skipped); const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id)); try { - const result = await processQuery(query, routing); + const result = await processQuery(query, routing, processingIds); if (result.continuation && result.continuation !== continuation) { continuation = result.continuation; setStoredSessionId(continuation); @@ -233,6 +189,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise { }); } + // Ensure completed even if processQuery ended without a result event + // (e.g. stream closed unexpectedly). markCompleted(processingIds); log(`Completed ${ids.length} message(s)`); } @@ -276,7 +234,11 @@ interface QueryResult { continuation?: string; } -async function processQuery(query: AgentQuery, routing: RoutingContext): Promise { +async function processQuery( + query: AgentQuery, + routing: RoutingContext, + initialBatchIds: string[], +): Promise { let queryContinuation: string | undefined; let done = false; @@ -289,18 +251,16 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise const pollHandle = setInterval(() => { if (done) return; - // Skip system messages (MCP tool responses) and admin commands (need fresh query). - // Also defer messages whose thread_id differs from the active turn's routing - // — mixing threads into one streaming turn would send the reply to the wrong - // thread because `routing` is captured at turn start. The next turn will pick - // them up with fresh routing. + // Skip system messages (MCP tool responses) and /clear (needs fresh query). + // Thread routing is the router's concern — if a message landed in this + // session, the agent should see it. Per-thread sessions already isolate + // threads into separate containers; shared sessions intentionally merge + // everything. Filtering on thread_id here caused deadlocks when the + // initial batch and follow-ups had mismatched thread_ids (e.g. a + // host-generated welcome trigger with null thread vs a Discord DM reply). const newMessages = getPendingMessages().filter((m) => { if (m.kind === 'system') return false; - if (m.kind === 'chat' || m.kind === 'chat-sdk') { - const cmd = categorizeMessage(m); - if (cmd.category === 'admin') return false; - } - if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false; + if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false; return true; }); if (newMessages.length > 0) { @@ -329,8 +289,17 @@ async function processQuery(query: AgentQuery, routing: RoutingContext): Promise // effectively orphaned and the next message started a blank // Claude session with no prior context. setStoredSessionId(event.continuation); - } else if (event.type === 'result' && event.text) { - dispatchResultText(event.text, routing); + } else if (event.type === 'result') { + // A result — with or without text — means the turn is done. Mark + // the initial batch completed now so the host sweep doesn't see + // stale 'processing' claims while the query stays open for + // follow-up pushes. The agent may have responded via MCP + // (send_message) mid-turn, or the message may not need a response + // at all — either way the turn is finished. + markCompleted(initialBatchIds); + if (event.text) { + dispatchResultText(event.text, routing); + } } } } finally { diff --git a/docs/claude-md-composition.md b/docs/claude-md-composition.md new file mode 100644 index 0000000..b3ce08f --- /dev/null +++ b/docs/claude-md-composition.md @@ -0,0 +1,146 @@ +# CLAUDE.md Composition + +Compose agent instructions from a shared base, skill/tool fragments, and per-group memory — replacing the current per-group CLAUDE.md with a host-regenerated entry point. + +## Problem + +Today each agent group has a single RW `groups//CLAUDE.md`, written once at init and never updated. Consequences: + +- Upstream improvements to shared agent guidance don't propagate to existing groups +- No way to ship tool-specific guidance with the tool itself (e.g., an agent-browser usage fragment) +- Human-authored identity and agent-accumulated memory live in the same file with no separation +- The `.claude-global.md` symlink + `groups/global/CLAUDE.md` pattern handled the shared base but not per-module fragments + +## Design + +**Principle: RW = per-group memory, RO = shared content.** Same rule that governs the shared-source refactor, applied to agent instructions. + +### Three tiers + +| Tier | File | Location | Mount | Editor | Change rate | +|---|---|---|---|---|---| +| **Shared base** | `CLAUDE.md` | `container/CLAUDE.md` | RO at `/app/CLAUDE.md` | Owner (via git) | Rare | +| **Module fragments** | `instructions.md` | Inside each module | RO via shared skills mount, or inline in `container.json` | Module author | Ships with module | +| **Per-group memory** | `CLAUDE.local.md` | `groups//` | RW at `/workspace/agent/` | Agent + owner | Continuous | +| **Composed entry** | `CLAUDE.md` | `groups//` | RW but host-regenerated | **Host, not human** | Every spawn | + +### Composition + +At every spawn, the host regenerates `groups//CLAUDE.md` as an import-only file: + +```markdown + +@./.claude-shared.md +@./.claude-fragments/welcome.md +@./.claude-fragments/agent-browser.md +@./.claude-fragments/.md +@./.claude-fragments/mcp-.md +``` + +Symlinks are created alongside, following the `.claude-global.md` pattern (dangling on host, valid in container via the RO mount): + +- `groups//.claude-shared.md` → `/app/CLAUDE.md` +- `groups//.claude-fragments/.md` → `/app/skills//instructions.md` (for each enabled skill that ships a fragment) + +Claude Code auto-loads `CLAUDE.local.md` from cwd without an import line — native behavior. Agent memory works natively; composition only wraps around it. + +### Module fragment contract + +**Skills.** A skill optionally ships an `instructions.md` at the top of its directory: + +``` +container/skills/welcome/ + SKILL.md — description + when-to-use (existing) + instructions.md — always-in-context guidance (optional, new) +``` + +When the skill is enabled for a group, the host imports `instructions.md` into the composed CLAUDE.md. `SKILL.md` semantics are unchanged — Claude Code still uses it for skill discovery and on-demand invocation. Most skills won't need an `instructions.md` (SKILL.md is sufficient for on-demand skills); it's only for guidance that should be in context at all times. + +**MCP servers.** A `container.json` MCP server entry can contribute a fragment inline: + +```jsonc +{ + "mcpServers": { + "my-db": { + "command": "...", + "instructions": "Read-only access to the production DB. Never run UPDATE/DELETE without admin approval." + } + } +} +``` + +Host writes the inline content to `.claude-fragments/mcp-.md` at spawn and imports it. + +**Global CLIs baked into the image** (agent-browser, vercel, claude-code) have always-present guidance; it belongs in `container/CLAUDE.md`, not as a conditional fragment. Don't try to make universally-present tools dynamic. + +### Identity vs memory + +All per-group content — human-authored identity ("you are the research agent, be terse") and agent-accumulated memory (inventories, user preferences, learned patterns) — lives in a single `CLAUDE.local.md`. Both humans and agents can edit it. + +If the distinction becomes operationally important later (agents confused about what they were told vs. what they learned), split into `identity.md` (human-authored, imported into composed CLAUDE.md) + `CLAUDE.local.md` (agent memory only). Starting with one file. + +## Changes + +### `container/CLAUDE.md` (new) + +Write the shared base: general NanoClaw context, how to engage with users, output conventions, anything that should apply to every agent across every group. Seed from current `groups/global/CLAUDE.md`. + +### `container/skills//instructions.md` (optional, per skill) + +Add for any skill that warrants always-in-context guidance. Optional. + +### `container.json` schema + +Add optional `instructions` field (string) to each MCP server entry. + +### `container-runner.ts` spawn-time sync + +Extend the skill-symlink sync function (added in the shared-source refactor) to also compose CLAUDE.md. On every spawn: + +1. Sync `.claude-shared/skills/` symlinks from `container.json` skill selection. +2. Sync `.claude-shared.md` symlink → `/app/CLAUDE.md`. +3. For each enabled skill with an `instructions.md`, create `.claude-fragments/.md` symlink → `/app/skills//instructions.md`. +4. For each `container.json` MCP server with an `instructions` field, write the inline content to `.claude-fragments/mcp-.md`. +5. Write `groups//CLAUDE.md` atomically (temp + rename) with import lines in a deterministic order: shared base → skill fragments (alphabetical) → MCP fragments (alphabetical). +6. Remove stale symlinks and fragment files for modules no longer enabled. + +### `group-init.ts` + +- Stop writing an initial `groups//CLAUDE.md` at group creation — host regenerates at first spawn. +- Stop creating the `.claude-global.md` symlink — replaced by `.claude-shared.md` in the composition step. +- Optionally create an empty `groups//CLAUDE.local.md` at init as a clear affordance for humans and agents. + +### `groups/global/` + +Eliminate. The shared base moves to `container/CLAUDE.md`. Any deployment-specific overrides live in the owner's customized `container/CLAUDE.md` (same pattern as any other codebase customization). + +## Migration + +Breaking change, one-time cutover: + +- For every group, rename `groups//CLAUDE.md` → `groups//CLAUDE.local.md`. Preserves all existing per-group content as memory. +- Move content from `groups/global/CLAUDE.md` (beyond the default stub) into `container/CLAUDE.md`. Delete `groups/global/`. +- Delete stale `.claude-global.md` symlinks in each group dir — the spawn pass creates `.claude-shared.md` instead. +- First spawn after cutover regenerates `CLAUDE.md` with proper imports. + +## Interaction with shared-source refactor + +This refactor depends on the shared skills mount (`/app/skills/` RO) from the shared-source refactor landing first. It extends the spawn-time sync from "just skill symlinks" to "skill symlinks + CLAUDE.md composition" — both passes share the same helper. + +After this refactor, the "Personality / instructions" row in the shared-source per-group customization table splits: + +| Resource | Location | Mechanism | +|----------|----------|-----------| +| Agent memory | `groups//CLAUDE.local.md` | RW at `/workspace/agent/`, auto-loaded by Claude Code | +| Composed entry | `groups//CLAUDE.md` | Host-regenerated at every spawn | + +## What triggers what + +| Change | Action | Scope | +|--------|--------|-------| +| Edit `container/CLAUDE.md` | Kill running containers (next spawn recomposes) | All groups | +| Add/edit a skill's `instructions.md` | Kill running containers | All groups with the skill enabled | +| Enable/disable a skill in `container.json` | Kill that group's containers | One group | +| Add MCP server with `instructions` field | Kill that group's containers | One group | +| Edit `CLAUDE.local.md` | Nothing — live via RW mount; Claude Code re-reads at next prompt | One group | +| Add a new agent group | Spawn writes `CLAUDE.md` fresh from the composition pass | One group | diff --git a/docs/shared-source.md b/docs/shared-source.md new file mode 100644 index 0000000..ab725ea --- /dev/null +++ b/docs/shared-source.md @@ -0,0 +1,270 @@ +# Shared Source + +Replace per-group agent-runner-src copies with a single shared read-only mount. + +## Problem + +Each agent group gets a full copy of `container/agent-runner/src/` at creation time. This copy is mounted RW at `/app/src` in the container. Consequences: + +- Bug fixes and features don't propagate to existing groups +- Owner edits to `container/agent-runner/src/` silently don't apply to existing groups +- No tooling to diff or detect drift between groups and upstream +- The RW mount lets agents write to their own runtime source without approval +- Cross-cutting changes (host + container) break down when container code is per-group +- Skills have the same copy-and-drift problem + +## Design + +**Principle: RW is per-group, RO is shared.** Every mount is either read-only and shared across all groups, or read-write and scoped to one group. Source and skills become RO + shared. Personality, config, working files, and Claude state stay RW + per-group. This makes drift impossible by construction — no group can diverge from shared code because no group has write access to it. + +### Shared source mount + +Mount `container/agent-runner/src/` into all containers at `/app/src` as **read-only**. + +``` +container/agent-runner/src/ → /app/src (RO, shared) +``` + +Source is never baked into the image. `/app/src/` exists only via this mount — running without it is an intentional startup failure (entrypoint `bun run /app/src/index.ts` → ENOENT). Source-only changes never trigger image rebuilds; edits to `.ts` files take effect on next container spawn. + +Image rebuilds are only needed for: +- Agent-runner npm dependency changes (`package.json` / `bun.lock`) +- System packages, runtime versions, global CLI version bumps +- Dockerfile/entrypoint changes + +### Shared skills mount + +Mount `container/skills/` into all containers at `/app/skills/` as **read-only**. + +Per-group skill selection via `container.json`: + +```jsonc +{ + "skills": ["welcome", "agent-browser", "self-customize"] + // or "skills": "all" (default) +} +``` + +At every spawn, the host syncs symlinks in the group's `.claude-shared/skills/` directory to match the selected set. For `"all"`, the set is recomputed from the shared skills dir on each spawn — newly-added upstream skills appear without intervention. Symlinks for skills no longer in the set are removed. + +Each symlink points to a container path: + +``` +.claude-shared/skills/welcome → /app/skills/welcome +.claude-shared/skills/agent-browser → /app/skills/agent-browser +``` + +Claude Code scans `/home/node/.claude/skills/`, follows the symlinks, loads the selected skills. Same dangling-symlink-on-host pattern as `.claude-global.md` — host tools don't resolve the target, the container mount makes it valid at read time. + +### Per-group customization surface + +What remains per-group (unchanged): + +| Resource | Location | Mechanism | +|----------|----------|-----------| +| Personality / instructions | `groups//CLAUDE.md` | Mount at `/workspace/agent` (RW, live) | +| MCP servers | `groups//container.json` | Env var at spawn | +| apt/npm packages | `groups//container.json` | Per-group image layer | +| Skill selection | `groups//container.json` | Symlinks at spawn | +| Additional mounts | `groups//container.json` | Validated bind mounts | +| Agent provider / model | `groups//container.json` | Read by runner at startup | +| Claude Code settings | `.claude-shared/settings.json` | Mount at `/home/node/.claude` (RW) | +| Working files | `groups//` | Mount at `/workspace/agent` (RW) | + +### Self-modification + +Existing config-level self-mod tools (`install_packages`, `add_mcp_server`, `request_rebuild`) mutate `container.json` and per-group images, not source. Unchanged — stays per-group. + +Source-level self-modification (not yet implemented) uses staging: edits happen against a copy of `container/agent-runner/src/`, reviewed and swapped in on approval. Owner can also edit source directly. + +## Environment variables + +Env is for things read by code we don't own: glibc, Node's http agent, CLIs we shell out to. Everything NanoClaw-specific moves out of env. + +**Stays in env (read by non-nanoclaw code):** + +| Var | Reader | +|---|---| +| `TZ` | glibc, child processes | +| `HTTPS_PROXY`, `NO_PROXY` | Node http agent, curl, git, etc. (OneCLI-injected) | +| `NODE_EXTRA_CA_CERTS` | Node at startup (OneCLI-injected) | + +**Moves to `container.json` (read by runner at startup):** + +| Var | Reason | +|---|---| +| `AGENT_PROVIDER` | Per-group config; runner reads before importing provider module | +| `NANOCLAW_AGENT_GROUP_NAME` | Per-group identity | +| `NANOCLAW_ASSISTANT_NAME` | Per-group identity | +| `NANOCLAW_MAX_MESSAGES_PER_PROMPT` | Config constant; per-group override possible | + +**Deleted (admin gating moves to router):** + +`NANOCLAW_ADMIN_USER_IDS` is removed entirely — not moved to a new location. The container no longer makes authorization decisions. See **Router command gate** below. + +**Hardcoded as conventions:** + +| Var | Convention | +|---|---| +| `SESSION_INBOUND_DB_PATH` | `/workspace/inbound.db` | +| `SESSION_OUTBOUND_DB_PATH` | `/workspace/outbound.db` | +| `SESSION_HEARTBEAT_PATH` | `/workspace/.heartbeat` | +| `NANOCLAW_AGENT_GROUP_ID` | Read from `/workspace/agent/container.json` at startup | + +### Runner startup order + +The runner can no longer assume DB paths or provider identity are handed to it in env. Revised startup: + +1. Set up logging. +2. Read `/workspace/agent/container.json` (mounted RW but read-only here). +3. Open `/workspace/inbound.db` and `/workspace/outbound.db` (fixed paths). +4. Read bootstrap tables from `inbound.db` (destinations). +5. Import the provider module selected by `container.json`. +6. Enter the poll loop. + +### Router command gate + +The host router gates slash commands before writing to `messages_in`. The container still handles whatever reaches it; it just stops making authorization decisions. + +1. **Filtered commands** (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. Never reach the container. +2. **Admin commands** (`/clear`, `/compact`, `/context`, `/cost`, `/files`) → check sender against `user_roles` (owners + global admins + admins scoped to this agent group). + - Denied: write "Permission denied: `` requires admin access." directly to `messages_out` in the same thread. Do not write to `messages_in`. + - Allowed: pass through to container unchanged. +3. **Normal messages** → pass through unchanged. + +Admin commands that flow through continue to be handled the same way they are today: +- `/clear` — container's existing handler in `poll-loop.ts` resets session continuation and writes "Session cleared." +- `/compact`, `/context`, `/cost`, `/files` — container forwards them to Claude Code's native slash-command handler. + +Container receives only authorized messages. The runner has no admin concept, no `adminUserIds` field, no admin-gate branch — but it still recognizes `/clear` to reset session state. + +### Scope rules + +Each channel answers a single scope question: + +| Channel | Scope | What it holds | +|---|---|---| +| Env vars | Process | Things read by code we don't own (`TZ`, `HTTPS_PROXY`) | +| `container.json` | Per-group | Per-group config (MCP, packages, provider, model, skills, mounts) | +| `inbound.db` / `outbound.db` | Per-session | Messages, session state, and host-projected views of cross-group state (destinations) | +| Central DB (`data/v2.db`) | Cross-group | Users, roles, wiring, messaging groups, sessions | + +The runner reads from env (for external-convention vars), `container.json` (for its own group's config), and `inbound.db` (for messages + projected views). It never reads central DB directly — that's always host-projected through inbound.db first. + +After this change, the spawn-time `-e` flags shrink from ~10 to ~3-5 (TZ + OneCLI networking). No `NANOCLAW_*` env var survives. + +## Image layer strategy + +Single Dockerfile with aggressive layer ordering: stable layers first, frequently-bumped layers last. BuildKit's layer cache handles "upstream layers unchanged" rebuilds efficiently — a separate base image isn't justified. + +Two image tags exist at runtime: + +``` +nanoclaw-agent:latest — shared base (rebuild: dep/CLI bumps + Dockerfile changes) + └── nanoclaw-agent: — per-group apt/npm packages (rebuild: per-group via install_packages) +``` + +Layer order within the base: + +```dockerfile +FROM node:22-slim + +# System deps (apt) — rarely change +RUN apt-get install ... + +# Bun — pinned version, rarely changes +RUN ... bun + +# Agent-runner deps — cached independently of CLI versions +COPY agent-runner/package.json agent-runner/bun.lock /app/ +RUN cd /app && bun install --frozen-lockfile + +# Global CLIs — most stable first, most frequently bumped last +RUN pnpm install -g "vercel@${VERCEL_VERSION}" +RUN pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}" +RUN pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" +``` + +Bumping claude-code (the most common change) only rebuilds one layer. Agent-runner deps and other CLIs stay cached. + +Source is never baked into the image — always provided by the shared RO mount at runtime. + +### Agent-triggered version bumps + +Agents can request a claude-code version bump via a new self-mod tool (`bump_claude_code`). Same fire-and-forget pattern as `install_packages`: agent requests → owner approves → host rebuilds base image → kill all running containers. Unlike `install_packages` (per-group image), this rebuilds the shared base image and affects all groups. + +## Changes + +### `group-init.ts` + +- Remove the `agent-runner-src` copy block (lines 109–117) +- Remove the `skills/` copy block (lines 100–107) +- Skill symlinks are no longer created at init — sync is spawn-owned (see `container-runner.ts`) + +### `container-runner.ts` `buildMounts()` + +- Remove per-group `agent-runner-src` mount (lines 206–209) +- Add shared RO mount: `container/agent-runner/src/` → `/app/src` +- Add shared RO mount: `container/skills/` → `/app/skills` +- Sync skill symlinks in `.claude-shared/skills/` at spawn: write desired set from `container.json` (`"all"` = every skill in the shared dir, recomputed per spawn), remove symlinks not in the set + +### `container-runner.ts` `buildContainerArgs()` + +- Remove `-e SESSION_INBOUND_DB_PATH`, `-e SESSION_OUTBOUND_DB_PATH`, `-e SESSION_HEARTBEAT_PATH` (hardcoded conventions now) +- Remove `-e AGENT_PROVIDER` (moves to `container.json`) +- Remove `-e NANOCLAW_ASSISTANT_NAME`, `-e NANOCLAW_AGENT_GROUP_ID`, `-e NANOCLAW_AGENT_GROUP_NAME` +- Remove `-e NANOCLAW_MAX_MESSAGES_PER_PROMPT` +- Remove the `user_roles` join + `-e NANOCLAW_ADMIN_USER_IDS` block (lines 269–287) entirely. Admin gating moves to the router — no admin data passed to the container. +- Keep: `-e TZ`, OneCLI-contributed env (`HTTPS_PROXY`, `NODE_EXTRA_CA_CERTS`, `NO_PROXY`) + +### `router.ts` (new command gate) + +- Classify inbound slash commands before writing to `messages_in`: filtered / admin / normal. +- Filtered (`/help`, `/login`, `/logout`, `/doctor`, `/config`, `/start`, `/remote-control`) → drop silently. +- Admin commands (`/clear`, `/compact`, `/context`, `/cost`, `/files`) from non-admins → write "Permission denied" directly to `messages_out`, skip `messages_in`. +- All authorized messages (admin commands from admins, and normal messages) → pass through unchanged to `messages_in`. Container handles them as today. +- The `ADMIN_COMMANDS` and `FILTERED_COMMANDS` lists move from `container/agent-runner/src/formatter.ts` to a host-side module. + +### `container/agent-runner/src/` (runner) + +- New `config.ts` module: loads `/workspace/agent/container.json` at startup, exposes a typed config singleton. All previous `process.env.NANOCLAW_*` reads go through this. +- `db/connection.ts`: use hardcoded paths `/workspace/inbound.db` and `/workspace/outbound.db`; drop `SESSION_*_DB_PATH` lookups. +- `formatter.ts`: remove `ADMIN_COMMANDS`, `FILTERED_COMMANDS`, and the `filtered` / admin-gate categorization. Keep enough to recognize `/clear` so `poll-loop.ts` can route it (e.g., a narrow `isClearCommand(msg)` helper). +- `poll-loop.ts`: remove `adminUserIds` field from config type and the admin-gate branch (lines 113–126). Keep the `/clear` handler (lines 128–142) — `/clear` still flows through from the router. +- Provider selection (`providers/index.ts` or equivalent): read provider from config singleton, not env. + +### `container-config.ts` + +- Add `skills` field to `ContainerConfig` (`string[] | "all"`, default `"all"`) +- Add fields: `provider`, `groupName`, `assistantName`, `maxMessagesPerPrompt` (optional, falls back to code default) + +### `.env` / `.env.example` + +- Remove any `NANOCLAW_*` entries that were documented as tunables. Update `.env.example` to list only TZ and OneCLI-related vars as valid overrides. + +### DB migration + +- Drop `agent_groups.agent_provider` column and `sessions.agent_provider` column. Source of truth becomes `container.json.provider`. +- One-time data migration reads existing values and writes them to each group's `container.json`. Sessions lose any per-session provider override — provider is a per-group property now. + +### Migration + +**This is a breaking change.** Host restart kills all running containers. No gradual rollout. Any code referencing dropped columns or removed env vars must be updated before the migration runs. + +- Provider install skills (`/add-opencode`, `/add-ollama-tool`) now write to the shared `container/agent-runner/src/providers/` tree. The per-group `providers/` overlay pattern is removed. Any uncommitted provider overlays must be upstreamed before cutover. +- Delete existing `data/v2-sessions//agent-runner-src/` directories on first run after cutover. +- Existing `.claude-shared/skills/` directories get replaced with symlinks on next spawn. +- DB migration (see above) reads `agent_provider` columns and projects into `container.json`, then drops the columns. + +## What triggers what + +| Change | Action needed | Scope | +|--------|--------------|-------| +| Agent-runner `.ts` source | Kill running containers | All groups | +| Agent-runner npm deps | Rebuild `nanoclaw-agent` + kill all | All groups | +| System deps, Bun, Node | Rebuild `nanoclaw-agent` + kill all | All groups | +| Claude-code version bump | Rebuild `nanoclaw-agent` + kill all | All groups (agent-triggerable) | +| Skill content | Kill running containers | All groups | +| Per-group apt/npm packages | `buildAgentGroupImage()` + kill | One group | +| Per-group config (MCP, mounts, provider, model, skills) | Kill that group's containers | One group | +| CLAUDE.md, working files | Nothing (live via RW mount) | One group | diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index b3d7bd0..dcb99b5 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -363,7 +363,7 @@ async function sendWelcomeViaCliSocket( to: { channelType: dmMg.channel_type, platformId: dmMg.platform_id, - threadId: null, + threadId: dmMg.platform_id, }, }) + '\n'; socket.write(payload, (err) => { diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts deleted file mode 100644 index dd16faf..0000000 --- a/scripts/migrate-group-claude-md.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * One-shot migration: wire each existing group up to global memory via - * an in-tree symlink + @-import. - * - * Claude Code's @-import only follows paths inside cwd, so a direct - * `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does - * nothing (the import line is parsed but the target file is never - * loaded into context). The working approach: - * - * 1. Symlink `groups//.claude-global.md` → - * `/workspace/global/CLAUDE.md` (container path; dangling on host, - * valid inside the container via the /workspace/global mount). - * 2. Have the group's CLAUDE.md import the symlink: - * `@./.claude-global.md`. - * - * This script: - * - Creates the symlink if missing. - * - Replaces any existing broken `@/workspace/global/CLAUDE.md` or - * `@../global/CLAUDE.md` import line with the symlink form. - * - Prepends the symlink import if neither form is present. - * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist. - * - * Idempotent — safe to re-run. - * - * Usage: pnpm exec tsx scripts/migrate-group-claude-md.ts - */ -import fs from 'fs'; -import path from 'path'; - -import { GROUPS_DIR } from '../src/config.js'; - -const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); -const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; -const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; -const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`; - -// Match any existing @-import that points at global/CLAUDE.md, whether -// via absolute path, relative path, or the new symlink form. -const EXISTING_IMPORT_REGEX = - /^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m; - -if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { - console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); - process.exit(1); -} - -if (!fs.existsSync(GROUPS_DIR)) { - console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`); - process.exit(1); -} - -const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); -let updated = 0; -let alreadyWired = 0; -let missingClaudeMd = 0; -let symlinksCreated = 0; - -for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'global') continue; - - const groupDir = path.join(GROUPS_DIR, entry.name); - - // Symlink (idempotent — skip if already present) - const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); - let linkExists = false; - try { - fs.lstatSync(linkPath); - linkExists = true; - } catch { - /* missing */ - } - if (!linkExists) { - fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath); - console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`); - symlinksCreated++; - } - - // CLAUDE.md import wiring - const claudeMd = path.join(groupDir, 'CLAUDE.md'); - if (!fs.existsSync(claudeMd)) { - console.log(`[skip] ${entry.name}: no CLAUDE.md`); - missingClaudeMd++; - continue; - } - - const body = fs.readFileSync(claudeMd, 'utf-8'); - const match = body.match(EXISTING_IMPORT_REGEX); - - if (match && match[0] === IMPORT_LINE) { - console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); - alreadyWired++; - continue; - } - - let newBody: string; - if (match) { - // Replace the broken import with the working form - newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE); - console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`); - } else { - // Prepend fresh - newBody = `${IMPORT_LINE}\n\n${body}`; - console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`); - } - - fs.writeFileSync(claudeMd, newBody); - updated++; -} - -console.log( - `\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`, -); diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts new file mode 100644 index 0000000..3cc74c1 --- /dev/null +++ b/src/claude-md-compose.ts @@ -0,0 +1,182 @@ +/** + * CLAUDE.md composition for agent groups. + * + * Replaces the per-group "written once at init, owned by the group" pattern + * with a host-regenerated entry point that imports: + * - a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`) + * - optional per-skill fragments (skills that ship `instructions.md`) + * - optional per-MCP-server fragments (inline `instructions` field in + * `container.json`) + * - per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code) + * + * Runs on every spawn from `container-runner.buildMounts()`. Deterministic — + * same inputs produce the same CLAUDE.md, and stale fragments are pruned. + * + * See `docs/claude-md-composition.md` for the full design. + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from './config.js'; +import { readContainerConfig } from './container-config.js'; +import { log } from './log.js'; +import type { AgentGroup } from './types.js'; + +// Symlink targets are container paths — dangling on host (hence the readlink +// dance instead of existsSync), valid inside the container via RO mounts. +const SHARED_CLAUDE_MD_CONTAINER_PATH = '/app/CLAUDE.md'; +const SHARED_SKILLS_CONTAINER_BASE = '/app/skills'; + +const COMPOSED_HEADER = ''; + +/** + * Regenerate `groups//CLAUDE.md` from the shared base, enabled skill + * fragments, and MCP server fragments declared in `container.json`. Creates + * an empty `CLAUDE.local.md` if missing. + */ +export function composeGroupClaudeMd(group: AgentGroup): void { + const groupDir = path.resolve(GROUPS_DIR, group.folder); + if (!fs.existsSync(groupDir)) { + fs.mkdirSync(groupDir, { recursive: true }); + } + + const sharedLink = path.join(groupDir, '.claude-shared.md'); + syncSymlink(sharedLink, SHARED_CLAUDE_MD_CONTAINER_PATH); + + const fragmentsDir = path.join(groupDir, '.claude-fragments'); + if (!fs.existsSync(fragmentsDir)) { + fs.mkdirSync(fragmentsDir, { recursive: true }); + } + + // Desired fragment set. + const config = readContainerConfig(group.folder); + const desired = new Map(); + + // Skill fragments — every skill that ships an `instructions.md`. + // TODO (shared-source refactor): respect `container.json` skill selection. + const skillsHostDir = path.join(process.cwd(), 'container', 'skills'); + if (fs.existsSync(skillsHostDir)) { + for (const skillName of fs.readdirSync(skillsHostDir)) { + const hostFragment = path.join(skillsHostDir, skillName, 'instructions.md'); + if (fs.existsSync(hostFragment)) { + desired.set(`${skillName}.md`, { + type: 'symlink', + content: `${SHARED_SKILLS_CONTAINER_BASE}/${skillName}/instructions.md`, + }); + } + } + } + + // MCP server fragments — inline instructions from container.json. + for (const [name, mcp] of Object.entries(config.mcpServers)) { + if (mcp.instructions) { + desired.set(`mcp-${name}.md`, { + type: 'inline', + content: mcp.instructions, + }); + } + } + + // Reconcile: drop stale, write desired. + for (const existing of fs.readdirSync(fragmentsDir)) { + if (!desired.has(existing)) { + fs.unlinkSync(path.join(fragmentsDir, existing)); + } + } + for (const [name, frag] of desired) { + const fragPath = path.join(fragmentsDir, name); + if (frag.type === 'symlink') { + syncSymlink(fragPath, frag.content); + } else { + writeAtomic(fragPath, frag.content); + } + } + + // Composed entry — imports only. + const imports = ['@./.claude-shared.md']; + for (const name of [...desired.keys()].sort()) { + imports.push(`@./.claude-fragments/${name}`); + } + const body = [COMPOSED_HEADER, ...imports, ''].join('\n'); + writeAtomic(path.join(groupDir, 'CLAUDE.md'), body); + + const localFile = path.join(groupDir, 'CLAUDE.local.md'); + if (!fs.existsSync(localFile)) { + fs.writeFileSync(localFile, ''); + } +} + +/** + * One-time cutover from the `groups/global/CLAUDE.md` + `.claude-global.md` + * pattern. Idempotent — safe to run on every host startup. + * + * For each group dir: + * - remove `.claude-global.md` symlink if present + * - rename `CLAUDE.md` → `CLAUDE.local.md` (only if `CLAUDE.local.md` + * doesn't already exist — preserves pre-cutover content as per-group + * memory; after the first spawn regenerates `CLAUDE.md`, this branch + * is skipped because `CLAUDE.local.md` now exists) + * + * Globally: + * - delete `groups/global/` (content already in `container/CLAUDE.md`) + */ +export function migrateGroupsToClaudeLocal(): void { + if (!fs.existsSync(GROUPS_DIR)) return; + + const actions: string[] = []; + + for (const entry of fs.readdirSync(GROUPS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name === 'global') continue; + + const groupDir = path.join(GROUPS_DIR, entry.name); + + const oldGlobalLink = path.join(groupDir, '.claude-global.md'); + try { + fs.lstatSync(oldGlobalLink); + fs.unlinkSync(oldGlobalLink); + actions.push(`${entry.name}/.claude-global.md removed`); + } catch { + /* already gone */ + } + + const claudeMd = path.join(groupDir, 'CLAUDE.md'); + const claudeLocal = path.join(groupDir, 'CLAUDE.local.md'); + if (fs.existsSync(claudeMd) && !fs.existsSync(claudeLocal)) { + fs.renameSync(claudeMd, claudeLocal); + actions.push(`${entry.name}/CLAUDE.md → CLAUDE.local.md`); + } + } + + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + fs.rmSync(globalDir, { recursive: true, force: true }); + actions.push('groups/global/ removed'); + } + + if (actions.length > 0) { + log.info('Migrated groups to CLAUDE.local.md model', { actions }); + } +} + +function syncSymlink(linkPath: string, target: string): void { + let currentTarget: string | null = null; + try { + currentTarget = fs.readlinkSync(linkPath); + } catch { + /* missing */ + } + if (currentTarget === target) return; + try { + fs.unlinkSync(linkPath); + } catch { + /* missing */ + } + fs.symlinkSync(target, linkPath); +} + +function writeAtomic(filePath: string, content: string): void { + const tmp = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmp, content); + fs.renameSync(tmp, filePath); +} diff --git a/src/command-gate.ts b/src/command-gate.ts new file mode 100644 index 0000000..7bd1b9f --- /dev/null +++ b/src/command-gate.ts @@ -0,0 +1,70 @@ +/** + * Host-side command gate. Classifies inbound slash commands and gates + * them before they reach the container. + * + * - Filtered commands: dropped silently (never reach the container) + * - Admin commands: checked against user_roles; denied senders get a + * "Permission denied" response written directly to messages_out + * - Normal messages: pass through unchanged + */ +import { getDb, hasTable } from './db/connection.js'; + +export type GateResult = + | { action: 'pass' } + | { action: 'filter' } + | { action: 'deny'; command: string }; + +const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); +const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']); + +/** + * Classify a message and decide whether it should reach the container. + * Returns 'pass' for normal messages and authorized admin commands, + * 'filter' for silently-dropped commands, 'deny' for unauthorized + * admin commands. + */ +export function gateCommand( + content: string, + userId: string | null, + agentGroupId: string, +): GateResult { + let text: string; + try { + const parsed = JSON.parse(content); + text = (parsed.text || '').trim(); + } catch { + text = content.trim(); + } + + if (!text.startsWith('/')) return { action: 'pass' }; + + const command = text.split(/\s/)[0].toLowerCase(); + + if (FILTERED_COMMANDS.has(command)) return { action: 'filter' }; + + if (ADMIN_COMMANDS.has(command)) { + if (isAdmin(userId, agentGroupId)) { + return { action: 'pass' }; + } + return { action: 'deny', command }; + } + + // Unknown slash commands pass through (the agent/SDK handles them) + return { action: 'pass' }; +} + +function isAdmin(userId: string | null, agentGroupId: string): boolean { + if (!userId) return false; + if (!hasTable(getDb(), 'user_roles')) return true; // no permissions module = allow all + const db = getDb(); + const row = db + .prepare( + `SELECT 1 FROM user_roles + WHERE user_id = ? + AND (role = 'owner' OR role = 'admin') + AND (agent_group_id IS NULL OR agent_group_id = ?) + LIMIT 1`, + ) + .get(userId, agentGroupId); + return row != null; +} diff --git a/src/container-config.ts b/src/container-config.ts index e1366e3..d972842 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,15 +1,8 @@ /** * Per-group container config, stored as a plain JSON file at - * `groups//container.json`. Replaces the former - * `agent_groups.container_config` DB column. - * - * Shape: - * { - * mcpServers: { [name]: { command, args, env } } - * packages: { apt: string[], npm: string[] } - * imageTag?: string // set by buildAgentGroupImage on rebuild - * additionalMounts?: Array<{hostPath, containerPath, readonly}> - * } + * `groups//container.json`. Mounted read-only inside the container + * at `/workspace/agent/container.json` — the runner reads it at startup but + * cannot modify it. Config changes go through the self-mod approval flow. * * All fields are optional — a missing file or a partial file both resolve * to sensible defaults. Writes are atomic-enough (write-then-rename is not @@ -25,6 +18,10 @@ export interface McpServerConfig { command: string; args?: string[]; env?: Record; + // Optional always-in-context guidance. When set, the host writes the + // content to `.claude-fragments/mcp-.md` at spawn and imports it + // into the composed CLAUDE.md. + instructions?: string; } export interface AdditionalMountConfig { @@ -38,6 +35,18 @@ export interface ContainerConfig { packages: { apt: string[]; npm: string[] }; imageTag?: string; additionalMounts: AdditionalMountConfig[]; + /** Which skills to enable — array of skill names or "all" (default). */ + skills: string[] | 'all'; + /** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */ + provider?: string; + /** Agent group display name (used in transcript archiving). */ + groupName?: string; + /** Assistant display name (used in system prompt / responses). */ + assistantName?: string; + /** Agent group ID — set by the host, read by the runner. */ + agentGroupId?: string; + /** Max messages per prompt. Falls back to code default if unset. */ + maxMessagesPerPrompt?: number; } function emptyConfig(): ContainerConfig { @@ -45,6 +54,7 @@ function emptyConfig(): ContainerConfig { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], + skills: 'all', }; } @@ -71,6 +81,12 @@ export function readContainerConfig(folder: string): ContainerConfig { }, imageTag: raw.imageTag, additionalMounts: raw.additionalMounts ?? [], + skills: raw.skills ?? 'all', + provider: raw.provider, + groupName: raw.groupName, + assistantName: raw.assistantName, + agentGroupId: raw.agentGroupId, + maxMessagesPerPrompt: raw.maxMessagesPerPrompt, }; } catch (err) { console.error(`[container-config] failed to parse ${p}: ${String(err)}`); diff --git a/src/container-runner.ts b/src/container-runner.ts index b357a0d..6f7f1d1 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -9,9 +9,10 @@ import path from 'path'; import { OneCLI } from '@onecli-sh/sdk'; -import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, MAX_MESSAGES_PER_PROMPT, ONECLI_URL, TIMEZONE } from './config.js'; +import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, ONECLI_URL, TIMEZONE } from './config.js'; import { readContainerConfig, writeContainerConfig } from './container-config.js'; import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; +import { composeGroupClaudeMd } from './claude-md-compose.js'; import { getAgentGroup } from './db/agent-groups.js'; import { getDb, hasTable } from './db/connection.js'; import { initGroupFilesystem } from './group-init.js'; @@ -91,17 +92,25 @@ async function spawnContainer(session: Session): Promise { } writeSessionRouting(agentGroup.id, session.id); + // Read container config once — threaded through provider resolution, + // buildMounts, and buildContainerArgs so we don't re-read the file. + const containerConfig = readContainerConfig(agentGroup.folder); + + // Ensure container.json has the agent group identity fields the runner needs. + // Written at spawn time so the runner can read them from the RO mount. + ensureRuntimeFields(containerConfig, agentGroup); + // Resolve the effective provider + any host-side contribution it declares // (extra mounts, env passthrough). Computed once and threaded through both // buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once. - const { provider, contribution } = resolveProviderContribution(session, agentGroup); + const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig); - const mounts = buildMounts(agentGroup, session, contribution); + const mounts = buildMounts(agentGroup, session, containerConfig, contribution); const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`; // OneCLI agent identifier is always the agent group id — stable across // sessions and reversible via getAgentGroup() for approval routing. const agentIdentifier = agentGroup.id; - const args = await buildContainerArgs(mounts, containerName, agentGroup, provider, contribution, agentIdentifier); + const args = await buildContainerArgs(mounts, containerName, agentGroup, containerConfig, provider, contribution, agentIdentifier); log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); @@ -156,8 +165,9 @@ export function killContainer(sessionId: string, reason: string): void { function resolveProviderContribution( session: Session, agentGroup: AgentGroup, + containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = (session.agent_provider || agentGroup.agent_provider || 'claude').toLowerCase(); + const provider = (containerConfig.provider || 'claude').toLowerCase(); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ @@ -172,15 +182,24 @@ function resolveProviderContribution( function buildMounts( agentGroup: AgentGroup, session: Session, + containerConfig: import('./container-config.js').ContainerConfig, providerContribution: ProviderContainerContribution, ): VolumeMount[] { + const projectRoot = process.cwd(); + // Per-group filesystem state lives forever after first creation. Init is // idempotent: it only writes paths that don't already exist, so this call - // is a no-op for groups that have spawned before. Pulling in upstream - // built-in skill or agent-runner source updates is an explicit operation - // (host-mediated tools), not something the spawn path does silently. + // is a no-op for groups that have spawned before. initGroupFilesystem(agentGroup); + // Sync skill symlinks based on container.json selection before mounting. + const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + syncSkillSymlinks(claudeDir, containerConfig); + + // Compose CLAUDE.md fresh every spawn from the shared base, enabled skill + // fragments, and MCP server instructions. See `claude-md-compose.ts`. + composeGroupClaudeMd(agentGroup); + const mounts: VolumeMount[] = []; const sessDir = sessionDir(agentGroup.id, session.id); const groupDir = path.resolve(GROUPS_DIR, agentGroup.folder); @@ -188,28 +207,44 @@ function buildMounts( // Session folder at /workspace (contains inbound.db, outbound.db, outbox/, .claude/) mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); - // Agent group folder at /workspace/agent + // Agent group folder at /workspace/agent (RW for working files + CLAUDE.md) mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); - // Global memory directory — always read-only. Edits to global config - // happen through the approval flow, not by handing one workspace RW. + // container.json — nested RO mount on top of RW group dir so the agent + // can read its config but cannot modify it. + const containerJsonPath = path.join(groupDir, 'container.json'); + if (fs.existsSync(containerJsonPath)) { + mounts.push({ hostPath: containerJsonPath, containerPath: '/workspace/agent/container.json', readonly: true }); + } + + // Global memory directory — always read-only. const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true }); } + // Shared CLAUDE.md — read-only, imported by the composed entry point via + // the `.claude-shared.md` symlink inside the group dir. + const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md'); + if (fs.existsSync(sharedClaudeMd)) { + mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true }); + } + // Per-group .claude-shared at /home/node/.claude (Claude state, settings, - // skills — initialized once at group creation, persistent thereafter) - const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); + // skill symlinks) mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - // Per-group agent-runner source at /app/src (initialized once at group - // creation, persistent thereafter — agents can modify their runner) - const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); - mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); + // Shared agent-runner source — read-only, same code for all groups. + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + mounts.push({ hostPath: agentRunnerSrc, containerPath: '/app/src', readonly: true }); - // Additional mounts from container config (groups//container.json) - const containerConfig = readContainerConfig(agentGroup.folder); + // Shared skills — read-only, symlinks in .claude-shared/skills/ point here. + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + if (fs.existsSync(skillsSrc)) { + mounts.push({ hostPath: skillsSrc, containerPath: '/app/skills', readonly: true }); + } + + // Additional mounts from container config if (containerConfig.additionalMounts && containerConfig.additionalMounts.length > 0) { const validated = validateAdditionalMounts(containerConfig.additionalMounts, agentGroup.name); mounts.push(...validated); @@ -223,32 +258,113 @@ function buildMounts( return mounts; } +/** + * Sync skill symlinks in .claude-shared/skills/ to match the container.json + * selection. Each symlink points to a container path (/app/skills/) + * so it's dangling on the host but valid inside the container. + */ +function syncSkillSymlinks( + claudeDir: string, + containerConfig: import('./container-config.js').ContainerConfig, +): void { + const skillsDir = path.join(claudeDir, 'skills'); + if (!fs.existsSync(skillsDir)) { + fs.mkdirSync(skillsDir, { recursive: true }); + } + + // Determine desired skill set + const projectRoot = process.cwd(); + const sharedSkillsDir = path.join(projectRoot, 'container', 'skills'); + let desired: string[]; + if (containerConfig.skills === 'all') { + // Recompute from shared dir — newly-added upstream skills appear automatically + desired = fs.existsSync(sharedSkillsDir) + ? fs.readdirSync(sharedSkillsDir).filter((e) => { + try { + return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory(); + } catch { + return false; + } + }) + : []; + } else { + desired = containerConfig.skills; + } + + const desiredSet = new Set(desired); + + // Remove symlinks not in the desired set + for (const entry of fs.readdirSync(skillsDir)) { + const entryPath = path.join(skillsDir, entry); + let isSymlink = false; + try { + isSymlink = fs.lstatSync(entryPath).isSymbolicLink(); + } catch { + continue; + } + if (isSymlink && !desiredSet.has(entry)) { + fs.unlinkSync(entryPath); + } + } + + // Create symlinks for desired skills (container path targets) + for (const skill of desired) { + const linkPath = path.join(skillsDir, skill); + let exists = false; + try { + fs.lstatSync(linkPath); + exists = true; + } catch { + /* missing */ + } + if (!exists) { + fs.symlinkSync(`/app/skills/${skill}`, linkPath); + } + } +} + +/** + * Ensure container.json has the runtime identity fields the runner needs. + * Written at spawn time so they're always current even if the DB values + * change (e.g. group rename). Only writes if values differ to avoid + * unnecessary file churn. + */ +function ensureRuntimeFields( + containerConfig: import('./container-config.js').ContainerConfig, + agentGroup: AgentGroup, +): void { + let dirty = false; + if (containerConfig.agentGroupId !== agentGroup.id) { + containerConfig.agentGroupId = agentGroup.id; + dirty = true; + } + if (containerConfig.groupName !== agentGroup.name) { + containerConfig.groupName = agentGroup.name; + dirty = true; + } + if (containerConfig.assistantName !== agentGroup.name) { + containerConfig.assistantName = agentGroup.name; + dirty = true; + } + if (dirty) { + writeContainerConfig(agentGroup.folder, containerConfig); + } +} + async function buildContainerArgs( mounts: VolumeMount[], containerName: string, agentGroup: AgentGroup, + containerConfig: import('./container-config.js').ContainerConfig, provider: string, providerContribution: ProviderContainerContribution, agentIdentifier?: string, ): Promise { const args: string[] = ['run', '--rm', '--name', containerName]; - // Environment + // Environment — only vars read by code we don't own. + // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); - args.push('-e', `AGENT_PROVIDER=${provider}`); - // Two-DB split: container reads inbound.db, writes outbound.db - args.push('-e', 'SESSION_INBOUND_DB_PATH=/workspace/inbound.db'); - args.push('-e', 'SESSION_OUTBOUND_DB_PATH=/workspace/outbound.db'); - args.push('-e', 'SESSION_HEARTBEAT_PATH=/workspace/.heartbeat'); - - if (agentGroup.name) { - args.push('-e', `NANOCLAW_ASSISTANT_NAME=${agentGroup.name}`); - } - args.push('-e', `NANOCLAW_AGENT_GROUP_ID=${agentGroup.id}`); - args.push('-e', `NANOCLAW_AGENT_GROUP_NAME=${agentGroup.name}`); - // Cap on how many pending messages reach one prompt. Accumulated context - // (trigger=0 rows) rides along with wake-eligible rows up to this cap. - args.push('-e', `NANOCLAW_MAX_MESSAGES_PER_PROMPT=${MAX_MESSAGES_PER_PROMPT}`); // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { @@ -257,39 +373,8 @@ async function buildContainerArgs( } } - // Users allowed to run admin commands (e.g. /clear) inside this container. - // Computed at wake time: owners + global admins + admins scoped to this - // agent group. Role changes take effect on next container spawn. - // - // SQL inlined to keep core independent of the permissions module — we - // guard on the `user_roles` table directly. If the permissions module - // isn't installed, the table doesn't exist and the set stays empty; the - // formatter treats an empty admin set as permissionless mode (every - // sender is admin). - const adminUserIds = new Set(); - if (hasTable(getDb(), 'user_roles')) { - const db = getDb(); - const owners = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'owner' AND agent_group_id IS NULL") - .all() as Array<{ user_id: string }>; - const globalAdmins = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id IS NULL") - .all() as Array<{ user_id: string }>; - const scopedAdmins = db - .prepare("SELECT user_id FROM user_roles WHERE role = 'admin' AND agent_group_id = ?") - .all(agentGroup.id) as Array<{ user_id: string }>; - for (const r of owners) adminUserIds.add(r.user_id); - for (const r of globalAdmins) adminUserIds.add(r.user_id); - for (const r of scopedAdmins) adminUserIds.add(r.user_id); - } - if (adminUserIds.size > 0) { - args.push('-e', `NANOCLAW_ADMIN_USER_IDS=${Array.from(adminUserIds).join(',')}`); - } - // OneCLI gateway — injects HTTPS_PROXY + certs so container API calls // are routed through the agent vault for credential injection. - // Must ensureAgent first for non-admin groups, otherwise applyContainerConfig - // rejects the unknown agent identifier and returns false. try { if (agentIdentifier) { await onecli.ensureAgent({ name: agentGroup.name, identifier: agentIdentifier }); @@ -324,16 +409,7 @@ async function buildContainerArgs( } } - // Pass additional MCP servers from container config (groups//container.json) - const containerConfig = readContainerConfig(agentGroup.folder); - if (containerConfig.mcpServers && Object.keys(containerConfig.mcpServers).length > 0) { - args.push('-e', `NANOCLAW_MCP_SERVERS=${JSON.stringify(containerConfig.mcpServers)}`); - } - // Override entrypoint: run v2 entry point directly via Bun (no tsc, no stdin). - // The image's ENTRYPOINT (tini → entrypoint.sh) handles the stdin-piped - // invocation path; the host-spawned sessions don't need stdin because all - // IO flows through the mounted session DBs. args.push('--entrypoint', 'bash'); // Use per-agent-group image if one has been built, otherwise base image diff --git a/src/group-init.ts b/src/group-init.ts index 527ba6b..437d10f 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -6,18 +6,6 @@ import { initContainerConfig } from './container-config.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; -// Container path where groups/global is mounted. The symlink we drop -// into each group's dir resolves to this target inside the container. -// It's a dangling symlink on the host — that's fine, host tools don't -// follow it and the container mount makes it valid at read time. -const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md'; - -// Symlink name inside the group's dir. Claude Code's @-import only -// follows paths inside cwd, so we can't reference /workspace/global -// directly — we symlink into the group dir and import the symlink. -export const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md'; -export const GLOBAL_CLAUDE_IMPORT = `@./${GLOBAL_MEMORY_LINK_NAME}`; - const DEFAULT_SETTINGS_JSON = JSON.stringify( { @@ -36,13 +24,17 @@ const DEFAULT_SETTINGS_JSON = * every step is gated on the target not already existing, so re-running on * an already-initialized group is a no-op. * - * Called once per group lifetime: at creation, or defensively from - * `buildMounts()` for groups that pre-date this code path. After init, the - * host never overwrites any of these paths automatically — agents own them. - * To pull in upstream changes, use the host-mediated reset/refresh tools. + * Called once per group lifetime at creation, or defensively from + * `buildMounts()` for groups that pre-date this code path. + * + * Source code and skills are shared RO mounts — not copied per-group. + * Skill symlinks are synced at spawn time by container-runner.ts. + * + * The composed `CLAUDE.md` is NOT written here — it's regenerated on every + * spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial + * per-group instructions (if provided) seed `CLAUDE.local.md`. */ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void { - const projectRoot = process.cwd(); const initialized: string[] = []; // 1. groups// — group memory + working dir @@ -52,29 +44,13 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('groupDir'); } - // groups//.claude-global.md — symlink into the group dir so - // Claude Code's @-import can follow it. Uses lstat to avoid tripping - // existsSync on a dangling symlink (target only resolves inside the - // container). - const globalLinkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME); - let linkExists = false; - try { - fs.lstatSync(globalLinkPath); - linkExists = true; - } catch { - /* missing — recreate */ - } - if (!linkExists) { - fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, globalLinkPath); - initialized.push('.claude-global.md'); - } - - // groups//CLAUDE.md — written once, then owned by the group - const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); - if (!fs.existsSync(claudeMdFile)) { - const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n'; - fs.writeFileSync(claudeMdFile, body); - initialized.push('CLAUDE.md'); + // groups//CLAUDE.local.md — per-group agent memory, auto-loaded by + // Claude Code. Seeded with caller-provided instructions on first creation. + const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md'); + if (!fs.existsSync(claudeLocalFile)) { + const body = opts?.instructions ? opts.instructions + '\n' : ''; + fs.writeFileSync(claudeLocalFile, body); + initialized.push('CLAUDE.local.md'); } // groups//container.json — empty container config, replaces the @@ -97,23 +73,12 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('settings.json'); } + // Skills directory — created empty here; symlinks are synced at spawn + // time by container-runner.ts based on container.json skills selection. const skillsDst = path.join(claudeDir, 'skills'); if (!fs.existsSync(skillsDst)) { - const skillsSrc = path.join(projectRoot, 'container', 'skills'); - if (fs.existsSync(skillsSrc)) { - fs.cpSync(skillsSrc, skillsDst, { recursive: true }); - initialized.push('skills/'); - } - } - - // 3. data/v2-sessions//agent-runner-src/ — per-group source copy - const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src'); - if (!fs.existsSync(groupRunnerDir)) { - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); - if (fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - initialized.push('agent-runner-src/'); - } + fs.mkdirSync(skillsDst, { recursive: true }); + initialized.push('skills/'); } if (initialized.length > 0) { diff --git a/src/index.ts b/src/index.ts index 1ec8619..d3de4d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; +import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; import { initDb } from './db/connection.js'; import { runMigrations } from './db/migrations/index.js'; import { ensureContainerRuntimeRunning, cleanupOrphans } from './container-runtime.js'; @@ -63,6 +64,9 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); + // 1b. One-time filesystem cutover — idempotent, no-op after first run. + migrateGroupsToClaudeLocal(); + // 2. Container runtime ensureContainerRuntimeRunning(); cleanupOrphans(); diff --git a/src/router.ts b/src/router.ts index c1e8881..538c270 100644 --- a/src/router.ts +++ b/src/router.ts @@ -18,6 +18,7 @@ * for policy refusals. */ import { getChannelAdapter } from './channels/channel-registry.js'; +import { gateCommand } from './command-gate.js'; import { getAgentGroup } from './db/agent-groups.js'; import { recordDroppedMessage } from './db/dropped-messages.js'; import { @@ -28,7 +29,7 @@ import { import { findSessionForAgent } from './db/sessions.js'; import { startTypingRefresh } from './modules/typing/index.js'; import { log } from './log.js'; -import { resolveSession, writeSessionMessage } from './session-manager.js'; +import { resolveSession, writeSessionMessage, writeOutboundDirect } from './session-manager.js'; import { wakeContainer } from './container-runner.js'; import { getSession } from './db/sessions.js'; import type { AgentGroup, MessagingGroup, MessagingGroupAgent } from './types.js'; @@ -398,6 +399,29 @@ async function deliverToAgent( threadId: event.threadId, }; + // Command gate: classify slash commands before they reach the container. + // Filtered commands are dropped silently. Denied admin commands get a + // permission-denied response written directly to messages_out. + if (event.message.kind === 'chat' || event.message.kind === 'chat-sdk') { + const gate = gateCommand(event.message.content, userId, agent.agent_group_id); + if (gate.action === 'filter') { + log.debug('Filtered command dropped by gate', { agentGroupId: agent.agent_group_id }); + return; + } + if (gate.action === 'deny') { + writeOutboundDirect(session.agent_group_id, session.id, { + id: `deny-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + platformId: deliveryAddr.platformId, + channelType: deliveryAddr.channelType, + threadId: deliveryAddr.threadId, + content: JSON.stringify({ text: `Permission denied: ${gate.command} requires admin access.` }), + }); + log.info('Admin command denied by gate', { command: gate.command, userId, agentGroupId: agent.agent_group_id }); + return; + } + } + writeSessionMessage(session.agent_group_id, session.id, { id: messageIdForAgent(event.message.id, agent.agent_group_id), kind: event.message.kind, diff --git a/src/session-manager.ts b/src/session-manager.ts index 2a5ac1d..38eaa0d 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -279,6 +279,34 @@ export function openOutboundDb(agentGroupId: string, sessionId: string): Databas return openOutboundDbRaw(outboundDbPath(agentGroupId, sessionId)); } +/** + * Write a message directly to a session's outbound DB so the host delivery + * loop picks it up. Used by the command gate to send denial responses + * without waking a container. + */ +export function writeOutboundDirect( + agentGroupId: string, + sessionId: string, + message: { + id: string; + kind: string; + platformId: string | null; + channelType: string | null; + threadId: string | null; + content: string; + }, +): void { + const db = openOutboundDb(agentGroupId, sessionId); + try { + db.prepare( + `INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content) + VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`, + ).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content); + } finally { + db.close(); + } +} + /** * @deprecated Use openInboundDb / openOutboundDb instead. */