Repository: qwibitai/nanoclaw Branch: main Commit: 91f17a11b265 Files: 134 Total size: 761.8 KB Directory structure: gitextract_5jpreexc/ ├── .claude/ │ ├── settings.json │ └── skills/ │ ├── add-compact/ │ │ └── SKILL.md │ ├── add-discord/ │ │ └── SKILL.md │ ├── add-gmail/ │ │ └── SKILL.md │ ├── add-image-vision/ │ │ └── SKILL.md │ ├── add-ollama-tool/ │ │ └── SKILL.md │ ├── add-parallel/ │ │ └── SKILL.md │ ├── add-pdf-reader/ │ │ └── SKILL.md │ ├── add-reactions/ │ │ └── SKILL.md │ ├── add-slack/ │ │ └── SKILL.md │ ├── add-telegram/ │ │ └── SKILL.md │ ├── add-telegram-swarm/ │ │ └── SKILL.md │ ├── add-voice-transcription/ │ │ └── SKILL.md │ ├── add-whatsapp/ │ │ └── SKILL.md │ ├── convert-to-apple-container/ │ │ └── SKILL.md │ ├── customize/ │ │ └── SKILL.md │ ├── debug/ │ │ └── SKILL.md │ ├── get-qodo-rules/ │ │ ├── SKILL.md │ │ └── references/ │ │ ├── output-format.md │ │ ├── pagination.md │ │ └── repository-scope.md │ ├── qodo-pr-resolver/ │ │ ├── SKILL.md │ │ └── resources/ │ │ └── providers.md │ ├── setup/ │ │ └── SKILL.md │ ├── update-nanoclaw/ │ │ └── SKILL.md │ ├── update-skills/ │ │ └── SKILL.md │ ├── use-local-whisper/ │ │ └── SKILL.md │ └── x-integration/ │ ├── SKILL.md │ ├── agent.ts │ ├── host.ts │ ├── lib/ │ │ ├── browser.ts │ │ └── config.ts │ └── scripts/ │ ├── like.ts │ ├── post.ts │ ├── quote.ts │ ├── reply.ts │ ├── retweet.ts │ └── setup.ts ├── .github/ │ ├── CODEOWNERS │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── bump-version.yml │ ├── ci.yml │ ├── merge-forward-skills.yml │ └── update-tokens.yml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .mcp.json ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── README.md ├── README_ja.md ├── README_zh.md ├── config-examples/ │ └── mount-allowlist.json ├── container/ │ ├── Dockerfile │ ├── agent-runner/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── index.ts │ │ │ └── ipc-mcp-stdio.ts │ │ └── tsconfig.json │ ├── build.sh │ └── skills/ │ ├── agent-browser/ │ │ └── SKILL.md │ ├── capabilities/ │ │ └── SKILL.md │ └── status/ │ └── SKILL.md ├── docs/ │ ├── APPLE-CONTAINER-NETWORKING.md │ ├── DEBUG_CHECKLIST.md │ ├── REQUIREMENTS.md │ ├── SDK_DEEP_DIVE.md │ ├── SECURITY.md │ ├── SPEC.md │ ├── docker-sandboxes.md │ ├── nanoclaw-architecture-final.md │ ├── nanorepo-architecture.md │ └── skills-as-branches.md ├── launchd/ │ └── com.nanoclaw.plist ├── package.json ├── repo-tokens/ │ ├── README.md │ └── action.yml ├── scripts/ │ └── run-migrations.ts ├── setup/ │ ├── container.ts │ ├── environment.test.ts │ ├── environment.ts │ ├── groups.ts │ ├── index.ts │ ├── mounts.ts │ ├── platform.test.ts │ ├── platform.ts │ ├── register.test.ts │ ├── register.ts │ ├── service.test.ts │ ├── service.ts │ ├── status.ts │ └── verify.ts ├── setup.sh ├── src/ │ ├── channels/ │ │ ├── index.ts │ │ ├── registry.test.ts │ │ └── registry.ts │ ├── config.ts │ ├── container-runner.test.ts │ ├── container-runner.ts │ ├── container-runtime.test.ts │ ├── container-runtime.ts │ ├── credential-proxy.test.ts │ ├── credential-proxy.ts │ ├── db.test.ts │ ├── db.ts │ ├── env.ts │ ├── formatting.test.ts │ ├── group-folder.test.ts │ ├── group-folder.ts │ ├── group-queue.test.ts │ ├── group-queue.ts │ ├── index.ts │ ├── ipc-auth.test.ts │ ├── ipc.ts │ ├── logger.ts │ ├── mount-security.ts │ ├── remote-control.test.ts │ ├── remote-control.ts │ ├── router.ts │ ├── routing.test.ts │ ├── sender-allowlist.test.ts │ ├── sender-allowlist.ts │ ├── task-scheduler.test.ts │ ├── task-scheduler.ts │ ├── timezone.test.ts │ ├── timezone.ts │ └── types.ts ├── tsconfig.json ├── vitest.config.ts └── vitest.skills.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .claude/settings.json ================================================ {} ================================================ FILE: .claude/skills/add-compact/SKILL.md ================================================ --- name: add-compact description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only. --- # Add /compact Command Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts. **Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context. ## Phase 1: Pre-flight Check if `src/session-commands.ts` exists: ```bash test -f src/session-commands.ts && echo "Already applied" || echo "Not applied" ``` If already applied, skip to Phase 3 (Verify). ## Phase 2: Apply Code Changes Merge the skill branch: ```bash git fetch upstream skill/compact git merge upstream/skill/compact ``` > **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly. This adds: - `src/session-commands.ts` (extract and authorize session commands) - `src/session-commands.test.ts` (unit tests for command parsing and auth) - Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`) - Slash command handling in `container/agent-runner/src/index.ts` ### Validate ```bash npm test npm run build ``` ### Rebuild container ```bash ./container/build.sh ``` ### Restart service ```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 3: Verify ### Integration Test 1. Start NanoClaw in dev mode: `npm run dev` 2. From the **main group** (self-chat), send exactly: `/compact` 3. Verify: - The agent acknowledges compaction (e.g., "Conversation compacted.") - The session continues — send a follow-up message and verify the agent responds coherently - A conversation archive is written to `groups/{folder}/conversations/` (by the PreCompact hook) - Container logs show `Compact boundary observed` (confirms SDK actually compacted) - If `compact_boundary` was NOT observed, the response says "compact_boundary was not observed" 4. From a **non-main group** as a non-admin user, send: `@ /compact` 5. Verify: - The bot responds with "Session commands require admin access." - No compaction occurs, no container is spawned for the command 6. From a **non-main group** as the admin (device owner / `is_from_me`), send: `@ /compact` 7. Verify: - Compaction proceeds normally (same behavior as main group) 8. While an **active container** is running for the main group, send `/compact` 9. Verify: - The active container is signaled to close (authorized senders only — untrusted senders cannot kill in-flight work) - Compaction proceeds via a new container once the active one exits - The command is not dropped (no cursor race) 10. Send a normal message, then `/compact`, then another normal message in quick succession (same polling batch): 11. Verify: - Pre-compact messages are sent to the agent first (check container logs for two `runAgent` calls) - Compaction proceeds after pre-compact messages are processed - Messages **after** `/compact` in the batch are preserved (cursor advances to `/compact`'s timestamp only) and processed on the next poll cycle 12. From a **non-main group** as a non-admin user, send `@ /compact`: 13. Verify: - Denial message is sent ("Session commands require admin access.") - The `/compact` is consumed (cursor advanced) — it does NOT replay on future polls - Other messages in the same batch are also consumed (cursor is a high-water mark — this is an accepted tradeoff for the narrow edge case of denied `/compact` + other messages in the same polling interval) - No container is killed or interrupted 14. From a **non-main group** (with `requiresTrigger` enabled) as a non-admin user, send bare `/compact` (no trigger prefix): 15. Verify: - No denial message is sent (trigger policy prevents untrusted bot responses) - The `/compact` is consumed silently - Note: in groups where `requiresTrigger` is `false`, a denial message IS sent because the sender is considered reachable 16. After compaction, verify **no auto-compaction** behavior — only manual `/compact` triggers it ### Validation on Fresh Clone ```bash git clone /tmp/nanoclaw-test cd /tmp/nanoclaw-test claude # then run /add-compact npm run build npm test ./container/build.sh # Manual: send /compact from main group, verify compaction + continuation # Manual: send @ /compact from non-main as non-admin, verify denial # Manual: send @ /compact from non-main as admin, verify allowed # Manual: verify no auto-compaction behavior ``` ## Security Constraints - **Main-group or trusted/admin sender only.** The main group is the user's private self-chat and is trusted (see `docs/SECURITY.md`). Non-main groups are untrusted — a careless or malicious user could wipe the agent's short-term memory. However, the device owner (`is_from_me`) is always trusted and can compact from any group. - **No auto-compaction.** This skill implements manual compaction only. Automatic threshold-based compaction is a separate concern and should be a separate skill. - **No config file.** NanoClaw's philosophy is customization through code changes, not configuration sprawl. - **Transcript archived before compaction.** The existing `PreCompact` hook in the agent-runner archives the full transcript to `conversations/` before the SDK compacts it. - **Session continues after compaction.** This is not a destructive reset. The conversation continues with summarized context. ## What This Does NOT Do - No automatic compaction threshold (add separately if desired) - No `/clear` command (separate skill, separate semantics — `/clear` is a destructive reset) - No cross-group compaction (each group's session is isolated) - No changes to the container image, Dockerfile, or build script ## Troubleshooting - **"Session commands require admin access"**: Only the device owner (`is_from_me`) or main-group senders can use `/compact`. Other users are denied. - **No compact_boundary in logs**: The SDK may not emit this event in all versions. Check the agent-runner logs for the warning message. Compaction may still have succeeded. - **Pre-compact failure**: If messages before `/compact` fail to process, the error message says "Failed to process messages before /compact." The cursor advances past sent output to prevent duplicates; `/compact` remains pending for the next attempt. ================================================ FILE: .claude/skills/add-discord/SKILL.md ================================================ --- name: add-discord description: Add Discord bot channel integration to NanoClaw. --- # Add Discord Channel This skill adds Discord support to NanoClaw, then walks through interactive setup. ## Phase 1: Pre-flight ### Check if already applied Check if `src/channels/discord.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. ### Ask the user Use `AskUserQuestion` to collect configuration: AskUserQuestion: Do you have a Discord bot token, or do you need to create one? If they have one, collect it now. If not, we'll create one in Phase 3. ## Phase 2: Apply Code Changes ### Ensure channel remote ```bash git remote -v ``` If `discord` is missing, add it: ```bash git remote add discord https://github.com/qwibitai/nanoclaw-discord.git ``` ### Merge the skill branch ```bash git fetch discord main git merge discord/main || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/channels/discord.ts` (DiscordChannel class with self-registration via `registerChannel`) - `src/channels/discord.test.ts` (unit tests with discord.js mock) - `import './discord.js'` appended to the channel barrel file `src/channels/index.ts` - `discord.js` npm dependency in `package.json` - `DISCORD_BOT_TOKEN` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install npm run build npx vitest run src/channels/discord.test.ts ``` All tests must pass (including the new Discord tests) and build must be clean before proceeding. ## Phase 3: Setup ### Create Discord Bot (if needed) If the user doesn't have a bot token, tell them: > I need you to create a Discord bot: > > 1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) > 2. Click **New Application** and give it a name (e.g., "Andy Assistant") > 3. Go to the **Bot** tab on the left sidebar > 4. Click **Reset Token** to generate a new bot token — copy it immediately (you can only see it once) > 5. Under **Privileged Gateway Intents**, enable: > - **Message Content Intent** (required to read message text) > - **Server Members Intent** (optional, for member display names) > 6. Go to **OAuth2** > **URL Generator**: > - Scopes: select `bot` > - Bot Permissions: select `Send Messages`, `Read Message History`, `View Channels` > - Copy the generated URL and open it in your browser to invite the bot to your server Wait for the user to provide the token. ### Configure environment Add to `.env`: ```bash DISCORD_BOT_TOKEN= ``` Channels auto-enable when their credentials are present — no extra configuration needed. Sync to container environment: ```bash mkdir -p data/env && cp .env data/env/env ``` The container reads environment from `data/env/env`, not `.env` directly. ### Build and restart ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ## Phase 4: Registration ### Get Channel ID Tell the user: > To get the channel ID for registration: > > 1. In Discord, go to **User Settings** > **Advanced** > Enable **Developer Mode** > 2. Right-click the text channel you want the bot to respond in > 3. Click **Copy Channel ID** > > The channel ID will be a long number like `1234567890123456`. Wait for the user to provide the channel ID (format: `dc:1234567890123456`). ### Register the channel The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main channel (responds to all messages): ```bash npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main ``` For additional channels (trigger-only): ```bash npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_" --trigger "@${ASSISTANT_NAME}" --channel discord ``` ## Phase 5: Verify ### Test the connection Tell the user: > Send a message in your registered Discord channel: > - For main channel: Any message works > - For non-main: @mention the bot in Discord > > The bot should respond within a few seconds. ### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ## Troubleshooting ### Bot not responding 1. Check `DISCORD_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` 2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'dc:%'"` 3. For non-main channels: message must include trigger pattern (@mention the bot) 4. Service is running: `launchctl list | grep nanoclaw` 5. Verify the bot has been invited to the server (check OAuth2 URL was used) ### Bot only responds to @mentions This is the default behavior for non-main channels (`requiresTrigger: true`). To change: - Update the registered group's `requiresTrigger` to `false` - Or register the channel as the main channel ### Message Content Intent not enabled If the bot connects but can't read messages, ensure: 1. Go to [Discord Developer Portal](https://discord.com/developers/applications) 2. Select your application > **Bot** tab 3. Under **Privileged Gateway Intents**, enable **Message Content Intent** 4. Restart NanoClaw ### Getting Channel ID If you can't copy the channel ID: - Ensure **Developer Mode** is enabled: User Settings > Advanced > Developer Mode - Right-click the channel name in the server sidebar > Copy Channel ID ## After Setup The Discord bot supports: - Text messages in registered channels - Attachment descriptions (images, videos, files shown as placeholders) - Reply context (shows who the user is replying to) - @mention translation (Discord `<@botId>` → NanoClaw trigger format) - Message splitting for responses over 2000 characters - Typing indicators while the agent processes ================================================ FILE: .claude/skills/add-gmail/SKILL.md ================================================ --- name: add-gmail description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration. --- # Add Gmail Integration This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox. ## Phase 1: Pre-flight ### Check if already applied Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. ### Ask the user Use `AskUserQuestion`: AskUserQuestion: Should incoming emails be able to trigger the agent? - **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically - **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added. ## Phase 2: Apply Code Changes ### Ensure channel remote ```bash git remote -v ``` If `gmail` is missing, add it: ```bash git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git ``` ### Merge the skill branch ```bash git fetch gmail main git merge gmail/main || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`) - `src/channels/gmail.test.ts` (unit tests) - `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts` - Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts` - Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts` - `googleapis` npm dependency in `package.json` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Add email handling instructions (Channel mode only) If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section): ```markdown ## Email Notifications When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email. ``` ### Validate code changes ```bash npm install npm run build npx vitest run src/channels/gmail.test.ts ``` All tests must pass (including the new Gmail tests) and build must be clean before proceeding. ## Phase 3: Setup ### Check existing Gmail credentials ```bash ls -la ~/.gmail-mcp/ 2>/dev/null || echo "No Gmail config found" ``` If `credentials.json` already exists, skip to "Build and restart" below. ### GCP Project Setup Tell the user: > I need you to set up Google Cloud OAuth credentials: > > 1. Open https://console.cloud.google.com — create a new project or select existing > 2. Go to **APIs & Services > Library**, search "Gmail API", click **Enable** > 3. Go to **APIs & Services > Credentials**, click **+ CREATE CREDENTIALS > OAuth client ID** > - If prompted for consent screen: choose "External", fill in app name and email, save > - Application type: **Desktop app**, name: anything (e.g., "NanoClaw Gmail") > 4. Click **DOWNLOAD JSON** and save as `gcp-oauth.keys.json` > > Where did you save the file? (Give me the full path, or paste the file contents here) If user provides a path, copy it: ```bash mkdir -p ~/.gmail-mcp cp "/path/user/provided/gcp-oauth.keys.json" ~/.gmail-mcp/gcp-oauth.keys.json ``` If user pastes JSON content, write it to `~/.gmail-mcp/gcp-oauth.keys.json`. ### OAuth Authorization Tell the user: > I'm going to run Gmail authorization. A browser window will open — sign in and grant access. If you see an "app isn't verified" warning, click "Advanced" then "Go to [app name] (unsafe)" — this is normal for personal OAuth apps. Run the authorization: ```bash npx -y @gongrzhe/server-gmail-autoauth-mcp auth ``` If that fails (some versions don't have an auth subcommand), try `timeout 60 npx -y @gongrzhe/server-gmail-autoauth-mcp || true`. Verify with `ls ~/.gmail-mcp/credentials.json`. ### Build and restart Clear stale per-group agent-runner copies (they only get re-created if missing, so existing copies won't pick up the new Gmail server): ```bash rm -r data/sessions/*/agent-runner-src 2>/dev/null || true ``` Rebuild the container (agent-runner changed): ```bash cd container && ./build.sh ``` Then compile and restart: ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 4: Verify ### Test tool access (both modes) Tell the user: > Gmail is connected! Send this in your main channel: > > `@Andy check my recent emails` or `@Andy list my Gmail labels` ### Test channel mode (Channel mode only) Tell the user to send themselves a test email. The agent should pick it up within a minute. Monitor: `tail -f logs/nanoclaw.log | grep -iE "(gmail|email)"`. Once verified, offer filter customization via `AskUserQuestion` — by default, only emails in the Primary inbox trigger the agent (Promotions, Social, Updates, and Forums are excluded). The user can keep this default or narrow further by sender, label, or keywords. No code changes needed for filters. ### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ## Troubleshooting ### Gmail connection not responding Test directly: ```bash npx -y @gongrzhe/server-gmail-autoauth-mcp ``` ### OAuth token expired Re-authorize: ```bash rm ~/.gmail-mcp/credentials.json npx -y @gongrzhe/server-gmail-autoauth-mcp ``` ### Container can't access Gmail - Verify `~/.gmail-mcp` is mounted: check `src/container-runner.ts` for the `.gmail-mcp` mount - Check container logs: `cat groups/main/logs/container-*.log | tail -50` ### Emails not being detected (Channel mode only) - By default, the channel polls unread Primary inbox emails (`is:unread category:primary`) - Check logs for Gmail polling errors ## Removal ### Tool-only mode 1. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` 2. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` 3. Rebuild and restart 4. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` 5. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) ### Channel mode 1. Delete `src/channels/gmail.ts` and `src/channels/gmail.test.ts` 2. Remove `import './gmail.js'` from `src/channels/index.ts` 3. Remove `~/.gmail-mcp` mount from `src/container-runner.ts` 4. Remove `gmail` MCP server and `mcp__gmail__*` from `container/agent-runner/src/index.ts` 5. Uninstall: `npm uninstall googleapis` 6. Rebuild and restart 7. Clear stale agent-runner copies: `rm -r data/sessions/*/agent-runner-src 2>/dev/null || true` 8. Rebuild: `cd container && ./build.sh && cd .. && npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) ================================================ FILE: .claude/skills/add-image-vision/SKILL.md ================================================ --- name: add-image-vision description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks. --- # Image Vision Skill Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks. ## Phase 1: Pre-flight 1. Check if `src/image.ts` exists — skip to Phase 3 if already applied 2. Confirm `sharp` is installable (native bindings require build tools) **Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. ## Phase 2: Apply Code Changes ### Ensure WhatsApp fork remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp skill/image-vision git merge whatsapp/skill/image-vision || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/image.ts` (image download, resize via sharp, base64 encoding) - `src/image.test.ts` (8 unit tests) - Image attachment handling in `src/channels/whatsapp.ts` - Image passing to agent in `src/index.ts` and `src/container-runner.ts` - Image content block support in `container/agent-runner/src/index.ts` - `sharp` npm dependency in `package.json` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install npm run build npx vitest run src/image.test.ts ``` All tests must pass and build must be clean before proceeding. ## Phase 3: Configure 1. Rebuild the container (agent-runner changes need a rebuild): ```bash ./container/build.sh ``` 2. Sync agent-runner source to group caches: ```bash for dir in data/sessions/*/agent-runner-src/; do cp container/agent-runner/src/*.ts "$dir" done ``` 3. Restart the service: ```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ## Phase 4: Verify 1. Send an image in a registered WhatsApp group 2. Check the agent responds with understanding of the image content 3. Check logs for "Processed image attachment": ```bash tail -50 groups/*/logs/container-*.log ``` ## Troubleshooting - **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections. - **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify. - **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches. ================================================ FILE: .claude/skills/add-ollama-tool/SKILL.md ================================================ --- name: add-ollama-tool description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries. --- # Add Ollama Integration This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models. Tools added: - `ollama_list_models` — lists installed Ollama models - `ollama_generate` — sends a prompt to a specified model and returns the response ## Phase 1: Pre-flight ### Check if already applied Check if `container/agent-runner/src/ollama-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). ### Check prerequisites Verify Ollama is installed and running on the host: ```bash ollama list ``` If Ollama is not installed, direct the user to https://ollama.com/download. If no models are installed, suggest pulling one: > You need at least one model. I recommend: > > ```bash > ollama pull gemma3:1b # Small, fast (1GB) > ollama pull llama3.2 # Good general purpose (2GB) > ollama pull qwen3-coder:30b # Best for code tasks (18GB) > ``` ## Phase 2: Apply Code Changes ### Ensure upstream remote ```bash git remote -v ``` If `upstream` is missing, add it: ```bash git remote add upstream https://github.com/qwibitai/nanoclaw.git ``` ### Merge the skill branch ```bash git fetch upstream skill/ollama-tool git merge upstream/skill/ollama-tool ``` This merges in: - `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server) - `scripts/ollama-watch.sh` (macOS notification watcher) - Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers) - `[OLLAMA]` log surfacing in `src/container-runner.ts` - `OLLAMA_HOST` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Copy to per-group agent-runner Existing groups have a cached copy of the agent-runner source. Copy the new files: ```bash for dir in data/sessions/*/agent-runner-src; do cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/" cp container/agent-runner/src/index.ts "$dir/" done ``` ### Validate code changes ```bash npm run build ./container/build.sh ``` Build must be clean before proceeding. ## Phase 3: Configure ### Set Ollama host (optional) By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`: ```bash OLLAMA_HOST=http://your-ollama-host:11434 ``` ### Restart the service ```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 4: Verify ### Test via WhatsApp Tell the user: > Send a message like: "use ollama to tell me the capital of France" > > The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response. ### Monitor activity (optional) Run the watcher script for macOS notifications when Ollama is used: ```bash ./scripts/ollama-watch.sh ``` ### Check logs if needed ```bash tail -f logs/nanoclaw.log | grep -i ollama ``` Look for: - `Agent output: ... Ollama ...` — agent used Ollama successfully - `[OLLAMA] >>> Generating` — generation started (if log surfacing works) - `[OLLAMA] <<< Done` — generation completed ## Troubleshooting ### Agent says "Ollama is not installed" The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means: 1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers` 2. The per-group source wasn't updated — re-copy files (see Phase 2) 3. The container wasn't rebuilt — run `./container/build.sh` ### "Failed to connect to Ollama" 1. Verify Ollama is running: `ollama list` 2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags` 3. If using a custom host, check `OLLAMA_HOST` in `.env` ### Agent doesn't use Ollama tools The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..." ================================================ FILE: .claude/skills/add-parallel/SKILL.md ================================================ # Add Parallel AI Integration Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities. ## What This Adds - **Quick Search** - Fast web lookups using Parallel Search API (free to use) - **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission) - **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking) ## Prerequisites User must have: 1. Parallel AI API key from https://platform.parallel.ai 2. NanoClaw already set up and running 3. Docker installed and running ## Implementation Steps Run all steps automatically. Only pause for user input when explicitly needed. ### 1. Get Parallel AI API Key Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?` **If they have one:** Collect it now. **If they need one:** Tell them: > 1. Go to https://platform.parallel.ai > 2. Sign up or log in > 3. Navigate to API Keys section > 4. Create a new API key > 5. Copy the key and paste it here Wait for the API key. ### 2. Add API Key to Environment Add `PARALLEL_API_KEY` to `.env`: ```bash # Check if .env exists, create if not if [ ! -f .env ]; then touch .env fi # Add PARALLEL_API_KEY if not already present if ! grep -q "PARALLEL_API_KEY=" .env; then echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env echo "✓ Added PARALLEL_API_KEY to .env" else # Update existing key sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env echo "✓ Updated PARALLEL_API_KEY in .env" fi ``` Verify: ```bash grep "PARALLEL_API_KEY" .env | head -c 50 ``` ### 3. Update Container Runner Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`: Find the line: ```typescript const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']; ``` Replace with: ```typescript const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY']; ``` ### 4. Configure MCP Servers in Agent Runner Update `container/agent-runner/src/index.ts`: Find the section where `mcpServers` is configured (around line 237-252): ```typescript const mcpServers: Record = { nanoclaw: ipcMcp }; ``` Add Parallel AI MCP servers after the nanoclaw server: ```typescript const mcpServers: Record = { nanoclaw: ipcMcp }; // Add Parallel AI MCP servers if API key is available const parallelApiKey = process.env.PARALLEL_API_KEY; if (parallelApiKey) { mcpServers['parallel-search'] = { type: 'http', // REQUIRED: Must specify type for HTTP MCP servers url: 'https://search-mcp.parallel.ai/mcp', headers: { 'Authorization': `Bearer ${parallelApiKey}` } }; mcpServers['parallel-task'] = { type: 'http', // REQUIRED: Must specify type for HTTP MCP servers url: 'https://task-mcp.parallel.ai/mcp', headers: { 'Authorization': `Bearer ${parallelApiKey}` } }; log('Parallel AI MCP servers configured'); } else { log('PARALLEL_API_KEY not set, skipping Parallel AI integration'); } ``` Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248): ```typescript allowedTools: [ 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'mcp__nanoclaw__*', 'mcp__parallel-search__*', 'mcp__parallel-task__*' ], ``` ### 5. Add Usage Instructions to CLAUDE.md Add Parallel AI usage instructions to `groups/main/CLAUDE.md`: Find the "## What You Can Do" section and add after the existing bullet points: ```markdown - Use Parallel AI for web research and deep learning tasks ``` Then add a new section after "## What You Can Do": ```markdown ## Web Research Tools You have access to two Parallel AI research tools: ### Quick Web Search (`mcp__parallel-search__search`) **When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts. **Examples:** - "Who invented the transistor?" - "What's the latest news about quantum computing?" - "When was the UN founded?" - "What are the top programming languages in 2026?" **Speed:** Fast (2-5 seconds) **Cost:** Low **Permission:** Not needed - use whenever it helps answer the question ### Deep Research (`mcp__parallel-task__create_task_run`) **When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research. **Examples:** - "Explain the development of quantum mechanics from 1900-1930" - "Compare the literary styles of Hemingway and Faulkner" - "Research the evolution of jazz from bebop to fusion" - "Analyze the causes of the French Revolution" **Speed:** Slower (1-20 minutes depending on depth) **Cost:** Higher (varies by processor tier) **Permission:** ALWAYS use `AskUserQuestion` before using this tool **How to ask permission:** ``` AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed? ``` **After permission - DO NOT BLOCK! Use scheduler instead:** 1. Create the task using `mcp__parallel-task__create_task_run` 2. Get the `run_id` from the response 3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`: ``` Prompt: "Check Parallel AI task run [run_id] and send results when ready. 1. Use the Parallel Task MCP to check the task status 2. If status is 'completed', extract the results 3. Send results to user with mcp__nanoclaw__send_message 4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done If status is still 'running' or 'pending', do nothing (task will run again in 30s). If status is 'failed', send error message and complete the task." Schedule: interval every 30 seconds Context mode: isolated ``` 4. Send acknowledgment with tracking link 5. Exit immediately - scheduler handles the rest ### Choosing Between Them **Use Search when:** - Question needs a quick fact or recent information - Simple definition or clarification - Verifying specific details - Current events or news **Use Deep Research (with permission) when:** - User wants to learn about a complex topic - Question requires analysis or comparison - Historical context or evolution of concepts - Structured, comprehensive understanding needed - User explicitly asks to "research" or "explain in depth" **Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis. ``` ### 6. Rebuild Container Build the container with updated agent runner: ```bash ./container/build.sh ``` Verify the build: ```bash echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" ``` ### 7. Restart Service Rebuild the main app and restart: ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` Wait 3 seconds for service to start, then verify: ```bash sleep 3 launchctl list | grep nanoclaw # macOS # Linux: systemctl --user status nanoclaw ``` ### 8. Test Integration Tell the user to test: > Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?` > > The assistant should use Parallel Search API to find current information. > > Then try: `@[YourAssistantName] can you research the history of artificial intelligence?` > > The assistant should ask for permission before using the Task API. Check logs to verify MCP servers loaded: ```bash tail -20 logs/nanoclaw.log ``` Look for: `Parallel AI MCP servers configured` ## Troubleshooting **Container hangs or times out:** - Check that `type: 'http'` is specified in MCP server config - Verify API key is correct in .env - Check container logs: `cat groups/main/logs/container-*.log | tail -50` **MCP servers not loading:** - Ensure PARALLEL_API_KEY is in .env - Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars - Check agent-runner logs for "Parallel AI MCP servers configured" message **Task polling not working:** - Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"` - Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"` - Ensure task prompt includes proper Parallel MCP tool names ## Uninstalling To remove Parallel AI integration: 1. Remove from .env: `sed -i.bak '/PARALLEL_API_KEY/d' .env` 2. Revert changes to container-runner.ts and agent-runner/src/index.ts 3. Remove Web Research Tools section from groups/main/CLAUDE.md 4. Rebuild: `./container/build.sh && npm run build` 5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) ================================================ FILE: .claude/skills/add-pdf-reader/SKILL.md ================================================ --- name: add-pdf-reader description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files. --- # Add PDF Reader Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace. ## Phase 1: Pre-flight 1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied 2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. ## Phase 2: Apply Code Changes ### Ensure WhatsApp fork remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp skill/pdf-reader git merge whatsapp/skill/pdf-reader || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `container/skills/pdf-reader/SKILL.md` (agent-facing documentation) - `container/skills/pdf-reader/pdf-reader` (CLI script) - `poppler-utils` in `container/Dockerfile` - PDF attachment download in `src/channels/whatsapp.ts` - PDF tests in `src/channels/whatsapp.test.ts` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate ```bash npm run build npx vitest run src/channels/whatsapp.test.ts ``` ### Rebuild container ```bash ./container/build.sh ``` ### Restart service ```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 3: Verify ### Test PDF extraction Send a PDF file in any registered WhatsApp chat. The agent should: 1. Download the PDF to `attachments/` 2. Respond acknowledging the PDF 3. Be able to extract text when asked ### Test URL fetching Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch `. ### Check logs if needed ```bash tail -f logs/nanoclaw.log | grep -i pdf ``` Look for: - `Downloaded PDF attachment` — successful download - `Failed to download PDF attachment` — media download issue ## Troubleshooting ### Agent says pdf-reader command not found Container needs rebuilding. Run `./container/build.sh` and restart the service. ### PDF text extraction is empty The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead. ### WhatsApp PDF not detected Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype. ================================================ FILE: .claude/skills/add-reactions/SKILL.md ================================================ --- name: add-reactions description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions. --- # Add Reactions This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite. ## Phase 1: Pre-flight ### Check if already applied Check if `src/status-tracker.ts` exists: ```bash test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied" ``` If already applied, skip to Phase 3 (Verify). ## Phase 2: Apply Code Changes ### Ensure WhatsApp fork remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp skill/reactions git merge whatsapp/skill/reactions || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This adds: - `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes) - `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry) - `src/status-tracker.test.ts` (unit tests for StatusTracker) - `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool) - Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts` ### Run database migration ```bash npx tsx scripts/migrate-reactions.ts ``` ### Validate code changes ```bash npm test npm run build ``` All tests must pass and build must be clean before proceeding. ## Phase 3: Verify ### Build and restart ```bash npm run build ``` Linux: ```bash systemctl --user restart nanoclaw ``` macOS: ```bash launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ### Test receiving reactions 1. Send a message from your phone 2. React to it with an emoji on WhatsApp 3. Check the database: ```bash sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;" ``` ### Test sending reactions Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message. ## Troubleshooting ### Reactions not appearing in database - Check NanoClaw logs for `Failed to process reaction` errors - Verify the chat is registered - Confirm the service is running ### Migration fails - Ensure `store/messages.db` exists and is accessible - If "table reactions already exists", the migration already ran — skip it ### Agent can't send reactions - Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat - Verify WhatsApp is connected: check logs for connection status ================================================ FILE: .claude/skills/add-slack/SKILL.md ================================================ --- name: add-slack description: Add Slack as a channel. Can replace WhatsApp entirely or run alongside it. Uses Socket Mode (no public URL needed). --- # Add Slack Channel This skill adds Slack support to NanoClaw, then walks through interactive setup. ## Phase 1: Pre-flight ### Check if already applied Check if `src/channels/slack.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. ### Ask the user **Do they already have a Slack app configured?** If yes, collect the Bot Token and App Token now. If no, we'll create one in Phase 3. ## Phase 2: Apply Code Changes ### Ensure channel remote ```bash git remote -v ``` If `slack` is missing, add it: ```bash git remote add slack https://github.com/qwibitai/nanoclaw-slack.git ``` ### Merge the skill branch ```bash git fetch slack main git merge slack/main || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/channels/slack.ts` (SlackChannel class with self-registration via `registerChannel`) - `src/channels/slack.test.ts` (46 unit tests) - `import './slack.js'` appended to the channel barrel file `src/channels/index.ts` - `@slack/bolt` npm dependency in `package.json` - `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install npm run build npx vitest run src/channels/slack.test.ts ``` All tests must pass (including the new Slack tests) and build must be clean before proceeding. ## Phase 3: Setup ### Create Slack App (if needed) If the user doesn't have a Slack app, share [SLACK_SETUP.md](SLACK_SETUP.md) which has step-by-step instructions with screenshots guidance, troubleshooting, and a token reference table. Quick summary of what's needed: 1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) 2. Enable Socket Mode and generate an App-Level Token (`xapp-...`) 3. Subscribe to bot events: `message.channels`, `message.groups`, `message.im` 4. Add OAuth scopes: `chat:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read` 5. Install to workspace and copy the Bot Token (`xoxb-...`) Wait for the user to provide both tokens. ### Configure environment Add to `.env`: ```bash SLACK_BOT_TOKEN=xoxb-your-bot-token SLACK_APP_TOKEN=xapp-your-app-token ``` Channels auto-enable when their credentials are present — no extra configuration needed. Sync to container environment: ```bash mkdir -p data/env && cp .env data/env/env ``` The container reads environment from `data/env/env`, not `.env` directly. ### Build and restart ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ## Phase 4: Registration ### Get Channel ID Tell the user: > 1. Add the bot to a Slack channel (right-click channel → **View channel details** → **Integrations** → **Add apps**) > 2. In that channel, the channel ID is in the URL when you open it in a browser: `https://app.slack.com/client/T.../C0123456789` — the `C...` part is the channel ID > 3. Alternatively, right-click the channel name → **Copy link** — the channel ID is the last path segment > > The JID format for NanoClaw is: `slack:C0123456789` Wait for the user to provide the channel ID. ### Register the channel The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main channel (responds to all messages): ```bash npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main ``` For additional channels (trigger-only): ```bash npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_" --trigger "@${ASSISTANT_NAME}" --channel slack ``` ## Phase 5: Verify ### Test the connection Tell the user: > Send a message in your registered Slack channel: > - For main channel: Any message works > - For non-main: `@ hello` (using the configured trigger word) > > The bot should respond within a few seconds. ### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ## Troubleshooting ### Bot not responding 1. Check `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN` are set in `.env` AND synced to `data/env/env` 2. Check channel is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'slack:%'"` 3. For non-main channels: message must include trigger pattern 4. Service is running: `launchctl list | grep nanoclaw` ### Bot connected but not receiving messages 1. Verify Socket Mode is enabled in the Slack app settings 2. Verify the bot is subscribed to the correct events (`message.channels`, `message.groups`, `message.im`) 3. Verify the bot has been added to the channel 4. Check that the bot has the required OAuth scopes ### Bot not seeing messages in channels By default, bots only see messages in channels they've been explicitly added to. Make sure to: 1. Add the bot to each channel you want it to monitor 2. Check the bot has `channels:history` and/or `groups:history` scopes ### "missing_scope" errors If the bot logs `missing_scope` errors: 1. Go to **OAuth & Permissions** in your Slack app settings 2. Add the missing scope listed in the error message 3. **Reinstall the app** to your workspace — scope changes require reinstallation 4. Copy the new Bot Token (it changes on reinstall) and update `.env` 5. Sync: `mkdir -p data/env && cp .env data/env/env` 6. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` ### Getting channel ID If the channel ID is hard to find: - In Slack desktop: right-click channel → **Copy link** → extract the `C...` ID from the URL - In Slack web: the URL shows `https://app.slack.com/client/TXXXXXXX/C0123456789` - Via API: `curl -s -H "Authorization: Bearer $SLACK_BOT_TOKEN" "https://slack.com/api/conversations.list" | jq '.channels[] | {id, name}'` ## After Setup The Slack channel supports: - **Public channels** — Bot must be added to the channel - **Private channels** — Bot must be invited to the channel - **Direct messages** — Users can DM the bot directly - **Multi-channel** — Can run alongside WhatsApp or other channels (auto-enabled by credentials) ## Known Limitations - **Threads are flattened** — Threaded replies are delivered to the agent as regular channel messages. The agent sees them but has no awareness they originated in a thread. Responses always go to the channel, not back into the thread. Users in a thread will need to check the main channel for the bot's reply. Full thread-aware routing (respond in-thread) requires pipeline-wide changes: database schema, `NewMessage` type, `Channel.sendMessage` interface, and routing logic. - **No typing indicator** — Slack's Bot API does not expose a typing indicator endpoint. The `setTyping()` method is a no-op. Users won't see "bot is typing..." while the agent works. - **Message splitting is naive** — Long messages are split at a fixed 4000-character boundary, which may break mid-word or mid-sentence. A smarter split (on paragraph or sentence boundaries) would improve readability. - **No file/image handling** — The bot only processes text content. File uploads, images, and rich message blocks are not forwarded to the agent. - **Channel metadata sync is unbounded** — `syncChannelMetadata()` paginates through all channels the bot is a member of, but has no upper bound or timeout. Workspaces with thousands of channels may experience slow startup. - **Workspace admin policies not detected** — If the Slack workspace restricts bot app installation, the setup will fail at the "Install to Workspace" step with no programmatic detection or guidance. See SLACK_SETUP.md troubleshooting section. ================================================ FILE: .claude/skills/add-telegram/SKILL.md ================================================ --- name: add-telegram description: Add Telegram as a channel. Can replace WhatsApp entirely or run alongside it. Also configurable as a control-only channel (triggers actions) or passive channel (receives notifications only). --- # Add Telegram Channel This skill adds Telegram support to NanoClaw, then walks through interactive setup. ## Phase 1: Pre-flight ### Check if already applied Check if `src/channels/telegram.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place. ### Ask the user Use `AskUserQuestion` to collect configuration: AskUserQuestion: Do you have a Telegram bot token, or do you need to create one? If they have one, collect it now. If not, we'll create one in Phase 3. ## Phase 2: Apply Code Changes ### Ensure channel remote ```bash git remote -v ``` If `telegram` is missing, add it: ```bash git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git ``` ### Merge the skill branch ```bash git fetch telegram main git merge telegram/main || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/channels/telegram.ts` (TelegramChannel class with self-registration via `registerChannel`) - `src/channels/telegram.test.ts` (unit tests with grammy mock) - `import './telegram.js'` appended to the channel barrel file `src/channels/index.ts` - `grammy` npm dependency in `package.json` - `TELEGRAM_BOT_TOKEN` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install npm run build npx vitest run src/channels/telegram.test.ts ``` All tests must pass (including the new Telegram tests) and build must be clean before proceeding. ## Phase 3: Setup ### Create Telegram Bot (if needed) If the user doesn't have a bot token, tell them: > I need you to create a Telegram bot: > > 1. Open Telegram and search for `@BotFather` > 2. Send `/newbot` and follow prompts: > - Bot name: Something friendly (e.g., "Andy Assistant") > - Bot username: Must end with "bot" (e.g., "andy_ai_bot") > 3. Copy the bot token (looks like `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`) Wait for the user to provide the token. ### Configure environment Add to `.env`: ```bash TELEGRAM_BOT_TOKEN= ``` Channels auto-enable when their credentials are present — no extra configuration needed. Sync to container environment: ```bash mkdir -p data/env && cp .env data/env/env ``` The container reads environment from `data/env/env`, not `.env` directly. ### Disable Group Privacy (for group chats) Tell the user: > **Important for group chats**: By default, Telegram bots only see @mentions and commands in groups. To let the bot see all messages: > > 1. Open Telegram and search for `@BotFather` > 2. Send `/mybots` and select your bot > 3. Go to **Bot Settings** > **Group Privacy** > **Turn off** > > This is optional if you only want trigger-based responses via @mentioning the bot. ### Build and restart ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 4: Registration ### Get Chat ID Tell the user: > 1. Open your bot in Telegram (search for its username) > 2. Send `/chatid` — it will reply with the chat ID > 3. For groups: add the bot to the group first, then send `/chatid` in the group Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234567890`). ### Register the chat The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags. For a main chat (responds to all messages): ```bash npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main ``` For additional chats (trigger-only): ```bash npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_" --trigger "@${ASSISTANT_NAME}" --channel telegram ``` ## Phase 5: Verify ### Test the connection Tell the user: > Send a message to your registered Telegram chat: > - For main chat: Any message works > - For non-main: `@Andy hello` or @mention the bot > > The bot should respond within a few seconds. ### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ## Troubleshooting ### Bot not responding Check: 1. `TELEGRAM_BOT_TOKEN` is set in `.env` AND synced to `data/env/env` 2. Chat is registered in SQLite (check with: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE 'tg:%'"`) 3. For non-main chats: message includes trigger pattern 4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) ### Bot only responds to @mentions in groups Group Privacy is enabled (default). Fix: 1. `@BotFather` > `/mybots` > select bot > **Bot Settings** > **Group Privacy** > **Turn off** 2. Remove and re-add the bot to the group (required for the change to take effect) ### Getting chat ID If `/chatid` doesn't work: - Verify token: `curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"` - Check bot is started: `tail -f logs/nanoclaw.log` ## After Setup If running `npm run dev` while the service is active: ```bash # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist npm run dev # When done testing: launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Linux: # systemctl --user stop nanoclaw # npm run dev # systemctl --user start nanoclaw ``` ## Agent Swarms (Teams) After completing the Telegram setup, use `AskUserQuestion`: AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions. If they say yes, invoke the `/add-telegram-swarm` skill. ## Removal To remove Telegram integration: 1. Delete `src/channels/telegram.ts` and `src/channels/telegram.test.ts` 2. Remove `import './telegram.js'` from `src/channels/index.ts` 3. Remove `TELEGRAM_BOT_TOKEN` from `.env` 4. Remove Telegram registrations from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE 'tg:%'"` 5. Uninstall: `npm uninstall grammy` 6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) ================================================ FILE: .claude/skills/add-telegram-swarm/SKILL.md ================================================ --- name: add-telegram-swarm description: Add Agent Swarm (Teams) support to Telegram. Each subagent gets its own bot identity in the group. Requires Telegram channel to be set up first (use /add-telegram). Triggers on "agent swarm", "agent teams telegram", "telegram swarm", "bot pool". --- # Add Agent Swarm to Telegram This skill adds Agent Teams (Swarm) support to an existing Telegram channel. Each subagent in a team gets its own bot identity in the Telegram group, so users can visually distinguish which agent is speaking. **Prerequisite**: Telegram must already be set up via the `/add-telegram` skill. If `src/telegram.ts` does not exist or `TELEGRAM_BOT_TOKEN` is not configured, tell the user to run `/add-telegram` first. ## How It Works - The **main bot** receives messages and sends lead agent responses (already set up by `/add-telegram`) - **Pool bots** are send-only — each gets a Grammy `Api` instance (no polling) - When a subagent calls `send_message` with a `sender` parameter, the host assigns a pool bot and renames it to match the sender's role - Messages appear in Telegram from different bot identities ``` Subagent calls send_message(text: "Found 3 results", sender: "Researcher") → MCP writes IPC file with sender field → Host IPC watcher picks it up → Assigns pool bot #2 to "Researcher" (round-robin, stable per-group) → Renames pool bot #2 to "Researcher" via setMyName → Sends message via pool bot #2's Api instance → Appears in Telegram from "Researcher" bot ``` ## Prerequisites ### 1. Create Pool Bots Tell the user: > I need you to create 3-5 Telegram bots to use as the agent pool. These will be renamed dynamically to match agent roles. > > 1. Open Telegram and search for `@BotFather` > 2. Send `/newbot` for each bot: > - Give them any placeholder name (e.g., "Bot 1", "Bot 2") > - Usernames like `myproject_swarm_1_bot`, `myproject_swarm_2_bot`, etc. > 3. Copy all the tokens > 4. Add all bots to your Telegram group(s) where you want agent teams Wait for user to provide the tokens. ### 2. Disable Group Privacy for Pool Bots Tell the user: > **Important**: Each pool bot needs Group Privacy disabled so it can send messages in groups. > > For each pool bot in `@BotFather`: > 1. Send `/mybots` and select the bot > 2. Go to **Bot Settings** > **Group Privacy** > **Turn off** > > Then add all pool bots to your Telegram group(s). ## Implementation ### Step 1: Update Configuration Read `src/config.ts` and add the bot pool config near the other Telegram exports: ```typescript export const TELEGRAM_BOT_POOL = (process.env.TELEGRAM_BOT_POOL || '') .split(',') .map((t) => t.trim()) .filter(Boolean); ``` ### Step 2: Add Bot Pool to Telegram Module Read `src/telegram.ts` and add the following: 1. **Update imports** — add `Api` to the Grammy import: ```typescript import { Api, Bot } from 'grammy'; ``` 2. **Add pool state** after the existing `let bot` declaration: ```typescript // Bot pool for agent teams: send-only Api instances (no polling) const poolApis: Api[] = []; // Maps "{groupFolder}:{senderName}" → pool Api index for stable assignment const senderBotMap = new Map(); let nextPoolIndex = 0; ``` 3. **Add pool functions** — place these before the `isTelegramConnected` function: ```typescript /** * Initialize send-only Api instances for the bot pool. * Each pool bot can send messages but doesn't poll for updates. */ export async function initBotPool(tokens: string[]): Promise { for (const token of tokens) { try { const api = new Api(token); const me = await api.getMe(); poolApis.push(api); logger.info( { username: me.username, id: me.id, poolSize: poolApis.length }, 'Pool bot initialized', ); } catch (err) { logger.error({ err }, 'Failed to initialize pool bot'); } } if (poolApis.length > 0) { logger.info({ count: poolApis.length }, 'Telegram bot pool ready'); } } /** * Send a message via a pool bot assigned to the given sender name. * Assigns bots round-robin on first use; subsequent messages from the * same sender in the same group always use the same bot. * On first assignment, renames the bot to match the sender's role. */ export async function sendPoolMessage( chatId: string, text: string, sender: string, groupFolder: string, ): Promise { if (poolApis.length === 0) { // No pool bots — fall back to main bot await sendTelegramMessage(chatId, text); return; } const key = `${groupFolder}:${sender}`; let idx = senderBotMap.get(key); if (idx === undefined) { idx = nextPoolIndex % poolApis.length; nextPoolIndex++; senderBotMap.set(key, idx); // Rename the bot to match the sender's role, then wait for Telegram to propagate try { await poolApis[idx].setMyName(sender); await new Promise((r) => setTimeout(r, 2000)); logger.info({ sender, groupFolder, poolIndex: idx }, 'Assigned and renamed pool bot'); } catch (err) { logger.warn({ sender, err }, 'Failed to rename pool bot (sending anyway)'); } } const api = poolApis[idx]; try { const numericId = chatId.replace(/^tg:/, ''); const MAX_LENGTH = 4096; if (text.length <= MAX_LENGTH) { await api.sendMessage(numericId, text); } else { for (let i = 0; i < text.length; i += MAX_LENGTH) { await api.sendMessage(numericId, text.slice(i, i + MAX_LENGTH)); } } logger.info({ chatId, sender, poolIndex: idx, length: text.length }, 'Pool message sent'); } catch (err) { logger.error({ chatId, sender, err }, 'Failed to send pool message'); } } ``` ### Step 3: Add sender Parameter to MCP Tool Read `container/agent-runner/src/ipc-mcp-stdio.ts` and update the `send_message` tool to accept an optional `sender` parameter: Change the tool's schema from: ```typescript { text: z.string().describe('The message text to send') }, ``` To: ```typescript { text: z.string().describe('The message text to send'), sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), }, ``` And update the handler to include `sender` in the IPC data: ```typescript async (args) => { const data: Record = { type: 'message', chatJid, text: args.text, sender: args.sender || undefined, groupFolder, timestamp: new Date().toISOString(), }; writeIpcFile(MESSAGES_DIR, data); return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; }, ``` ### Step 4: Update Host IPC Routing Read `src/ipc.ts` and make these changes: 1. **Add imports** — add `sendPoolMessage` and `initBotPool` from the Telegram swarm module, and `TELEGRAM_BOT_POOL` from config. 2. **Update IPC message routing** — in `src/ipc.ts`, find where the `sendMessage` dependency is called to deliver IPC messages (inside `processIpcFiles`). The `sendMessage` is passed in via the `IpcDeps` parameter. Wrap it to route Telegram swarm messages through the bot pool: ```typescript if (data.sender && data.chatJid.startsWith('tg:')) { await sendPoolMessage( data.chatJid, data.text, data.sender, sourceGroup, ); } else { await deps.sendMessage(data.chatJid, data.text); } ``` Note: The assistant name prefix is handled by `formatOutbound()` in the router — Telegram channels have `prefixAssistantName = false` so no prefix is added for `tg:` JIDs. 3. **Initialize pool in `main()` in `src/index.ts`** — after creating the Telegram channel, add: ```typescript if (TELEGRAM_BOT_POOL.length > 0) { await initBotPool(TELEGRAM_BOT_POOL); } ``` ### Step 5: Update CLAUDE.md Files #### 5a. Add global message formatting rules Read `groups/global/CLAUDE.md` and add a Message Formatting section: ```markdown ## Message Formatting NEVER use markdown. Only use WhatsApp/Telegram formatting: - *single asterisks* for bold (NEVER **double asterisks**) - _underscores_ for italic - • bullet points - ```triple backticks``` for code No ## headings. No [links](url). No **double stars**. ``` #### 5b. Update existing group CLAUDE.md headings In any group CLAUDE.md that has a "WhatsApp Formatting" section (e.g. `groups/main/CLAUDE.md`), rename the heading to reflect multi-channel support: ``` ## WhatsApp Formatting (and other messaging apps) ``` #### 5c. Add Agent Teams instructions to Telegram groups For each Telegram group that will use agent teams, create or update its `groups/{folder}/CLAUDE.md` with these instructions. Read the existing CLAUDE.md first (or `groups/global/CLAUDE.md` as a base) and add the Agent Teams section: ```markdown ## Agent Teams When creating a team to tackle a complex task, follow these rules: ### CRITICAL: Follow the user's prompt exactly Create *exactly* the team the user asked for — same number of agents, same roles, same names. Do NOT add extra agents, rename roles, or use generic names like "Researcher 1". If the user says "a marine biologist, a physicist, and Alexander Hamilton", create exactly those three agents with those exact names. ### Team member instructions Each team member MUST be instructed to: 1. *Share progress in the group* via `mcp__nanoclaw__send_message` with a `sender` parameter matching their exact role/character name (e.g., `sender: "Marine Biologist"` or `sender: "Alexander Hamilton"`). This makes their messages appear from a dedicated bot in the Telegram group. 2. *Also communicate with teammates* via `SendMessage` as normal for coordination. 3. Keep group messages *short* — 2-4 sentences max per message. Break longer content into multiple `send_message` calls. No walls of text. 4. Use the `sender` parameter consistently — always the same name so the bot identity stays stable. 5. NEVER use markdown formatting. Use ONLY WhatsApp/Telegram formatting: single *asterisks* for bold (NOT **double**), _underscores_ for italic, • for bullets, ```backticks``` for code. No ## headings, no [links](url), no **double asterisks**. ### Example team creation prompt When creating a teammate, include instructions like: \``` You are the Marine Biologist. When you have findings or updates for the user, send them to the group using mcp__nanoclaw__send_message with sender set to "Marine Biologist". Keep each message short (2-4 sentences max). Use emojis for strong reactions. ONLY use single *asterisks* for bold (never **double**), _underscores_ for italic, • for bullets. No markdown. Also communicate with teammates via SendMessage. \``` ### Lead agent behavior As the lead agent who created the team: - You do NOT need to react to or relay every teammate message. The user sees those directly from the teammate bots. - Send your own messages only to comment, share thoughts, synthesize, or direct the team. - When processing an internal update from a teammate that doesn't need a user-facing response, wrap your *entire* output in `` tags. - Focus on high-level coordination and the final synthesis. ``` ### Step 6: Update Environment Add pool tokens to `.env`: ```bash TELEGRAM_BOT_POOL=TOKEN1,TOKEN2,TOKEN3,... ``` **Important**: Sync to all required locations: ```bash cp .env data/env/env ``` Also add `TELEGRAM_BOT_POOL` to the launchd plist (`~/Library/LaunchAgents/com.nanoclaw.plist`) in the `EnvironmentVariables` dict if using launchd. ### Step 7: Rebuild and Restart ```bash npm run build ./container/build.sh # Required — MCP tool changed # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Linux: # systemctl --user restart nanoclaw ``` Must use `unload/load` (macOS) or `restart` (Linux) because the service env vars changed. ### Step 8: Test Tell the user: > Send a message in your Telegram group asking for a multi-agent task, e.g.: > "Assemble a team of a researcher and a coder to build me a hello world app" > > You should see: > - The lead agent (main bot) acknowledging and creating the team > - Each subagent messaging from a different bot, renamed to their role > - Short, scannable messages from each agent > > Check logs: `tail -f logs/nanoclaw.log | grep -i pool` ## Architecture Notes - Pool bots use Grammy's `Api` class — lightweight, no polling, just send - Bot names are set via `setMyName` — changes are global to the bot, not per-chat - A 2-second delay after `setMyName` allows Telegram to propagate the name change before the first message - Sender→bot mapping is stable within a group (keyed as `{groupFolder}:{senderName}`) - Mapping resets on service restart — pool bots get reassigned fresh - If pool runs out, bots are reused (round-robin wraps) ## Troubleshooting ### Pool bots not sending messages 1. Verify tokens: `curl -s "https://api.telegram.org/botTOKEN/getMe"` 2. Check pool initialized: `grep "Pool bot" logs/nanoclaw.log` 3. Ensure all pool bots are members of the Telegram group 4. Check Group Privacy is disabled for each pool bot ### Bot names not updating Telegram caches bot names client-side. The 2-second delay after `setMyName` helps, but users may need to restart their Telegram client to see updated names immediately. ### Subagents not using send_message Check the group's `CLAUDE.md` has the Agent Teams instructions. The lead agent reads this when creating teammates and must include the `send_message` + `sender` instructions in each teammate's prompt. ## Removal To remove Agent Swarm support while keeping basic Telegram: 1. Remove `TELEGRAM_BOT_POOL` from `src/config.ts` 2. Remove pool code from `src/telegram.ts` (`poolApis`, `senderBotMap`, `initBotPool`, `sendPoolMessage`) 3. Remove pool routing from IPC handler in `src/index.ts` (revert to plain `sendMessage`) 4. Remove `initBotPool` call from `main()` 5. Remove `sender` param from MCP tool in `container/agent-runner/src/ipc-mcp-stdio.ts` 6. Remove Agent Teams section from group CLAUDE.md files 7. Remove `TELEGRAM_BOT_POOL` from `.env`, `data/env/env`, and launchd plist/systemd unit 8. Rebuild: `npm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `npm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux) ================================================ FILE: .claude/skills/add-voice-transcription/SKILL.md ================================================ --- name: add-voice-transcription description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them. --- # Add Voice Transcription This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: ]`. ## Phase 1: Pre-flight ### Check if already applied Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place. ### Ask the user Use `AskUserQuestion` to collect information: AskUserQuestion: Do you have an OpenAI API key for Whisper transcription? If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys. ## Phase 2: Apply Code Changes **Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files. ### Ensure WhatsApp fork remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp skill/voice-transcription git merge whatsapp/skill/voice-transcription || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/transcription.ts` (voice transcription module using OpenAI Whisper) - Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call) - Transcription tests in `src/channels/whatsapp.test.ts` - `openai` npm dependency in `package.json` - `OPENAI_API_KEY` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install --legacy-peer-deps npm run build npx vitest run src/channels/whatsapp.test.ts ``` All tests must pass and build must be clean before proceeding. ## Phase 3: Configure ### Get OpenAI API key (if needed) If the user doesn't have an API key: > I need you to create an OpenAI API key: > > 1. Go to https://platform.openai.com/api-keys > 2. Click "Create new secret key" > 3. Give it a name (e.g., "NanoClaw Transcription") > 4. Copy the key (starts with `sk-`) > > Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note) Wait for the user to provide the key. ### Add to environment Add to `.env`: ```bash OPENAI_API_KEY= ``` Sync to container environment: ```bash mkdir -p data/env && cp .env data/env/env ``` The container reads environment from `data/env/env`, not `.env` directly. ### Build and restart ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ## Phase 4: Verify ### Test with a voice note Tell the user: > Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: ]` and respond to its content. ### Check logs if needed ```bash tail -f logs/nanoclaw.log | grep -i voice ``` Look for: - `Transcribed voice message` — successful transcription with character count - `OPENAI_API_KEY not set` — key missing from `.env` - `OpenAI transcription failed` — API error (check key validity, billing) - `Failed to download audio message` — media download issue ## Troubleshooting ### Voice notes show "[Voice Message - transcription unavailable]" 1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env` 2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200` 3. Check OpenAI billing — Whisper requires a funded account ### Voice notes show "[Voice Message - transcription failed]" Check logs for the specific error. Common causes: - Network timeout — transient, will work on next message - Invalid API key — regenerate at https://platform.openai.com/api-keys - Rate limiting — wait and retry ### Agent doesn't respond to voice notes Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups. ================================================ FILE: .claude/skills/add-whatsapp/SKILL.md ================================================ --- name: add-whatsapp description: Add WhatsApp as a channel. Can replace other channels entirely or run alongside them. Uses QR code or pairing code for authentication. --- # Add WhatsApp Channel This skill adds WhatsApp support to NanoClaw. It installs the WhatsApp channel code, dependencies, and guides through authentication, registration, and configuration. ## Phase 1: Pre-flight ### Check current state Check if WhatsApp is already configured. If `store/auth/` exists with credential files, skip to Phase 4 (Registration) or Phase 5 (Verify). ```bash ls store/auth/creds.json 2>/dev/null && echo "WhatsApp auth exists" || echo "No WhatsApp auth" ``` ### Detect environment Check whether the environment is headless (no display server): ```bash [[ -z "$DISPLAY" && -z "$WAYLAND_DISPLAY" && "$OSTYPE" != darwin* ]] && echo "IS_HEADLESS=true" || echo "IS_HEADLESS=false" ``` ### Ask the user Use `AskUserQuestion` to collect configuration. **Adapt auth options based on environment:** If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenticate WhatsApp? - **Pairing code** (Recommended) - Enter a numeric code on your phone (no camera needed, requires phone number) - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp? - **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code - **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number) - **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays) If they chose pairing code: AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890) ## Phase 2: Apply Code Changes Check if `src/channels/whatsapp.ts` already exists. If it does, skip to Phase 3 (Authentication). ### Ensure channel remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp main git merge whatsapp/main || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This merges in: - `src/channels/whatsapp.ts` (WhatsAppChannel class with self-registration via `registerChannel`) - `src/channels/whatsapp.test.ts` (41 unit tests) - `src/whatsapp-auth.ts` (standalone WhatsApp authentication script) - `setup/whatsapp-auth.ts` (WhatsApp auth setup step) - `import './whatsapp.js'` appended to the channel barrel file `src/channels/index.ts` - `'whatsapp-auth'` step added to `setup/index.ts` - `@whiskeysockets/baileys`, `qrcode`, `qrcode-terminal` npm dependencies in `package.json` - `ASSISTANT_HAS_OWN_NUMBER` in `.env.example` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm install npm run build npx vitest run src/channels/whatsapp.test.ts ``` All tests must pass and build must be clean before proceeding. ## Phase 3: Authentication ### Clean previous auth state (if re-authenticating) ```bash rm -rf store/auth/ ``` ### Run WhatsApp authentication For QR code in browser (recommended): ```bash npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` (Bash timeout: 150000ms) Tell the user: > A browser window will open with a QR code. > > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** > 2. Scan the QR code in the browser > 3. The page will show "Authenticated!" when done For QR code in terminal: ```bash npx tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal ``` Tell the user to run `npm run auth` in another terminal, then: > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** > 2. Scan the QR code displayed in the terminal For pairing code: Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately. Run the auth process in the background and poll `store/pairing-code.txt` for the code: ```bash rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 & ``` Then immediately poll for the code (do NOT wait for the background command to finish): ```bash for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done ``` Display the code to the user the moment it appears. Tell them: > **Enter this code now** — it expires in ~60 seconds. > > 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device** > 2. Tap **Link with phone number instead** > 3. Enter the code immediately After the user enters the code, poll for authentication to complete: ```bash for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done ``` **If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry. ### Verify authentication succeeded ```bash test -f store/auth/creds.json && echo "Authentication successful" || echo "Authentication failed" ``` ### Configure environment Channels auto-enable when their credentials are present — WhatsApp activates when `store/auth/creds.json` exists. Sync to container environment: ```bash mkdir -p data/env && cp .env data/env/env ``` ## Phase 4: Registration ### Configure trigger and channel type Get the bot's WhatsApp number: `node -e "const c=require('./store/auth/creds.json');console.log(c.me.id.split(':')[0].split('@')[0])"` AskUserQuestion: Is this a shared phone number (personal WhatsApp) or a dedicated number (separate device)? - **Shared number** - Your personal WhatsApp number (recommended: use self-chat or a solo group) - **Dedicated number** - A separate phone/SIM for the assistant AskUserQuestion: What trigger word should activate the assistant? - **@Andy** - Default trigger - **@Claw** - Short and easy - **@Claude** - Match the AI name AskUserQuestion: What should the assistant call itself? - **Andy** - Default name - **Claw** - Short and easy - **Claude** - Match the AI name AskUserQuestion: Where do you want to chat with the assistant? **Shared number options:** - **Self-chat** (Recommended) - Chat in your own "Message Yourself" conversation - **Solo group** - A group with just you and the linked device - **Existing group** - An existing WhatsApp group **Dedicated number options:** - **DM with bot** (Recommended) - Direct message the bot's number - **Solo group** - A group with just you and the bot - **Existing group** - An existing WhatsApp group ### Get the JID **Self-chat:** JID = your phone number with `@s.whatsapp.net`. Extract from auth credentials: ```bash node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')" ``` **DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net` **Group (solo, existing):** Run group sync and list available groups: ```bash npx tsx setup/index.ts --step groups npx tsx setup/index.ts --step groups --list ``` The output shows `JID|GroupName` pairs. Present candidates as AskUserQuestion (names only, not JIDs). ### Register the chat ```bash npx tsx setup/index.ts --step register \ --jid "" \ --name "" \ --trigger "@" \ --folder "whatsapp_main" \ --channel whatsapp \ --assistant-name "" \ --is-main \ --no-trigger-required # Only for main/self-chat ``` For additional groups (trigger-required): ```bash npx tsx setup/index.ts --step register \ --jid "" \ --name "" \ --trigger "@" \ --folder "whatsapp_" \ --channel whatsapp ``` ## Phase 5: Verify ### Build and restart ```bash npm run build ``` Restart the service: ```bash # macOS (launchd) launchctl kickstart -k gui/$(id -u)/com.nanoclaw # Linux (systemd) systemctl --user restart nanoclaw # Linux (nohup fallback) bash start-nanoclaw.sh ``` ### Test the connection Tell the user: > Send a message to your registered WhatsApp chat: > - For self-chat / main: Any message works > - For groups: Use the trigger word (e.g., "@Andy hello") > > The assistant should respond within a few seconds. ### Check logs if needed ```bash tail -f logs/nanoclaw.log ``` ## Troubleshooting ### QR code expired QR codes expire after ~60 seconds. Re-run the auth command: ```bash rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts ``` ### Pairing code not working Codes expire in ~60 seconds. To retry: ```bash rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone ``` Enter the code **immediately** when it appears. Also ensure: 1. Phone number includes country code without `+` (e.g., `1234567890`) 2. Phone has internet access 3. WhatsApp is updated to the latest version If pairing code keeps failing, switch to QR-browser auth instead: ```bash rm -rf store/auth/ && npx tsx setup/index.ts --step whatsapp-auth -- --method qr-browser ``` ### "conflict" disconnection This happens when two instances connect with the same credentials. Ensure only one NanoClaw process is running: ```bash pkill -f "node dist/index.js" # Then restart ``` ### Bot not responding Check: 1. Auth credentials exist: `ls store/auth/creds.json` 3. Chat is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid LIKE '%whatsapp%' OR jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` 4. Service is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux) 5. Logs: `tail -50 logs/nanoclaw.log` ### Group names not showing Run group metadata sync: ```bash npx tsx setup/index.ts --step groups ``` This fetches all group names from WhatsApp. Runs automatically every 24 hours. ## After Setup If running `npm run dev` while the service is active: ```bash # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist npm run dev # When done testing: launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Linux: # systemctl --user stop nanoclaw # npm run dev # systemctl --user start nanoclaw ``` ## Removal To remove WhatsApp integration: 1. Delete auth credentials: `rm -rf store/auth/` 2. Remove WhatsApp registrations: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid LIKE '%@g.us' OR jid LIKE '%@s.whatsapp.net'"` 3. Sync env: `mkdir -p data/env && cp .env data/env/env` 4. Rebuild and restart: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux) ================================================ FILE: .claude/skills/convert-to-apple-container/SKILL.md ================================================ --- name: convert-to-apple-container description: Switch from Docker to Apple Container for macOS-native container isolation. Use when the user wants Apple Container instead of Docker, or is setting up on macOS and prefers the native runtime. Triggers on "apple container", "convert to apple container", "switch to apple container", or "use apple container". --- # Convert to Apple Container This skill switches NanoClaw's container runtime from Docker to Apple Container (macOS-only). It uses the skills engine for deterministic code changes, then walks through verification. **What this changes:** - Container runtime binary: `docker` → `container` - Mount syntax: `-v path:path:ro` → `--mount type=bind,source=...,target=...,readonly` - Startup check: `docker info` → `container system status` (with auto-start) - Orphan detection: `docker ps --filter` → `container ls --format json` - Build script default: `docker` → `container` - Dockerfile entrypoint: `.env` shadowing via `mount --bind` inside the container (Apple Container only supports directory mounts, not file mounts like Docker's `/dev/null` overlay) - Container runner: main-group containers start as root for `mount --bind`, then drop privileges via `setpriv` **What stays the same:** - Mount security/allowlist validation - All exported interfaces and IPC protocol - Non-main container behavior (still uses `--user` flag) - All other functionality ## Prerequisites Verify Apple Container is installed: ```bash container --version && echo "Apple Container ready" || echo "Install Apple Container first" ``` If not installed: - Download from https://github.com/apple/container/releases - Install the `.pkg` file - Verify: `container --version` Apple Container requires macOS. It does not work on Linux. ## Phase 1: Pre-flight ### Check if already applied ```bash grep "CONTAINER_RUNTIME_BIN" src/container-runtime.ts ``` If it already shows `'container'`, the runtime is already Apple Container. Skip to Phase 3. ## Phase 2: Apply Code Changes ### Ensure upstream remote ```bash git remote -v ``` If `upstream` is missing, add it: ```bash git remote add upstream https://github.com/qwibitai/nanoclaw.git ``` ### Merge the skill branch ```bash git fetch upstream skill/apple-container git merge upstream/skill/apple-container ``` This merges in: - `src/container-runtime.ts` — Apple Container implementation (replaces Docker) - `src/container-runtime.test.ts` — Apple Container-specific tests - `src/container-runner.ts` — .env shadow mount fix and privilege dropping - `container/Dockerfile` — entrypoint that shadows .env via `mount --bind` - `container/build.sh` — default runtime set to `container` If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. ### Validate code changes ```bash npm test npm run build ``` All tests must pass and build must be clean before proceeding. ## Phase 3: Verify ### Ensure Apple Container runtime is running ```bash container system status || container system start ``` ### Build the container image ```bash ./container/build.sh ``` ### Test basic execution ```bash echo '{}' | container run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK" ``` ### Test readonly mounts ```bash mkdir -p /tmp/test-ro && echo "test" > /tmp/test-ro/file.txt container run --rm --entrypoint /bin/bash \ --mount type=bind,source=/tmp/test-ro,target=/test,readonly \ nanoclaw-agent:latest \ -c "cat /test/file.txt && touch /test/new.txt 2>&1 || echo 'Write blocked (expected)'" rm -rf /tmp/test-ro ``` Expected: Read succeeds, write fails with "Read-only file system". ### Test read-write mounts ```bash mkdir -p /tmp/test-rw container run --rm --entrypoint /bin/bash \ -v /tmp/test-rw:/test \ nanoclaw-agent:latest \ -c "echo 'test write' > /test/new.txt && cat /test/new.txt" cat /tmp/test-rw/new.txt && rm -rf /tmp/test-rw ``` Expected: Both operations succeed. ### Full integration test ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` Send a message via WhatsApp and verify the agent responds. ## Troubleshooting **Apple Container not found:** - Download from https://github.com/apple/container/releases - Install the `.pkg` file - Verify: `container --version` **Runtime won't start:** ```bash container system start container system status ``` **Image build fails:** ```bash # Clean rebuild — Apple Container caches aggressively container builder stop && container builder rm && container builder start ./container/build.sh ``` **Container can't write to mounted directories:** Check directory permissions on the host. The container runs as uid 1000. ## Summary of Changed Files | File | Type of Change | |------|----------------| | `src/container-runtime.ts` | Full replacement — Docker → Apple Container API | | `src/container-runtime.test.ts` | Full replacement — tests for Apple Container behavior | | `src/container-runner.ts` | .env shadow mount removed, main containers start as root with privilege drop | | `container/Dockerfile` | Entrypoint: `mount --bind` for .env shadowing, `setpriv` privilege drop | | `container/build.sh` | Default runtime: `docker` → `container` | ================================================ FILE: .claude/skills/customize/SKILL.md ================================================ --- name: customize description: Add new capabilities or modify NanoClaw behavior. Use when user wants to add channels (Telegram, Slack, email input), change triggers, add integrations, modify the router, or make any other customizations. This is an interactive skill that asks questions to understand what the user wants. --- # NanoClaw Customization This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes. ## Workflow 1. **Understand the request** - Ask clarifying questions 3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually. 4. **Implement** - Make changes directly to the code 5. **Test guidance** - Tell user how to verify ## Key Files | File | Purpose | |------|---------| | `src/index.ts` | Orchestrator: state, message loop, agent invocation | | `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive | | `src/ipc.ts` | IPC watcher and task processing | | `src/router.ts` | Message formatting and outbound routing | | `src/types.ts` | TypeScript interfaces (includes Channel) | | `src/config.ts` | Assistant name, trigger pattern, directories | | `src/db.ts` | Database initialization and queries | | `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script | | `groups/CLAUDE.md` | Global memory/persona | ## Common Customization Patterns ### Adding a New Input Channel (e.g., Telegram, Slack, Email) Questions to ask: - Which channel? (Telegram, Slack, Discord, email, SMS, etc.) - Same trigger word or different? - Same memory hierarchy or separate? - Should messages from this channel go to existing groups or new ones? Implementation pattern: 1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference) 2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`) 3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()` ### Adding a New MCP Integration Questions to ask: - What service? (Calendar, Notion, database, etc.) - What operations needed? (read, write, both) - Which groups should have access? Implementation: 1. Add MCP server config to the container settings (see `src/container-runner.ts` for how MCP servers are mounted) 2. Document available tools in `groups/CLAUDE.md` ### Changing Assistant Behavior Questions to ask: - What aspect? (name, trigger, persona, response style) - Apply to all groups or specific ones? Simple changes → edit `src/config.ts` Persona changes → edit `groups/CLAUDE.md` Per-group behavior → edit specific group's `CLAUDE.md` ### Adding New Commands Questions to ask: - What should the command do? - Available in all groups or main only? - Does it need new MCP tools? Implementation: 1. Commands are handled by the agent naturally — add instructions to `groups/CLAUDE.md` or the group's `CLAUDE.md` 2. For trigger-level routing changes, modify `processGroupMessages()` in `src/index.ts` ### Changing Deployment Questions to ask: - Target platform? (Linux server, Docker, different Mac) - Service manager? (systemd, Docker, supervisord) Implementation: 1. Create appropriate service files 2. Update paths in config 3. Provide setup instructions ## After Changes Always tell the user: ```bash # Rebuild and restart npm run build # macOS: launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Linux: # systemctl --user restart nanoclaw ``` ## Example Interaction User: "Add Telegram as an input channel" 1. Ask: "Should Telegram use the same @Andy trigger, or a different one?" 2. Ask: "Should Telegram messages create separate conversation contexts, or share with WhatsApp groups?" 3. Create `src/channels/telegram.ts` implementing the `Channel` interface (see `src/channels/whatsapp.ts`) 4. Add the channel to `main()` in `src/index.ts` 5. Tell user how to authenticate and test ================================================ FILE: .claude/skills/debug/SKILL.md ================================================ --- name: debug description: Debug container agent issues. Use when things aren't working, container fails, authentication problems, or to understand how the container system works. Covers logs, environment variables, mounts, and common issues. --- # NanoClaw Container Debugging This guide covers debugging the containerized agent execution system. ## Architecture Overview ``` Host (macOS) Container (Linux VM) ───────────────────────────────────────────────────────────── src/container-runner.ts container/agent-runner/ │ │ │ spawns container │ runs Claude Agent SDK │ with volume mounts │ with MCP servers │ │ ├── data/env/env ──────────────> /workspace/env-dir/env ├── groups/{folder} ───────────> /workspace/group ├── data/ipc/{folder} ────────> /workspace/ipc ├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group) └── (main only) project root ──> /workspace/project ``` **Important:** The container runs as user `node` with `HOME=/home/node`. Session files must be mounted to `/home/node/.claude/` (not `/root/.claude/`) for session resumption to work. ## Log Locations | Log | Location | Content | |-----|----------|---------| | **Main app logs** | `logs/nanoclaw.log` | Host-side WhatsApp, routing, container spawning | | **Main app errors** | `logs/nanoclaw.error.log` | Host-side errors | | **Container run logs** | `groups/{folder}/logs/container-*.log` | Per-run: input, mounts, stderr, stdout | | **Claude sessions** | `~/.claude/projects/` | Claude Code session history | ## Enabling Debug Logging Set `LOG_LEVEL=debug` for verbose output: ```bash # For development LOG_LEVEL=debug npm run dev # For launchd service (macOS), add to plist EnvironmentVariables: LOG_LEVEL debug # For systemd service (Linux), add to unit [Service] section: # Environment=LOG_LEVEL=debug ``` Debug level shows: - Full mount configurations - Container command arguments - Real-time container stderr ## Common Issues ### 1. "Claude Code process exited with code 1" **Check the container log file** in `groups/{folder}/logs/container-*.log` Common causes: #### Missing Authentication ``` Invalid API key · Please run /login ``` **Fix:** Ensure `.env` file exists with either OAuth token or API key: ```bash cat .env # Should show one of: # CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription) # ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use) ``` #### Root User Restriction ``` --dangerously-skip-permissions cannot be used with root/sudo privileges ``` **Fix:** Container must run as non-root user. Check Dockerfile has `USER node`. ### 2. Environment Variables Not Passing **Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin). **Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed. To verify env vars are reaching the container: ```bash echo '{}' | docker run -i \ -v $(pwd)/data/env:/workspace/env-dir:ro \ --entrypoint /bin/bash nanoclaw-agent:latest \ -c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"' ``` ### 3. Mount Issues **Container mount notes:** - Docker supports both `-v` and `--mount` syntax - Use `:ro` suffix for readonly mounts: ```bash # Readonly -v /path:/container/path:ro # Read-write -v /path:/container/path ``` To check what's mounted inside a container: ```bash docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/' ``` Expected structure: ``` /workspace/ ├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY) ├── group/ # Current group folder (cwd) ├── project/ # Project root (main channel only) ├── global/ # Global CLAUDE.md (non-main only) ├── ipc/ # Inter-process communication │ ├── messages/ # Outgoing WhatsApp messages │ ├── tasks/ # Scheduled task commands │ ├── current_tasks.json # Read-only: scheduled tasks visible to this group │ └── available_groups.json # Read-only: WhatsApp groups for activation (main only) └── extra/ # Additional custom mounts ``` ### 4. Permission Issues The container runs as user `node` (uid 1000). Check ownership: ```bash docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' whoami ls -la /workspace/ ls -la /app/ ' ``` All of `/workspace/` and `/app/` should be owned by `node`. ### 5. Session Not Resuming / "Claude Code process exited with code 1" If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming: **Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`. **Check the mount path:** ```bash # In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/ grep -A3 "Claude sessions" src/container-runner.ts ``` **Verify sessions are accessible:** ```bash docker run --rm --entrypoint /bin/bash \ -v ~/.claude:/home/node/.claude \ nanoclaw-agent:latest -c ' echo "HOME=$HOME" ls -la $HOME/.claude/projects/ 2>&1 | head -5 ' ``` **Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`: ```typescript mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', // NOT /root/.claude readonly: false }); ``` ### 6. MCP Server Failures If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors. ## Manual Container Testing ### Test the full agent flow: ```bash # Set up env file mkdir -p data/env groups/test cp .env data/env/env # Run test query echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \ docker run -i \ -v $(pwd)/data/env:/workspace/env-dir:ro \ -v $(pwd)/groups/test:/workspace/group \ -v $(pwd)/data/ipc:/workspace/ipc \ nanoclaw-agent:latest ``` ### Test Claude Code directly: ```bash docker run --rm --entrypoint /bin/bash \ -v $(pwd)/data/env:/workspace/env-dir:ro \ nanoclaw-agent:latest -c ' export $(cat /workspace/env-dir/env | xargs) claude -p "Say hello" --dangerously-skip-permissions --allowedTools "" ' ``` ### Interactive shell in container: ```bash docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest ``` ## SDK Options Reference The agent-runner uses these Claude Agent SDK options: ```typescript query({ prompt: input.prompt, options: { cwd: '/workspace/group', allowedTools: ['Bash', 'Read', 'Write', ...], permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, // Required with bypassPermissions settingSources: ['project'], mcpServers: { ... } } }) ``` **Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1. ## Rebuilding After Changes ```bash # Rebuild main app npm run build # Rebuild container (use --no-cache for clean rebuild) ./container/build.sh # Or force full rebuild docker builder prune -af ./container/build.sh ``` ## Checking Container Image ```bash # List images docker images # Check what's in the image docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c ' echo "=== Node version ===" node --version echo "=== Claude Code version ===" claude --version echo "=== Installed packages ===" ls /app/node_modules/ ' ``` ## Session Persistence Claude sessions are stored per-group in `data/sessions/{group}/.claude/` for security isolation. Each group has its own session directory, preventing cross-group access to conversation history. **Critical:** The mount path must match the container user's HOME directory: - Container user: `node` - Container HOME: `/home/node` - Mount target: `/home/node/.claude/` (NOT `/root/.claude/`) To clear sessions: ```bash # Clear all sessions for all groups rm -rf data/sessions/ # Clear sessions for a specific group rm -rf data/sessions/{groupFolder}/.claude/ # Also clear the session ID from NanoClaw's tracking (stored in SQLite) sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'" ``` To verify session resumption is working, check the logs for the same session ID across messages: ```bash grep "Session initialized" logs/nanoclaw.log | tail -5 # Should show the SAME session ID for consecutive messages in the same group ``` ## IPC Debugging The container communicates back to the host via files in `/workspace/ipc/`: ```bash # Check pending messages ls -la data/ipc/messages/ # Check pending task operations ls -la data/ipc/tasks/ # Read a specific IPC file cat data/ipc/messages/*.json # Check available groups (main channel only) cat data/ipc/main/available_groups.json # Check current tasks snapshot cat data/ipc/{groupFolder}/current_tasks.json ``` **IPC file types:** - `messages/*.json` - Agent writes: outgoing WhatsApp messages - `tasks/*.json` - Agent writes: task operations (schedule, pause, resume, cancel, refresh_groups) - `current_tasks.json` - Host writes: read-only snapshot of scheduled tasks - `available_groups.json` - Host writes: read-only list of WhatsApp groups (main only) ## Quick Diagnostic Script Run this to check common issues: ```bash echo "=== Checking NanoClaw Container Setup ===" echo -e "\n1. Authentication configured?" [ -f .env ] && (grep -q "CLAUDE_CODE_OAUTH_TOKEN=sk-" .env || grep -q "ANTHROPIC_API_KEY=sk-" .env) && echo "OK" || echo "MISSING - add CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY to .env" echo -e "\n2. Env file copied for container?" [ -f data/env/env ] && echo "OK" || echo "MISSING - will be created on first run" echo -e "\n3. Container runtime running?" docker info &>/dev/null && echo "OK" || echo "NOT RUNNING - start Docker Desktop (macOS) or sudo systemctl start docker (Linux)" echo -e "\n4. Container image exists?" echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "OK" 2>/dev/null || echo "MISSING - run ./container/build.sh" echo -e "\n5. Session mount path correct?" grep -q "/home/node/.claude" src/container-runner.ts 2>/dev/null && echo "OK" || echo "WRONG - should mount to /home/node/.claude/, not /root/.claude/" echo -e "\n6. Groups directory?" ls -la groups/ 2>/dev/null || echo "MISSING - run setup" echo -e "\n7. Recent container logs?" ls -t groups/*/logs/container-*.log 2>/dev/null | head -3 || echo "No container logs yet" echo -e "\n8. Session continuity working?" SESSIONS=$(grep "Session initialized" logs/nanoclaw.log 2>/dev/null | tail -5 | awk '{print $NF}' | sort -u | wc -l) [ "$SESSIONS" -le 2 ] && echo "OK (recent sessions reusing IDs)" || echo "CHECK - multiple different session IDs, may indicate resumption issues" ``` ================================================ FILE: .claude/skills/get-qodo-rules/SKILL.md ================================================ --- name: get-qodo-rules description: "Loads org- and repo-level coding rules from Qodo before code tasks begin, ensuring all generation and modification follows team standards. Use before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning." version: 2.0.0 allowed-tools: ["Bash"] triggers: - "get.?qodo.?rules" - "get.?rules" - "load.?qodo.?rules" - "load.?rules" - "fetch.?qodo.?rules" - "fetch.?rules" - "qodo.?rules" - "coding.?rules" - "code.?rules" - "before.?cod" - "start.?coding" - "write.?code" - "implement" - "create.*code" - "build.*feature" - "add.*feature" - "fix.*bug" - "refactor" - "modify.*code" - "update.*code" --- # Get Qodo Rules Skill ## Description Fetches repository-specific coding rules from the Qodo platform API before code generation or modification tasks. Rules include security requirements, coding standards, quality guidelines, and team conventions that must be applied during code generation. **Use** before any code generation or modification task when rules are not already loaded. Invoke when user asks to write, edit, refactor, or review code, or when starting implementation planning. **Skip** if "Qodo Rules Loaded" already appears in conversation context --- ## Workflow ### Step 1: Check if Rules Already Loaded If rules are already loaded (look for "Qodo Rules Loaded" in recent messages), skip to step 6. ### Step 2: Verify working in a git repository - Check that the current directory is inside a git repository. If not, inform the user that a git repository is required and exit gracefully. - Extract the repository scope from the git `origin` remote URL. If no remote is found, exit silently. If the URL cannot be parsed, inform the user and exit gracefully. - Detect module-level scope: if inside a `modules/*` subdirectory, use it as the query scope; otherwise use repository-wide scope. See [repository scope detection](references/repository-scope.md) for details. ### Step 3: Verify Qodo Configuration Check that the required Qodo configuration is present. The default location is `~/.qodo/config.json`. - **API key**: Read from `~/.qodo/config.json` (`API_KEY` field). If not found, inform the user that an API key is required and provide setup instructions, then exit gracefully. - **Environment name**: Read from `~/.qodo/config.json` (`ENVIRONMENT_NAME` field), with `QODO_ENVIRONMENT_NAME` environment variable taking precedence. If not found, inform the user that an API key is required and provide setup instructions, then exit gracefully. ### Step 4: Fetch Rules with Pagination - Fetch all pages from the API (50 rules per page) until no more results are returned. - On each page, handle HTTP errors and exit gracefully with a user-friendly message. - Accumulate all rules across pages into a single list. - Stop after 100 pages maximum (safety limit). - If no rules are found after all pages, inform the user and exit gracefully. See [pagination details](references/pagination.md) for the full algorithm and error handling. ### Step 5: Format and Output Rules - Print the "📋 Qodo Rules Loaded" header with repository scope, scope context, and total rule count. - Group rules by severity and print each non-empty group: ERROR, WARNING, RECOMMENDATION. - Each rule is formatted as: `- **{name}** ({category}): {description}` - End output with `---`. See [output format details](references/output-format.md) for the exact format. ### Step 6: Apply Rules by Severity | Severity | Enforcement | When Skipped | |---|---|---| | **ERROR** | Must comply, non-negotiable. Add comment documenting compliance (e.g., `# Following Qodo rule: No Hardcoded Credentials`) | Explain to user and ask for guidance | | **WARNING** | Should comply by default | Briefly explain why in response | | **RECOMMENDATION** | Consider when appropriate | No action needed | ### Step 7: Report After code generation, inform the user about rule application: - **ERROR rules applied**: List which rules were followed - **WARNING rules skipped**: Explain why - **No rules applicable**: Inform: "No Qodo rules were applicable to this code change" - **RECOMMENDATION rules**: Mention only if they influenced a design decision --- ## How Scope Levels Work Determines scope from git remote and working directory (see [Step 2](#step-2-verify-working-in-a-git-repository)): **Scope Hierarchy**: - **Universal** (`/`) - applies everywhere - **Org Level** (`/org/`) - applies to organization - **Repo Level** (`/org/repo/`) - applies to repository - **Path Level** (`/org/repo/path/`) - applies to specific paths --- ## Configuration See `~/.qodo/config.json` for API key setup. Set `QODO_ENVIRONMENT_NAME` env var or `ENVIRONMENT_NAME` in config to select environment. --- ## Common Mistakes - **Re-running when rules are loaded** - Check for "Qodo Rules Loaded" in context first - **Missing compliance comments on ERROR rules** - ERROR rules require a comment documenting compliance - **Forgetting to report when no rules apply** - Always inform the user when no rules were applicable, so they know the rules system is active - **Not in git repo** - Inform the user that a git repository is required and exit gracefully; do not attempt code generation - **No API key** - Inform the user with setup instructions; set `QODO_API_KEY` or create `~/.qodo/config.json` - **No rules found** - Inform the user; set up rules at app.qodo.ai ================================================ FILE: .claude/skills/get-qodo-rules/references/output-format.md ================================================ # Formatting and Outputting Rules ## Output Structure Print the following header: ``` # 📋 Qodo Rules Loaded Scope: `{QUERY_SCOPE}` Rules loaded: **{TOTAL_RULES}** (universal, org level, repo level, and path level rules) These rules must be applied during code generation based on severity: ``` ## Grouping by Severity Group rules into three sections and print each non-empty section: **ERROR** (`severity == "error"`): ``` ## ❌ ERROR Rules (Must Comply) - {count} - **{name}** ({category}): {description} ``` **WARNING** (`severity == "warning"`): ``` ## ⚠️ WARNING Rules (Should Comply) - {count} - **{name}** ({category}): {description} ``` **RECOMMENDATION** (`severity == "recommendation"`): ``` ## 💡 RECOMMENDATION Rules (Consider) - {count} - **{name}** ({category}): {description} ``` End output with `---`. ================================================ FILE: .claude/skills/get-qodo-rules/references/pagination.md ================================================ # Fetching Rules with Pagination The API returns rules in pages of 50. All pages must be fetched to ensure no rules are missed. ## Algorithm 1. Start with `page=1`, `page_size=50`, accumulate results in an empty list 2. Request: `GET {API_URL}/rules?scopes={ENCODED_SCOPE}&state=active&page={PAGE}&page_size=50` - Header: `Authorization: Bearer {API_KEY}` 3. On non-200 response, handle the error and exit gracefully: - `401` — invalid/expired API key - `403` — access forbidden - `404` — endpoint not found (check `QODO_ENVIRONMENT_NAME`) - `429` — rate limit exceeded - `5xx` — API temporarily unavailable - connection error — check internet connection 4. Parse `rules` array from JSON response body 5. Append page rules to accumulated list 6. If rules returned on this page < 50 → last page, stop 7. Otherwise increment page and repeat from step 2 8. Safety limit: stop after 100 pages (5000 rules max) ## API URL Construct `{API_URL}` from `ENVIRONMENT_NAME` (read from `~/.qodo/config.json`): | `ENVIRONMENT_NAME` | `{API_URL}` | |---|---| | set (e.g. `staging`) | `https://qodo-platform.staging.qodo.ai/rules/v1` | ## After Fetching If total rules == 0, inform the user no rules are configured for the repository scope and exit gracefully. ================================================ FILE: .claude/skills/get-qodo-rules/references/repository-scope.md ================================================ # Repository Scope Detection ## Extracting Repository Scope from Git Remote URL Parse the `origin` remote URL to derive the scope path. Both URL formats are supported: - SSH: `git@github.com:org/repo.git` → `/org/repo/` - HTTPS: `https://github.com/org/repo.git` → `/org/repo/` If no remote is found, exit silently. If the URL cannot be parsed, inform the user and exit gracefully. ## Module-Level Scope Detection If the current working directory is inside a `modules/*` subdirectory relative to the repository root, use it as the query scope: - `modules/rules/src/service.py` → query scope: `/org/repo/modules/rules/` - repository root or any other path → query scope: `/org/repo/` ## Scope Hierarchy The API returns all rules matching the query scope via prefix matching: | Query scope | Rules returned | |---|---| | `/org/repo/modules/rules/` | universal + org + repo + path-level rules | | `/org/repo/` | universal + org + repo-level rules | ================================================ FILE: .claude/skills/qodo-pr-resolver/SKILL.md ================================================ --- name: qodo-pr-resolver description: Review and resolve PR issues with Qodo - get AI-powered code review issues and fix them interactively (GitHub, GitLab, Bitbucket, Azure DevOps) version: 0.3.0 triggers: - qodo.?pr.?resolver - pr.?resolver - resolve.?pr - qodo.?fix - fix.?qodo - qodo.?review - review.?qodo - qodo.?issues? - show.?qodo - get.?qodo - qodo.?resolve --- # Qodo PR Resolver Fetch Qodo review issues for your current branch's PR/MR, fix them interactively or in batch, and reply to each inline comment with the decision. Supports GitHub, GitLab, Bitbucket, and Azure DevOps. ## Prerequisites ### Required Tools: - **Git** - For branch operations - **Git Provider CLI** - One of: `gh` (GitHub), `glab` (GitLab), `bb` (Bitbucket), or `az` (Azure DevOps) **Installation and authentication details:** See [providers.md](./resources/providers.md) for provider-specific setup instructions. ### Required Context: - Must be in a git repository - Repository must be hosted on a supported git provider (GitHub, GitLab, Bitbucket, or Azure DevOps) - Current branch must have an open PR/MR - PR/MR must have been reviewed by Qodo (pr-agent-pro bot, qodo-merge[bot], etc.) ### Quick Check: ```bash git --version # Check git installed git remote get-url origin # Identify git provider ``` See [providers.md](./resources/providers.md) for provider-specific verification commands. ## Understanding Qodo Reviews Qodo (formerly Codium AI) is an AI-powered code review tool that analyzes PRs/MRs with compliance checks, bug detection, and code quality suggestions. ### Bot Identifiers Look for comments from: **`pr-agent-pro`**, **`pr-agent-pro-staging`**, **`qodo-merge[bot]`**, **`qodo-ai[bot]`** ### Review Comment Types 1. **PR Compliance Guide** 🔍 - Security/ticket/custom compliance with 🟢/🟡/🔴/⚪ indicators 2. **PR Code Suggestions** ✨ - Categorized improvements with importance ratings 3. **Code Review by Qodo** - Structured issues with 🐞/📘/📎 sections and agent prompts (most detailed) ## Instructions When the user asks for a code review, to see Qodo issues, or fix Qodo comments: ### Step 0: Check code push status Check for uncommitted changes, unpushed commits, and get the current branch. #### Scenario A: Uncommitted changes exist - Inform: "⚠️ You have uncommitted changes. These won't be included in the Qodo review." - Ask: "Would you like to commit and push them first?" - If yes: Wait for user action, then proceed to Step 1 - If no: Warn "Proceeding with review of pushed code only" and continue to Step 1 #### Scenario B: Unpushed commits exist (no uncommitted changes) - Inform: "⚠️ You have N unpushed commits. Qodo hasn't reviewed them yet." - Ask: "Would you like to push them now?" - If yes: Execute `git push`, inform "Pushed! Qodo will review shortly. Please wait ~5 minutes then run this skill again." - Exit skill (don't proceed - Qodo needs time to review) - If no: Warn "Proceeding with existing PR review" and continue to Step 1 #### Scenario C: Everything pushed (both uncommitted changes and unpushed commits are empty) - Proceed to Step 1 ### Step 1: Detect git provider Detect git provider from the remote URL (`git remote get-url origin`). See [providers.md](./resources/providers.md) for provider detection patterns. ### Step 2: Find the open PR/MR Find the open PR/MR for this branch using the provider's CLI. See [providers.md § Find Open PR/MR](./resources/providers.md#find-open-prmr) for provider-specific commands. ### Step 3: Get Qodo review comments Get the Qodo review comments using the provider's CLI. Qodo typically posts both a **summary comment** (PR-level, containing all issues) and **inline review comments** (one per issue, attached to specific lines of code). You must fetch both. See [providers.md § Fetch Review Comments](./resources/providers.md#fetch-review-comments) for provider-specific commands. Look for comments where the author is "qodo-merge[bot]", "pr-agent-pro", "pr-agent-pro-staging" or similar Qodo bot name. #### Step 3a: Check if review is still in progress - If any comment contains "Come back again in a few minutes" or "An AI review agent is analysing this pull request", the review is still running - In this case, inform the user: "⏳ Qodo review is still in progress. Please wait a few minutes and try again." - Exit early - don't try to parse incomplete reviews #### Step 3b: Deduplicate issues Deduplicate issues across summary and inline comments: - Qodo posts each issue in two places: once in the **summary comment** (PR-level) and once as an **inline review comment** (attached to the specific code line). These will share the same issue title. - Qodo may also post multiple summary comments (Compliance Guide, Code Suggestions, Code Review, etc.) where issues can overlap with slightly different wording. - Deduplicate by matching on **issue title** (primary key - the same title means the same issue): - If an issue appears in both the summary comment and as an inline comment, merge them into a single issue - Prefer the **inline comment** for file location (it has the exact line context) - Prefer the **summary comment** for severity, type, and agent prompt (it is more detailed) - **IMPORTANT:** Preserve each issue's **inline review comment ID** — you will need it later (Step 8) to reply directly to that comment with the decision - Also deduplicate across multiple summary comments by location (file path + line numbers) as a secondary key - If the same issue appears in multiple places, combine the agent prompts ### Step 4: Parse and display the issues - Extract the review body/comments from Qodo's review - Parse out individual issues/suggestions - **IMPORTANT: Preserve Qodo's exact issue titles verbatim** — do not rename, paraphrase, or summarize them. Use the title exactly as Qodo wrote it. - **IMPORTANT: Preserve Qodo's original ordering** — display issues in the same order Qodo listed them. Qodo already orders by severity. - Extract location, issue description, and suggested fix - Extract the agent prompt from Qodo's suggestion (the description of what needs to be fixed) #### Severity mapping Derive severity from Qodo's action level and position: 1. **Action level determines severity range:** - **"Action required"** issues → Can only be 🔴 CRITICAL or 🟠 HIGH - **"Review recommended"** / **"Remediation recommended"** issues → Can only be 🟡 MEDIUM or ⚪ LOW - **"Other"** / **"Advisory comments"** issues → Always ⚪ LOW (lowest priority) 2. **Qodo's position within each action level determines the specific severity:** - Group issues by action level ("Action required" vs "Review recommended" vs "Other") - Within "Action required" and "Review recommended" groups: earlier positions → higher severity, later positions → lower severity - Split point: roughly first half of each group gets the higher severity, second half gets the lower - All "Other" issues are treated as ⚪ LOW regardless of position **Example:** 7 "Action required" issues would be split as: - Issues 1-3: 🔴 CRITICAL - Issues 4-7: 🟠 HIGH - Result: No MEDIUM or LOW issues (because there are no "Review recommended" or "Other" issues) **Example:** 5 "Action required" + 3 "Review recommended" + 2 "Other" issues would be split as: - Issues 1-2 or 1-3: 🔴 CRITICAL (first ~half of "Action required") - Issues 3-5 or 4-5: 🟠 HIGH (second ~half of "Action required") - Issues 6-7: 🟡 MEDIUM (first ~half of "Review recommended") - Issue 8: ⚪ LOW (second ~half of "Review recommended") - Issues 9-10: ⚪ LOW (all "Other" issues) **Action guidelines:** - 🔴 CRITICAL / 🟠 HIGH ("Action required"): Always "Fix" - 🟡 MEDIUM ("Review recommended"): Usually "Fix", can "Defer" if low impact - ⚪ LOW ("Review recommended" or "Other"): Can be "Defer" unless quick to fix; "Other" issues are lowest priority #### Output format Display as a markdown table in Qodo's exact original ordering (do NOT reorder by severity - Qodo's order IS the severity ranking): ``` Qodo Issues for PR #123: [PR Title] | # | Severity | Issue Title | Issue Details | Type | Action | |---|----------|-------------|---------------|------|--------| | 1 | 🔴 CRITICAL | Insecure authentication check | • **Location:** src/auth/service.py:42

• **Issue:** Authorization logic is inverted | 🐞 Bug ⛨ Security | Fix | | 2 | 🔴 CRITICAL | Missing input validation | • **Location:** src/api/handlers.py:156

• **Issue:** User input not sanitized before database query | 📘 Rule violation ⛯ Reliability | Fix | | 3 | 🟠 HIGH | Database query not awaited | • **Location:** src/db/repository.py:89

• **Issue:** Async call missing await keyword | 🐞 Bug ✓ Correctness | Fix | ``` ### Step 5: Ask user for fix preference After displaying the table, ask the user how they want to proceed using AskUserQuestion: **Options:** - 🔍 "Review each issue" - Review and approve/defer each issue individually (recommended for careful review) - ⚡ "Auto-fix all" - Automatically apply all fixes marked as "Fix" without individual approval (faster, but less control) - ❌ "Cancel" - Exit without making changes **Based on the user's choice:** - If "Review each issue": Proceed to Step 6 (manual review) - If "Auto-fix all": Skip to Step 7 (auto-fix mode - apply all "Fix" issues automatically using Qodo's agent prompts) - If "Cancel": Exit the skill ### Step 6: Review and fix issues (manual mode) If "Review each issue" was selected: - For each issue marked as "Fix" (starting with CRITICAL): - Read the relevant file(s) to understand the current code - Implement the fix by **executing the Qodo agent prompt as a direct instruction**. The agent prompt is the fix specification — follow it literally, do not reinterpret or improvise a different solution. Only deviate if the prompt is clearly outdated relative to the current code (e.g. references lines that no longer exist). - Calculate the proposed fix in memory (DO NOT use Edit or Write tool yet) - **Present the fix and ask for approval in a SINGLE step:** 1. Show a brief header with issue title and location 2. **Show Qodo's agent prompt in full** so the user can verify the fix matches it 3. Display current code snippet 4. Display proposed change as markdown diff 5. Immediately use AskUserQuestion with these options: - ✅ "Apply fix" - Apply the proposed change - ⏭️ "Defer" - Skip this issue (will prompt for reason) - 🔧 "Modify" - User wants to adjust the fix first - **WAIT for user's choice via AskUserQuestion** - **If "Apply fix" selected:** - Apply change using Edit tool (or Write if creating new file) - Reply to the Qodo inline comment with the decision (see Step 8 for inline reply commands) - Git commit the fix: `git add && git commit -m "fix: "` - Confirm: "✅ Fix applied, commented, and committed!" - Mark issue as completed - **If "Defer" selected:** - Ask for deferral reason using AskUserQuestion - Reply to the Qodo inline comment with the deferral (see Step 8 for inline reply commands) - Record reason and move to next issue - **If "Modify" selected:** - Inform user they can make changes manually - Move to next issue - Continue until all "Fix" issues are addressed or the user decides to stop #### Important notes **Single-step approval with AskUserQuestion:** - NO native Edit UI (no persistent permissions possible) - Each fix requires explicit approval via custom question - Clearer options, no risk of accidental auto-approval **CRITICAL:** Single validation only - do NOT show the diff separately and then ask. Combine the diff display and the question into ONE message. The user should see: brief context → current code → proposed diff → AskUserQuestion, all at once. **Example:** Show location, Qodo's guidance, current code, proposed diff, then AskUserQuestion with options (✅ Apply fix / ⏭️ Defer / 🔧 Modify). Wait for user choice, apply via Edit tool if approved. ### Step 7: Auto-fix mode If "Auto-fix all" was selected: - For each issue marked as "Fix" (starting with CRITICAL): - Read the relevant file(s) to understand the current code - Implement the fix by **executing the Qodo agent prompt as a direct instruction**. The agent prompt is the fix specification — follow it literally, do not reinterpret or improvise a different solution. Only deviate if the prompt is clearly outdated relative to the current code (e.g. references lines that no longer exist). - Apply the fix using Edit tool - Reply to the Qodo inline comment with the decision (see Step 8 for inline reply commands) - Git commit the fix: `git add && git commit -m "fix: "` - Report each fix with the agent prompt that was followed: > ✅ **Fixed: [Issue Title]** at `[Location]` > **Agent prompt:** [the Qodo agent prompt used] - Mark issue as completed - After all auto-fixes are applied, display summary: - List of all issues that were fixed - List of any issues that were skipped (with reasons) ### Step 8: Post summary to PR/MR **REQUIRED:** After all issues have been reviewed (fixed or deferred), ALWAYS post a comment summarizing the actions taken, even if all issues were deferred. See [providers.md § Post Summary Comment](./resources/providers.md#post-summary-comment) for provider-specific commands and summary format. **After posting the summary, resolve the Qodo review comment:** Find the Qodo "Code Review by Qodo" comment and mark it as resolved or react to acknowledge it. See [providers.md § Resolve Qodo Review Comment](./resources/providers.md#resolve-qodo-review-comment) for provider-specific commands. If resolve fails (comment not found, API error), continue — the summary comment is the important part. ### Step 9: Push to remote If any fixes were applied (commits were created in Steps 6/7), ask the user if they want to push: - If yes: `git push` - If no: Inform them they can push later with `git push` **Important:** If all issues were deferred, there are no commits to push — skip this step. ### Special cases #### Unsupported git provider If the remote URL doesn't match GitHub, GitLab, Bitbucket, or Azure DevOps, inform the user and exit. See [providers.md § Error Handling](./resources/providers.md#error-handling) for details. #### No PR/MR exists - Inform: "No PR/MR found for branch ``" - Ask: "Would you like me to create a PR/MR?" - If yes: Use appropriate CLI to create PR/MR (see [providers.md § Create PR/MR](./resources/providers.md#create-prmr-special-case)), then inform "PR created! Qodo will review it shortly. Run this skill again in ~5 minutes." - If no: Exit skill **IMPORTANT:** Do NOT proceed without a PR/MR #### No Qodo review yet - Check if PR/MR has comments from Qodo bots (pr-agent-pro, qodo-merge[bot], etc.) - If no Qodo comments found: Inform "Qodo hasn't reviewed this PR/MR yet. Please wait a few minutes for Qodo to analyze it." - Exit skill (do NOT attempt manual review) **IMPORTANT:** This skill only works with Qodo reviews, not manual reviews #### Review in progress If "Come back again in a few minutes" message is found, inform user to wait and try again, then exit. #### Missing CLI tool If the detected provider's CLI is not installed, provide installation instructions and exit. See [providers.md § Error Handling](./resources/providers.md#error-handling) for provider-specific installation commands. #### Inline reply commands Used per-issue in Steps 6 and 7 to reply to Qodo's inline comments: Use the inline comment ID preserved during deduplication (Step 3b) to reply directly to Qodo's comment. See [providers.md § Reply to Inline Comments](./resources/providers.md#reply-to-inline-comments) for provider-specific commands and reply format. Keep replies short (one line). If a reply fails, log it and continue. ================================================ FILE: .claude/skills/qodo-pr-resolver/resources/providers.md ================================================ # Git Provider Commands Reference This document contains all provider-specific CLI commands and API interactions for the Qodo PR Resolver skill. Reference this file when implementing provider-specific operations. ## Supported Providers - GitHub (via `gh` CLI) - GitLab (via `glab` CLI) - Bitbucket (via `bb` CLI) - Azure DevOps (via `az` CLI with DevOps extension) ## Provider Detection Detect the git provider from the remote URL: ```bash git remote get-url origin ``` Match against: - `github.com` → GitHub - `gitlab.com` → GitLab - `bitbucket.org` → Bitbucket - `dev.azure.com` → Azure DevOps ## Prerequisites by Provider ### GitHub **CLI:** `gh` - **Install:** `brew install gh` or [cli.github.com](https://cli.github.com/) - **Authenticate:** `gh auth login` - **Verify:** ```bash gh --version && gh auth status ``` ### GitLab **CLI:** `glab` - **Install:** `brew install glab` or [glab.readthedocs.io](https://glab.readthedocs.io/) - **Authenticate:** `glab auth login` - **Verify:** ```bash glab --version && glab auth status ``` ### Bitbucket **CLI:** `bb` or API access - **Install:** See [bitbucket.org/product/cli](https://bitbucket.org/product/cli) - **Verify:** ```bash bb --version ``` ### Azure DevOps **CLI:** `az` with DevOps extension - **Install:** `brew install azure-cli` or [docs.microsoft.com/cli/azure](https://docs.microsoft.com/cli/azure) - **Install extension:** `az extension add --name azure-devops` - **Authenticate:** `az login` then `az devops configure --defaults organization=https://dev.azure.com/yourorg project=yourproject` - **Verify:** ```bash az --version && az devops ``` ## Find Open PR/MR Get the PR/MR number for the current branch: ### GitHub ```bash gh pr list --head --state open --json number,title ``` ### GitLab ```bash glab mr list --source-branch --state opened ``` ### Bitbucket ```bash bb pr list --source-branch --state OPEN ``` ### Azure DevOps ```bash az repos pr list --source-branch --status active --output json ``` ## Fetch Review Comments Qodo posts both **summary comments** (PR-level) and **inline review comments** (per-line). Fetch both. ### GitHub ```bash # PR-level comments (includes the summary comment with all issues) gh pr view --json comments # Inline review comments (per-line comments on specific code) gh api repos/{owner}/{repo}/pulls//comments ``` ### GitLab ```bash # All MR notes including inline comments glab mr view --comments ``` ### Bitbucket ```bash # All PR comments including inline comments bb pr view --comments ``` ### Azure DevOps ```bash # PR-level threads (includes summary comments) az repos pr show --id --output json # All PR threads including inline comments az repos pr policy list --id --output json az repos pr thread list --id --output json ``` ## Reply to Inline Comments Use the inline comment ID preserved during deduplication to reply directly to Qodo's comments. ### GitHub ```bash gh api repos/{owner}/{repo}/pulls//comments//replies \ -X POST \ -f body='' ``` **Reply format:** - **Fixed:** `✅ **Fixed** — ` - **Deferred:** `⏭️ **Deferred** — ` ### GitLab ```bash glab api "/projects/:id/merge_requests//discussions//notes" \ -X POST \ -f body='' ``` ### Bitbucket ```bash bb api "/2.0/repositories/{workspace}/{repo}/pullrequests//comments" \ -X POST \ -f 'content.raw=' \ -f 'parent.id=' ``` ### Azure DevOps ```bash az repos pr thread comment add \ --id \ --thread-id \ --content '' ``` ## Post Summary Comment After reviewing all issues, post a summary comment to the PR/MR. ### GitHub ```bash gh pr comment --body '' ``` ### GitLab ```bash glab mr comment --message '' ``` ### Bitbucket ```bash bb pr comment '' ``` ### Azure DevOps ```bash az repos pr thread create \ --id \ --comment-content '' ``` **Summary format:** ```markdown ## Qodo Fix Summary Reviewed and addressed Qodo review issues: ### ✅ Fixed Issues - **Issue Title** (Severity) - Brief description of what was fixed ### ⏭️ Deferred Issues - **Issue Title** (Severity) - Reason for deferring --- *Generated by Qodo PR Resolver skill* ``` ## Resolve Qodo Review Comment After posting the summary, resolve the main Qodo review comment. **Steps:** 1. Fetch all PR/MR comments 2. Find the Qodo bot comment containing "Code Review by Qodo" 3. Resolve or react to the comment ### GitHub ```bash # 1. Fetch comments to find the comment ID gh pr view --json comments # 2. React with thumbs up to acknowledge gh api "repos/{owner}/{repo}/issues/comments//reactions" \ -X POST \ -f content='+1' ``` ### GitLab ```bash # 1. Fetch discussions to find the discussion ID glab api "/projects/:id/merge_requests//discussions" # 2. Resolve the discussion glab api "/projects/:id/merge_requests//discussions/" \ -X PUT \ -f resolved=true ``` ### Bitbucket ```bash # Fetch comments via bb api, find the comment ID, then update to resolved status bb api "/2.0/repositories/{workspace}/{repo}/pullrequests//comments/" \ -X PUT \ -f 'resolved=true' ``` ### Azure DevOps ```bash # Mark the thread as resolved az repos pr thread update \ --id \ --thread-id \ --status resolved ``` ## Create PR/MR (Special Case) If no PR/MR exists for the current branch, offer to create one. ### GitHub ```bash gh pr create --title '' --body '<body>' ``` ### GitLab ```bash glab mr create --title '<title>' --description '<body>' ``` ### Bitbucket ```bash bb pr create --title '<title>' --description '<body>' ``` ### Azure DevOps ```bash az repos pr create \ --title '<title>' \ --description '<body>' \ --source-branch <branch-name> \ --target-branch main ``` ## Error Handling ### Missing CLI Tool If the detected provider's CLI is not installed: 1. Inform the user: "❌ Missing required CLI tool: `<cli-name>`" 2. Provide installation instructions from the Prerequisites section 3. Exit the skill ### Unsupported Provider If the remote URL doesn't match any supported provider: 1. Inform: "❌ Unsupported git provider detected: `<url>`" 2. List supported providers: GitHub, GitLab, Bitbucket, Azure DevOps 3. Exit the skill ### API Failures If inline reply or summary posting fails: - Log the error - Continue with remaining operations - The workflow should not abort due to comment posting failures ================================================ FILE: .claude/skills/setup/SKILL.md ================================================ --- name: setup description: Run initial NanoClaw setup. Use when user wants to install dependencies, authenticate messaging channels, register their main channel, or start the background services. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests. --- # NanoClaw Setup Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices). Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step <name>` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`. **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work. **UX Note:** Use `AskUserQuestion` for all user-facing questions. ## 0. Git & Fork Setup Check the git remote configuration to ensure the user has a fork and upstream is configured. Run: - `git remote -v` **Case A — `origin` points to `qwibitai/nanoclaw` (user cloned directly):** The user cloned instead of forking. AskUserQuestion: "You cloned NanoClaw directly. We recommend forking so you can push your customizations. Would you like to set up a fork?" - Fork now (recommended) — walk them through it - Continue without fork — they'll only have local changes If fork: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask them for their GitHub username. Run: ```bash git remote rename origin upstream git remote add origin https://github.com/<their-username>/nanoclaw.git git push --force origin main ``` Verify with `git remote -v`. If continue without fork: add upstream so they can still pull updates: ```bash git remote add upstream https://github.com/qwibitai/nanoclaw.git ``` **Case B — `origin` points to user's fork, no `upstream` remote:** Add upstream: ```bash git remote add upstream https://github.com/qwibitai/nanoclaw.git ``` **Case C — both `origin` (user's fork) and `upstream` (qwibitai) exist:** Already configured. Continue. **Verify:** `git remote -v` should show `origin` → user's repo, `upstream` → `qwibitai/nanoclaw.git`. ## 1. Bootstrap (Node.js + Dependencies) Run `bash setup.sh` and parse the status block. - If NODE_OK=false → Node.js is missing or too old. Use `AskUserQuestion: Would you like me to install Node.js 22?` If confirmed: - macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22` - Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm - After installing Node, re-run `bash setup.sh` - If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry. - If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run. - Record PLATFORM and IS_WSL for later steps. ## 2. Check Environment Run `npx tsx setup/index.ts --step environment` and parse the status block. - If HAS_AUTH=true → WhatsApp is already configured, note for step 5 - If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure - Record APPLE_CONTAINER and DOCKER values for step 3 ## 3. Container Runtime ### 3a. Choose runtime Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1. - PLATFORM=linux → Docker (only option) - PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c. - PLATFORM=macos + APPLE_CONTAINER=not_found → Docker ### 3a-docker. Install Docker - DOCKER=running → continue to 4b - DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`. - DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed: - macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop - Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership. ### 3b. Apple Container conversion gate (if needed) **If the chosen runtime is Apple Container**, you MUST check whether the source code has already been converted from Docker to Apple Container. Do NOT skip this step. Run: ```bash grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "ALREADY_CONVERTED" || echo "NEEDS_CONVERSION" ``` **If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step. **If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c. **If the chosen runtime is Docker**, no conversion is needed. Continue to 3c. ### 3c. Build and test Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse the status block. **If BUILD_OK=false:** Read `logs/setup.log` tail for the build error. - Cache issue (stale layers): `docker builder prune -f` (Docker) or `container builder stop && container builder rm && container builder start` (Apple Container). Retry. - Dockerfile syntax or missing files: diagnose from the log and fix, then retry. **If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test. ## 4. Claude Authentication (No Script) If HAS_ENV=true from step 2, read `.env` and check for `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`. If present, confirm with user: keep or reconfigure? AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key? **Subscription:** Tell user to run `claude setup-token` in another terminal, copy the token, add `CLAUDE_CODE_OAUTH_TOKEN=<token>` to `.env`. Do NOT collect the token in chat. **API key:** Tell user to add `ANTHROPIC_API_KEY=<key>` to `.env`. ## 5. Set Up Channels AskUserQuestion (multiSelect): Which messaging channels do you want to enable? - WhatsApp (authenticates via QR code or pairing code) - Telegram (authenticates via bot token from @BotFather) - Slack (authenticates via Slack app with Socket Mode) - Discord (authenticates via Discord bot token) **Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct. For each selected channel, invoke its skill: - **WhatsApp:** Invoke `/add-whatsapp` - **Telegram:** Invoke `/add-telegram` - **Slack:** Invoke `/add-slack` - **Discord:** Invoke `/add-discord` Each skill will: 1. Install the channel code (via `git merge` of the skill branch) 2. Collect credentials/tokens and write to `.env` 3. Authenticate (WhatsApp QR/pairing, or verify token-based connection) 4. Register the chat with the correct JID format 5. Build and verify **After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages: ```bash npm install && npm run build ``` If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6. ## 6. Mount Allowlist AskUserQuestion: Agent access to external directories? **No:** `npx tsx setup/index.ts --step mounts -- --empty` **Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'` ## 7. Start Service If service already running: unload first. - macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` - Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root) Run `npx tsx setup/index.ts --step service` and parse the status block. **If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper. **If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands: 1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock` 2. Persistent fix (re-applies after every Docker restart): ```bash sudo mkdir -p /etc/systemd/system/docker.service.d sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF' [Service] ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock EOF sudo systemctl daemon-reload ``` Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step. **If SERVICE_LOADED=false:** - Read `logs/setup.log` for the error. - macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`. - Linux: check `systemctl --user status nanoclaw`. - Re-run the service step after fixing. ## 8. Verify Run `npx tsx setup/index.ts --step verify` and parse the status block. **If STATUS=failed, fix each:** - SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup) - SERVICE=not_found → re-run step 7 - CREDENTIALS=missing → re-run step 4 - CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`) - REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5 - MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty` Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log` ## Troubleshooting **Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), missing channel credentials (re-invoke channel skill). **Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`. **No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `npx tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`. **Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change. **Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw` ================================================ FILE: .claude/skills/update-nanoclaw/SKILL.md ================================================ --- name: update-nanoclaw description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage. --- # About Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications. Run `/update-nanoclaw` in Claude Code. ## How it works **Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`). **Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times. **Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories: - **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill - **Source** (`src/`): may conflict if you modified the same files - **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed **Update paths** (you pick one): - `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass. - `cherry-pick`: `git cherry-pick <hashes>`. Pull in only the commits you want. - `rebase`: `git rebase upstream/<branch>`. Linear history, but conflicts resolve per-commit. - `abort`: just view the changelog, change nothing. **Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point. **Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact. **Validation**: runs `npm run build` and `npm test`. **Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate. ## Rollback The backup tag is printed at the end of each run: ``` git reset --hard pre-update-<hash>-<timestamp> ``` Backup branch `backup/pre-update-<hash>-<timestamp>` also exists. ## Token usage Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git status` for everything else. Does not scan or refactor unrelated code. --- # Goal Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens. # Operating principles - Never proceed with a dirty working tree. - Always create a rollback point (backup branch + tag) before touching anything. - Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers. - Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option. - Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files. # Step 0: Preflight (stop early if unsafe) Run: - `git status --porcelain` If output is non-empty: - Tell the user to commit or stash first, then stop. Confirm remotes: - `git remote -v` If `upstream` is missing: - Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`). - Add it: `git remote add upstream <user-provided-url>` - Then: `git fetch upstream --prune` Determine the upstream branch name: - `git branch -r | grep upstream/` - If `upstream/main` exists, use `main`. - If only `upstream/master` exists, use `master`. - Otherwise, ask the user which branch to use. - Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead. Fetch: - `git fetch upstream --prune` # Step 1: Create a safety net Capture current state: - `HASH=$(git rev-parse --short HEAD)` - `TIMESTAMP=$(date +%Y%m%d-%H%M%S)` Create backup branch and tag (using timestamp to avoid collisions on retry): - `git branch backup/pre-update-$HASH-$TIMESTAMP` - `git tag pre-update-$HASH-$TIMESTAMP` Save the tag name for later reference in the summary and rollback instructions. # Step 2: Preview what upstream changed (no edits yet) Compute common base: - `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` Show upstream commits since BASE: - `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` Show local commits since BASE (custom drift): - `git log --oneline $BASE..HEAD` Show file-level impact from upstream: - `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH` Bucket the upstream changed files: - **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill - **Source** (`src/`): may conflict if user modified the same files - **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed - **Other**: docs, tests, misc Present these buckets to the user and ask them to choose one path using AskUserQuestion: - A) **Full update**: merge all upstream changes - B) **Selective update**: cherry-pick specific upstream commits - C) **Abort**: they only wanted the preview - D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit) If Abort: stop here. # Step 3: Conflict preview (before committing anything) If Full update or Rebase: - Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes: ``` git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort ``` - If conflicts were listed: show them and ask user if they want to proceed. - If no conflicts: tell user it is clean and proceed. # Step 4A: Full update (MERGE, default) Run: - `git merge upstream/$UPSTREAM_BRANCH --no-edit` If conflicts occur: - Run `git status` and identify conflicted files. - For each conflicted file: - Open the file. - Resolve only conflict markers. - Preserve intentional local customizations. - Incorporate upstream fixes/improvements. - Do not refactor surrounding code. - `git add <file>` - When all resolved: - If merge did not auto-commit: `git commit --no-edit` # Step 4B: Selective update (CHERRY-PICK) If user chose Selective: - Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)` - Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH` - Ask user which commit hashes they want. - Apply: `git cherry-pick <hash1> <hash2> ...` If conflicts during cherry-pick: - Resolve only conflict markers, then: - `git add <file>` - `git cherry-pick --continue` If user wants to stop: - `git cherry-pick --abort` # Step 4C: Rebase (only if user explicitly chose option D) Run: - `git rebase upstream/$UPSTREAM_BRANCH` If conflicts: - Resolve conflict markers only, then: - `git add <file>` - `git rebase --continue` If it gets messy (more than 3 rounds of conflicts): - `git rebase --abort` - Recommend merge instead. # Step 5: Validation Run: - `npm run build` - `npm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. - Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code). - Do not refactor unrelated code. - If unclear, ask the user before making changes. # Step 6: Breaking changes check After validation succeeds, check if the update introduced any breaking changes. Determine which CHANGELOG entries are new by diffing against the backup tag: - `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md` Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is: ``` [BREAKING] <description>. Run `/<skill-name>` to <action>. ``` If no `[BREAKING]` lines are found: - Skip this step silently. Proceed to Step 7 (skill updates check). If one or more `[BREAKING]` lines are found: - Display a warning header to the user: "This update includes breaking changes that may require action:" - For each breaking change, display the full description. - Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part). - Use AskUserQuestion to ask the user which migration skills they want to run now. Options: - One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel") - "Skip — I'll handle these manually" - Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes. - For each skill the user selects, invoke it using the Skill tool. - After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check). # Step 7: Check for skill updates After the summary, check if skills are distributed as branches in this repo: - `git branch -r --list 'upstream/skill/*'` If any `upstream/skill/*` branches exist: - Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?" - Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates") - Option 2: "No, skip" (description: "You can run /update-skills later any time") - If user selects yes, invoke `/update-skills` using the Skill tool. - After the skill completes (or if user selected no), proceed to Step 8. # Step 8: Summary + rollback instructions Show: - Backup tag: the tag name created in Step 1 - New HEAD: `git rev-parse --short HEAD` - Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH` - Conflicts resolved (list files, if any) - Breaking changes applied (list skills run, if any) - Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD` Tell the user: - To rollback: `git reset --hard <backup-tag-from-step-1>` - Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>` - Restart the service to apply changes: - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` - If running manually: restart `npm run dev` ================================================ FILE: .claude/skills/update-skills/SKILL.md ================================================ --- name: update-skills description: Check for and apply updates to installed skill branches from upstream. --- # About Skills are distributed as git branches (`skill/*`). When you install a skill, you merge its branch into your repo. This skill checks upstream for newer commits on those skill branches and helps you update. Run `/update-skills` in Claude Code. ## How it works **Preflight**: checks for clean working tree and upstream remote. **Detection**: fetches upstream, lists all `upstream/skill/*` branches, determines which ones you've previously merged (via merge-base), and checks if any have new commits. **Selection**: presents a list of skills with available updates. You pick which to update. **Update**: merges each selected skill branch, resolves conflicts if any, then validates with build + test. --- # Goal Help users update their installed skill branches from upstream without losing local customizations. # Operating principles - Never proceed with a dirty working tree. - Only offer updates for skills the user has already merged (installed). - Use git-native operations. Do not manually rewrite files except conflict markers. - Keep token usage low: rely on `git` commands, only open files with actual conflicts. # Step 0: Preflight Run: - `git status --porcelain` If output is non-empty: - Tell the user to commit or stash first, then stop. Check remotes: - `git remote -v` If `upstream` is missing: - Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`). - `git remote add upstream <url>` Fetch: - `git fetch upstream --prune` # Step 1: Detect installed skills with available updates List all upstream skill branches: - `git branch -r --list 'upstream/skill/*'` For each `upstream/skill/<name>`: 1. Check if the user has merged this skill branch before: - `git merge-base --is-ancestor upstream/skill/<name>~1 HEAD` — if this succeeds (exit 0) for any ancestor commit of the skill branch, the user has merged it at some point. A simpler check: `git log --oneline --merges --grep="skill/<name>" HEAD` to see if there's a merge commit referencing this branch. - Alternative: `MERGE_BASE=$(git merge-base HEAD upstream/skill/<name>)` — if the merge base is NOT the initial commit and the merge base includes commits unique to the skill branch, it has been merged. - Simplest reliable check: compare `git merge-base HEAD upstream/skill/<name>` with `git merge-base HEAD upstream/main`. If the skill merge-base is strictly ahead of (or different from) the main merge-base, the user has merged this skill. 2. Check if there are new commits on the skill branch not yet in HEAD: - `git log --oneline HEAD..upstream/skill/<name>` - If this produces output, there are updates available. Build three lists: - **Updates available**: skills that are merged AND have new commits - **Up to date**: skills that are merged and have no new commits - **Not installed**: skills that have never been merged # Step 2: Present results If no skills have updates available: - Tell the user all installed skills are up to date. List them. - If there are uninstalled skills, mention them briefly (e.g., "3 other skills available in upstream that you haven't installed"). - Stop here. If updates are available: - Show the list of skills with updates, including the number of new commits for each: ``` skill/<name>: 3 new commits skill/<other>: 1 new commit ``` - Also show skills that are up to date (for context). - Use AskUserQuestion with `multiSelect: true` to let the user pick which skills to update. - One option per skill with updates, labeled with the skill name and commit count. - Add an option: "Skip — don't update any skills now" - If user selects Skip, stop here. # Step 3: Apply updates For each selected skill (process one at a time): 1. Tell the user which skill is being updated. 2. Run: `git merge upstream/skill/<name> --no-edit` 3. If the merge is clean, move to the next skill. 4. If conflicts occur: - Run `git status` to identify conflicted files. - For each conflicted file: - Open the file. - Resolve only conflict markers. - Preserve intentional local customizations. - `git add <file>` - Complete the merge: `git commit --no-edit` If a merge fails badly (e.g., cannot resolve conflicts): - `git merge --abort` - Tell the user this skill could not be auto-updated and they should resolve it manually. - Continue with the remaining skills. # Step 4: Validation After all selected skills are merged: - `npm run build` - `npm test` (do not fail the flow if tests are not configured) If build fails: - Show the error. - Only fix issues clearly caused by the merge (missing imports, type mismatches). - Do not refactor unrelated code. - If unclear, ask the user. # Step 5: Summary Show: - Skills updated (list) - Skills skipped or failed (if any) - New HEAD: `git rev-parse --short HEAD` - Any conflicts that were resolved (list files) If the service is running, remind the user to restart it to pick up changes. ================================================ FILE: .claude/skills/use-local-whisper/SKILL.md ================================================ --- name: use-local-whisper description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first. --- # Use Local Whisper Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost. **Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them. **Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`. ## Prerequisites - `voice-transcription` skill must be applied first (WhatsApp channel) - macOS with Apple Silicon (M1+) recommended - `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary) - `ffmpeg` installed: `brew install ffmpeg` - A GGML model file downloaded to `data/models/` ## Phase 1: Pre-flight ### Check if already applied Check if `src/transcription.ts` already uses `whisper-cli`: ```bash grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied" ``` If already applied, skip to Phase 3 (Verify). ### Check dependencies are installed ```bash whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING" ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING" ``` If missing, install via Homebrew: ```bash brew install whisper-cpp ffmpeg ``` ### Check for model file ```bash ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL" ``` If no model exists, download the base model (148MB, good balance of speed and accuracy): ```bash mkdir -p data/models curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin" ``` For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB). ## Phase 2: Apply Code Changes ### Ensure WhatsApp fork remote ```bash git remote -v ``` If `whatsapp` is missing, add it: ```bash git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git ``` ### Merge the skill branch ```bash git fetch whatsapp skill/local-whisper git merge whatsapp/skill/local-whisper || { git checkout --theirs package-lock.json git add package-lock.json git merge --continue } ``` This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API. ### Validate ```bash npm run build ``` ## Phase 3: Verify ### Ensure launchd PATH includes Homebrew The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH. Check the current PATH: ```bash grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist ``` If `/opt/homebrew/bin` is missing, add it to the `<string>` value inside the `PATH` key in the plist. Then reload: ```bash launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist ``` ### Build and restart ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ### Test Send a voice note in any registered group. The agent should receive it as `[Voice: <transcript>]`. ### Check logs ```bash tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper" ``` Look for: - `Transcribed voice message` — successful transcription - `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH ## Configuration Environment variables (optional, set in `.env`): | Variable | Default | Description | |----------|---------|-------------| | `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary | | `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file | ## Troubleshooting **"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually: ```bash ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt ``` **Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3. **Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing. **Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`. ================================================ FILE: .claude/skills/x-integration/SKILL.md ================================================ --- name: x-integration description: X (Twitter) integration for NanoClaw. Post tweets, like, reply, retweet, and quote. Use for setup, testing, or troubleshooting X functionality. Triggers on "setup x", "x integration", "twitter", "post tweet", "tweet". --- # X (Twitter) Integration Browser automation for X interactions via WhatsApp. > **Compatibility:** NanoClaw v1.0.0. Directory structure may change in future versions. ## Features | Action | Tool | Description | |--------|------|-------------| | Post | `x_post` | Publish new tweets | | Like | `x_like` | Like any tweet | | Reply | `x_reply` | Reply to tweets | | Retweet | `x_retweet` | Retweet without comment | | Quote | `x_quote` | Quote tweet with comment | ## Prerequisites Before using this skill, ensure: 1. **NanoClaw is installed and running** - WhatsApp connected, service active 2. **Dependencies installed**: ```bash npm ls playwright dotenv-cli || npm install playwright dotenv-cli ``` 3. **CHROME_PATH configured** in `.env` (if Chrome is not at default location): ```bash # Find your Chrome path mdfind "kMDItemCFBundleIdentifier == 'com.google.Chrome'" 2>/dev/null | head -1 # Add to .env CHROME_PATH=/path/to/Google Chrome.app/Contents/MacOS/Google Chrome ``` ## Quick Start ```bash # 1. Setup authentication (interactive) npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts # Verify: data/x-auth.json should exist after successful login # 2. Rebuild container to include skill ./container/build.sh # Verify: Output shows "COPY .claude/skills/x-integration/agent.ts" # 3. Rebuild host and restart service npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw # Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux) ``` ## Configuration ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `CHROME_PATH` | `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome` | Chrome executable path | | `NANOCLAW_ROOT` | `process.cwd()` | Project root directory | | `LOG_LEVEL` | `info` | Logging level (debug, info, warn, error) | Set in `.env` file (loaded via `dotenv-cli` at runtime): ```bash # .env CHROME_PATH=/Applications/Google Chrome.app/Contents/MacOS/Google Chrome ``` ### Configuration File Edit `lib/config.ts` to modify defaults: ```typescript export const config = { // Browser viewport viewport: { width: 1280, height: 800 }, // Timeouts (milliseconds) timeouts: { navigation: 30000, // Page navigation elementWait: 5000, // Wait for element afterClick: 1000, // Delay after click afterFill: 1000, // Delay after form fill afterSubmit: 3000, // Delay after submit pageLoad: 3000, // Initial page load }, // Tweet limits limits: { tweetMaxLength: 280, }, }; ``` ### Data Directories Paths relative to project root: | Path | Purpose | Git | |------|---------|-----| | `data/x-browser-profile/` | Chrome profile with X session | Ignored | | `data/x-auth.json` | Auth state marker | Ignored | | `logs/nanoclaw.log` | Service logs (contains X operation logs) | Ignored | ## Architecture ``` ┌─────────────────────────────────────────────────────────────┐ │ Container (Linux VM) │ │ └── agent.ts → MCP tool definitions (x_post, etc.) │ │ └── Writes IPC request to /workspace/ipc/tasks/ │ └──────────────────────┬──────────────────────────────────────┘ │ IPC (file system) ▼ ┌─────────────────────────────────────────────────────────────┐ │ Host (macOS) │ │ └── src/ipc.ts → processTaskIpc() │ │ └── host.ts → handleXIpc() │ │ └── spawn subprocess → scripts/*.ts │ │ └── Playwright → Chrome → X Website │ └─────────────────────────────────────────────────────────────┘ ``` ### Why This Design? - **API is expensive** - X official API requires paid subscription ($100+/month) for posting - **Bot browsers get blocked** - X detects and bans headless browsers and common automation fingerprints - **Must use user's real browser** - Reuses the user's actual Chrome on Host with real browser fingerprint to avoid detection - **One-time authorization** - User logs in manually once, session persists in Chrome profile for future use ### File Structure ``` .claude/skills/x-integration/ ├── SKILL.md # This documentation ├── host.ts # Host-side IPC handler ├── agent.ts # Container-side MCP tool definitions ├── lib/ │ ├── config.ts # Centralized configuration │ └── browser.ts # Playwright utilities └── scripts/ ├── setup.ts # Interactive login ├── post.ts # Post tweet ├── like.ts # Like tweet ├── reply.ts # Reply to tweet ├── retweet.ts # Retweet └── quote.ts # Quote tweet ``` ### Integration Points To integrate this skill into NanoClaw, make the following modifications: --- **1. Host side: `src/ipc.ts`** Add import after other local imports: ```typescript import { handleXIpc } from '../.claude/skills/x-integration/host.js'; ``` Modify `processTaskIpc` function's switch statement default case: ```typescript // Find: default: logger.warn({ type: data.type }, 'Unknown IPC task type'); // Replace with: default: const handled = await handleXIpc(data, sourceGroup, isMain, DATA_DIR); if (!handled) { logger.warn({ type: data.type }, 'Unknown IPC task type'); } ``` --- **2. Container side: `container/agent-runner/src/ipc-mcp.ts`** Add import after `cron-parser` import: ```typescript // @ts-ignore - Copied during Docker build from .claude/skills/x-integration/ import { createXTools } from './skills/x-integration/agent.js'; ``` Add to the end of tools array (before the closing `]`): ```typescript ...createXTools({ groupFolder, isMain }) ``` --- **3. Build script: `container/build.sh`** Change build context from `container/` to project root (required to access `.claude/skills/`): ```bash # Find: docker build -t "${IMAGE_NAME}:${TAG}" . # Replace with: cd "$SCRIPT_DIR/.." docker build -t "${IMAGE_NAME}:${TAG}" -f container/Dockerfile . ``` --- **4. Dockerfile: `container/Dockerfile`** First, update the build context paths (required to access `.claude/skills/` from project root): ```dockerfile # Find: COPY agent-runner/package*.json ./ ... COPY agent-runner/ ./ # Replace with: COPY container/agent-runner/package*.json ./ ... COPY container/agent-runner/ ./ ``` Then add COPY line after `COPY container/agent-runner/ ./` and before `RUN npm run build`: ```dockerfile # Copy skill MCP tools COPY .claude/skills/x-integration/agent.ts ./src/skills/x-integration/ ``` ## Setup All paths below are relative to project root (`NANOCLAW_ROOT`). ### 1. Check Chrome Path ```bash # Check if Chrome exists at configured path cat .env | grep CHROME_PATH ls -la "$(grep CHROME_PATH .env | cut -d= -f2)" 2>/dev/null || \ echo "Chrome not found - update CHROME_PATH in .env" ``` ### 2. Run Authentication ```bash npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts ``` This opens Chrome for manual X login. Session saved to `data/x-browser-profile/`. **Verify success:** ```bash cat data/x-auth.json # Should show {"authenticated": true, ...} ``` ### 3. Rebuild Container ```bash ./container/build.sh ``` **Verify success:** ```bash ./container/build.sh 2>&1 | grep -i "agent.ts" # Should show COPY line ``` ### 4. Restart Service ```bash npm run build launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` **Verify success:** ```bash launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or - # Linux: systemctl --user status nanoclaw ``` ## Usage via WhatsApp Replace `@Assistant` with your configured trigger name (`ASSISTANT_NAME` in `.env`): ``` @Assistant post a tweet: Hello world! @Assistant like this tweet https://x.com/user/status/123 @Assistant reply to https://x.com/user/status/123 with: Great post! @Assistant retweet https://x.com/user/status/123 @Assistant quote https://x.com/user/status/123 with comment: Interesting ``` **Note:** Only the main group can use X tools. Other groups will receive an error. ## Testing Scripts require environment variables from `.env`. Use `dotenv-cli` to load them: ### Check Authentication Status ```bash # Check if auth file exists and is valid cat data/x-auth.json 2>/dev/null && echo "Auth configured" || echo "Auth not configured" # Check if browser profile exists ls -la data/x-browser-profile/ 2>/dev/null | head -5 ``` ### Re-authenticate (if expired) ```bash npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts ``` ### Test Post (will actually post) ```bash echo '{"content":"Test tweet - please ignore"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/post.ts ``` ### Test Like ```bash echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/like.ts ``` Or export `CHROME_PATH` manually before running: ```bash export CHROME_PATH="/path/to/chrome" echo '{"content":"Test"}' | npx tsx .claude/skills/x-integration/scripts/post.ts ``` ## Troubleshooting ### Authentication Expired ```bash npx dotenv -e .env -- npx tsx .claude/skills/x-integration/scripts/setup.ts launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS # Linux: systemctl --user restart nanoclaw ``` ### Browser Lock Files If Chrome fails to launch: ```bash rm -f data/x-browser-profile/SingletonLock rm -f data/x-browser-profile/SingletonSocket rm -f data/x-browser-profile/SingletonCookie ``` ### Check Logs ```bash # Host logs (relative to project root) grep -i "x_post\|x_like\|x_reply\|handleXIpc" logs/nanoclaw.log | tail -20 # Script errors grep -i "error\|failed" logs/nanoclaw.log | tail -20 ``` ### Script Timeout Default timeout is 2 minutes (120s). Increase in `host.ts`: ```typescript const timer = setTimeout(() => { proc.kill('SIGTERM'); resolve({ success: false, message: 'Script timed out (120s)' }); }, 120000); // ← Increase this value ``` ### X UI Selector Changes If X updates their UI, selectors in scripts may break. Current selectors: | Element | Selector | |---------|----------| | Tweet input | `[data-testid="tweetTextarea_0"]` | | Post button | `[data-testid="tweetButtonInline"]` | | Reply button | `[data-testid="reply"]` | | Like | `[data-testid="like"]` | | Unlike | `[data-testid="unlike"]` | | Retweet | `[data-testid="retweet"]` | | Unretweet | `[data-testid="unretweet"]` | | Confirm retweet | `[data-testid="retweetConfirm"]` | | Modal dialog | `[role="dialog"][aria-modal="true"]` | | Modal submit | `[data-testid="tweetButton"]` | ### Container Build Issues If MCP tools not found in container: ```bash # Verify build copies skill ./container/build.sh 2>&1 | grep -i skill # Check container has the file docker run nanoclaw-agent ls -la /app/src/skills/ ``` ## Security - `data/x-browser-profile/` - Contains X session cookies (in `.gitignore`) - `data/x-auth.json` - Auth state marker (in `.gitignore`) - Only main group can use X tools (enforced in `agent.ts` and `host.ts`) - Scripts run as subprocesses with limited environment ================================================ FILE: .claude/skills/x-integration/agent.ts ================================================ /** * X Integration - MCP Tool Definitions (Agent/Container Side) * * These tools run inside the container and communicate with the host via IPC. * The host-side implementation is in host.ts. * * Note: This file is compiled in the container, not on the host. * The @ts-ignore is needed because the SDK is only available in the container. */ // @ts-ignore - SDK available in container environment only import { tool } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; import fs from 'fs'; import path from 'path'; // IPC directories (inside container) const IPC_DIR = '/workspace/ipc'; const TASKS_DIR = path.join(IPC_DIR, 'tasks'); const RESULTS_DIR = path.join(IPC_DIR, 'x_results'); function writeIpcFile(dir: string, data: object): string { fs.mkdirSync(dir, { recursive: true }); const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; const filepath = path.join(dir, filename); const tempPath = `${filepath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); fs.renameSync(tempPath, filepath); return filename; } async function waitForResult(requestId: string, maxWait = 60000): Promise<{ success: boolean; message: string }> { const resultFile = path.join(RESULTS_DIR, `${requestId}.json`); const pollInterval = 1000; let elapsed = 0; while (elapsed < maxWait) { if (fs.existsSync(resultFile)) { try { const result = JSON.parse(fs.readFileSync(resultFile, 'utf-8')); fs.unlinkSync(resultFile); return result; } catch (err) { return { success: false, message: `Failed to read result: ${err}` }; } } await new Promise(resolve => setTimeout(resolve, pollInterval)); elapsed += pollInterval; } return { success: false, message: 'Request timed out' }; } export interface SkillToolsContext { groupFolder: string; isMain: boolean; } /** * Create X integration MCP tools */ export function createXTools(ctx: SkillToolsContext) { const { groupFolder, isMain } = ctx; return [ tool( 'x_post', `Post a tweet to X (Twitter). Main group only. The host machine will execute the browser automation to post the tweet. Make sure the content is appropriate and within X's character limit (280 chars for text).`, { content: z.string().max(280).describe('The tweet content to post (max 280 characters)') }, async (args: { content: string }) => { if (!isMain) { return { content: [{ type: 'text', text: 'Only the main group can post tweets.' }], isError: true }; } if (args.content.length > 280) { return { content: [{ type: 'text', text: `Tweet exceeds 280 character limit (current: ${args.content.length})` }], isError: true }; } const requestId = `xpost-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeIpcFile(TASKS_DIR, { type: 'x_post', requestId, content: args.content, groupFolder, timestamp: new Date().toISOString() }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], isError: !result.success }; } ), tool( 'x_like', `Like a tweet on X (Twitter). Main group only. Provide the tweet URL or tweet ID to like.`, { tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') }, async (args: { tweet_url: string }) => { if (!isMain) { return { content: [{ type: 'text', text: 'Only the main group can interact with X.' }], isError: true }; } const requestId = `xlike-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeIpcFile(TASKS_DIR, { type: 'x_like', requestId, tweetUrl: args.tweet_url, groupFolder, timestamp: new Date().toISOString() }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], isError: !result.success }; } ), tool( 'x_reply', `Reply to a tweet on X (Twitter). Main group only. Provide the tweet URL and your reply content.`, { tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), content: z.string().max(280).describe('The reply content (max 280 characters)') }, async (args: { tweet_url: string; content: string }) => { if (!isMain) { return { content: [{ type: 'text', text: 'Only the main group can interact with X.' }], isError: true }; } const requestId = `xreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeIpcFile(TASKS_DIR, { type: 'x_reply', requestId, tweetUrl: args.tweet_url, content: args.content, groupFolder, timestamp: new Date().toISOString() }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], isError: !result.success }; } ), tool( 'x_retweet', `Retweet a tweet on X (Twitter). Main group only. Provide the tweet URL to retweet.`, { tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID') }, async (args: { tweet_url: string }) => { if (!isMain) { return { content: [{ type: 'text', text: 'Only the main group can interact with X.' }], isError: true }; } const requestId = `xretweet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeIpcFile(TASKS_DIR, { type: 'x_retweet', requestId, tweetUrl: args.tweet_url, groupFolder, timestamp: new Date().toISOString() }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], isError: !result.success }; } ), tool( 'x_quote', `Quote tweet on X (Twitter). Main group only. Retweet with your own comment added.`, { tweet_url: z.string().describe('The tweet URL (e.g., https://x.com/user/status/123) or tweet ID'), comment: z.string().max(280).describe('Your comment for the quote tweet (max 280 characters)') }, async (args: { tweet_url: string; comment: string }) => { if (!isMain) { return { content: [{ type: 'text', text: 'Only the main group can interact with X.' }], isError: true }; } const requestId = `xquote-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; writeIpcFile(TASKS_DIR, { type: 'x_quote', requestId, tweetUrl: args.tweet_url, comment: args.comment, groupFolder, timestamp: new Date().toISOString() }); const result = await waitForResult(requestId); return { content: [{ type: 'text', text: result.message }], isError: !result.success }; } ) ]; } ================================================ FILE: .claude/skills/x-integration/host.ts ================================================ /** * X Integration IPC Handler * * Handles all x_* IPC messages from container agents. * This is the entry point for X integration in the host process. */ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import pino from 'pino'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { colorize: true } } }); interface SkillResult { success: boolean; message: string; data?: unknown; } // Run a skill script as subprocess async function runScript(script: string, args: object): Promise<SkillResult> { const scriptPath = path.join(process.cwd(), '.claude', 'skills', 'x-integration', 'scripts', `${script}.ts`); return new Promise((resolve) => { const proc = spawn('npx', ['tsx', scriptPath], { cwd: process.cwd(), env: { ...process.env, NANOCLAW_ROOT: process.cwd() }, stdio: ['pipe', 'pipe', 'pipe'] }); let stdout = ''; proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stdin.write(JSON.stringify(args)); proc.stdin.end(); const timer = setTimeout(() => { proc.kill('SIGTERM'); resolve({ success: false, message: 'Script timed out (120s)' }); }, 120000); proc.on('close', (code) => { clearTimeout(timer); if (code !== 0) { resolve({ success: false, message: `Script exited with code: ${code}` }); return; } try { const lines = stdout.trim().split('\n'); resolve(JSON.parse(lines[lines.length - 1])); } catch { resolve({ success: false, message: `Failed to parse output: ${stdout.slice(0, 200)}` }); } }); proc.on('error', (err) => { clearTimeout(timer); resolve({ success: false, message: `Failed to spawn: ${err.message}` }); }); }); } // Write result to IPC results directory function writeResult(dataDir: string, sourceGroup: string, requestId: string, result: SkillResult): void { const resultsDir = path.join(dataDir, 'ipc', sourceGroup, 'x_results'); fs.mkdirSync(resultsDir, { recursive: true }); fs.writeFileSync(path.join(resultsDir, `${requestId}.json`), JSON.stringify(result)); } /** * Handle X integration IPC messages * * @returns true if message was handled, false if not an X message */ export async function handleXIpc( data: Record<string, unknown>, sourceGroup: string, isMain: boolean, dataDir: string ): Promise<boolean> { const type = data.type as string; // Only handle x_* types if (!type?.startsWith('x_')) { return false; } // Only main group can use X integration if (!isMain) { logger.warn({ sourceGroup, type }, 'X integration blocked: not main group'); return true; } const requestId = data.requestId as string; if (!requestId) { logger.warn({ type }, 'X integration blocked: missing requestId'); return true; } logger.info({ type, requestId }, 'Processing X request'); let result: SkillResult; switch (type) { case 'x_post': if (!data.content) { result = { success: false, message: 'Missing content' }; break; } result = await runScript('post', { content: data.content }); break; case 'x_like': if (!data.tweetUrl) { result = { success: false, message: 'Missing tweetUrl' }; break; } result = await runScript('like', { tweetUrl: data.tweetUrl }); break; case 'x_reply': if (!data.tweetUrl || !data.content) { result = { success: false, message: 'Missing tweetUrl or content' }; break; } result = await runScript('reply', { tweetUrl: data.tweetUrl, content: data.content }); break; case 'x_retweet': if (!data.tweetUrl) { result = { success: false, message: 'Missing tweetUrl' }; break; } result = await runScript('retweet', { tweetUrl: data.tweetUrl }); break; case 'x_quote': if (!data.tweetUrl || !data.comment) { result = { success: false, message: 'Missing tweetUrl or comment' }; break; } result = await runScript('quote', { tweetUrl: data.tweetUrl, comment: data.comment }); break; default: return false; } writeResult(dataDir, sourceGroup, requestId, result); if (result.success) { logger.info({ type, requestId }, 'X request completed'); } else { logger.error({ type, requestId, message: result.message }, 'X request failed'); } return true; } ================================================ FILE: .claude/skills/x-integration/lib/browser.ts ================================================ /** * X Integration - Shared utilities * Used by all X scripts */ import { chromium, BrowserContext, Page } from 'playwright'; import fs from 'fs'; import path from 'path'; import { config } from './config.js'; export { config }; export interface ScriptResult { success: boolean; message: string; data?: unknown; } /** * Read input from stdin */ export async function readInput<T>(): Promise<T> { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', () => { try { resolve(JSON.parse(data)); } catch (err) { reject(new Error(`Invalid JSON input: ${err}`)); } }); process.stdin.on('error', reject); }); } /** * Write result to stdout */ export function writeResult(result: ScriptResult): void { console.log(JSON.stringify(result)); } /** * Clean up browser lock files */ export function cleanupLockFiles(): void { for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) { const lockPath = path.join(config.browserDataDir, lockFile); if (fs.existsSync(lockPath)) { try { fs.unlinkSync(lockPath); } catch {} } } } /** * Validate tweet/reply content */ export function validateContent(content: string | undefined, type = 'Tweet'): ScriptResult | null { if (!content || content.length === 0) { return { success: false, message: `${type} content cannot be empty` }; } if (content.length > config.limits.tweetMaxLength) { return { success: false, message: `${type} exceeds ${config.limits.tweetMaxLength} character limit (current: ${content.length})` }; } return null; // Valid } /** * Get browser context with persistent profile */ export async function getBrowserContext(): Promise<BrowserContext> { if (!fs.existsSync(config.authPath)) { throw new Error('X authentication not configured. Run /x-integration to complete login.'); } cleanupLockFiles(); const context = await chromium.launchPersistentContext(config.browserDataDir, { executablePath: config.chromePath, headless: false, viewport: config.viewport, args: config.chromeArgs, ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, }); return context; } /** * Extract tweet ID from URL or raw ID */ export function extractTweetId(input: string): string | null { const urlMatch = input.match(/(?:x\.com|twitter\.com)\/\w+\/status\/(\d+)/); if (urlMatch) return urlMatch[1]; if (/^\d+$/.test(input.trim())) return input.trim(); return null; } /** * Navigate to a tweet page */ export async function navigateToTweet( context: BrowserContext, tweetUrl: string ): Promise<{ page: Page; success: boolean; error?: string }> { const page = context.pages()[0] || await context.newPage(); let url = tweetUrl; const tweetId = extractTweetId(tweetUrl); if (tweetId && !tweetUrl.startsWith('http')) { url = `https://x.com/i/status/${tweetId}`; } try { await page.goto(url, { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); await page.waitForTimeout(config.timeouts.pageLoad); const exists = await page.locator('article[data-testid="tweet"]').first().isVisible().catch(() => false); if (!exists) { return { page, success: false, error: 'Tweet not found. It may have been deleted or the URL is invalid.' }; } return { page, success: true }; } catch (err) { return { page, success: false, error: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }; } } /** * Run script with error handling */ export async function runScript<T>( handler: (input: T) => Promise<ScriptResult> ): Promise<void> { try { const input = await readInput<T>(); const result = await handler(input); writeResult(result); } catch (err) { writeResult({ success: false, message: `Script execution failed: ${err instanceof Error ? err.message : String(err)}` }); process.exit(1); } } ================================================ FILE: .claude/skills/x-integration/lib/config.ts ================================================ /** * X Integration - Configuration * * All environment-specific settings in one place. * Override via environment variables or modify defaults here. */ import path from 'path'; // Project root - can be overridden for different deployments const PROJECT_ROOT = process.env.NANOCLAW_ROOT || process.cwd(); /** * Configuration object with all settings */ export const config = { // Chrome executable path // Default: standard macOS Chrome location // Override: CHROME_PATH environment variable chromePath: process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // Browser profile directory for persistent login sessions browserDataDir: path.join(PROJECT_ROOT, 'data', 'x-browser-profile'), // Auth state marker file authPath: path.join(PROJECT_ROOT, 'data', 'x-auth.json'), // Browser viewport settings viewport: { width: 1280, height: 800, }, // Timeouts (in milliseconds) timeouts: { navigation: 30000, elementWait: 5000, afterClick: 1000, afterFill: 1000, afterSubmit: 3000, pageLoad: 3000, }, // X character limits limits: { tweetMaxLength: 280, }, // Chrome launch arguments chromeArgs: [ '--disable-blink-features=AutomationControlled', '--no-sandbox', '--disable-setuid-sandbox', '--no-first-run', '--no-default-browser-check', '--disable-sync', ], // Args to ignore when launching Chrome chromeIgnoreDefaultArgs: ['--enable-automation'], }; ================================================ FILE: .claude/skills/x-integration/scripts/like.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Like Tweet * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx like.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; interface LikeInput { tweetUrl: string; } async function likeTweet(input: LikeInput): Promise<ScriptResult> { const { tweetUrl } = input; if (!tweetUrl) { return { success: false, message: 'Please provide a tweet URL' }; } let context = null; try { context = await getBrowserContext(); const { page, success, error } = await navigateToTweet(context, tweetUrl); if (!success) { return { success: false, message: error || 'Navigation failed' }; } const tweet = page.locator('article[data-testid="tweet"]').first(); const unlikeButton = tweet.locator('[data-testid="unlike"]'); const likeButton = tweet.locator('[data-testid="like"]'); // Check if already liked const alreadyLiked = await unlikeButton.isVisible().catch(() => false); if (alreadyLiked) { return { success: true, message: 'Tweet already liked' }; } await likeButton.waitFor({ timeout: config.timeouts.elementWait }); await likeButton.click(); await page.waitForTimeout(config.timeouts.afterClick); // Verify const nowLiked = await unlikeButton.isVisible().catch(() => false); if (nowLiked) { return { success: true, message: 'Like successful' }; } return { success: false, message: 'Like action completed but could not verify success' }; } finally { if (context) await context.close(); } } runScript<LikeInput>(likeTweet); ================================================ FILE: .claude/skills/x-integration/scripts/post.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Post Tweet * Usage: echo '{"content":"Hello world"}' | npx tsx post.ts */ import { getBrowserContext, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; interface PostInput { content: string; } async function postTweet(input: PostInput): Promise<ScriptResult> { const { content } = input; const validationError = validateContent(content, 'Tweet'); if (validationError) return validationError; let context = null; try { context = await getBrowserContext(); const page = context.pages()[0] || await context.newPage(); await page.goto('https://x.com/home', { timeout: config.timeouts.navigation, waitUntil: 'domcontentloaded' }); await page.waitForTimeout(config.timeouts.pageLoad); // Check if logged in const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); if (!isLoggedIn) { const onLoginPage = await page.locator('input[autocomplete="username"]').isVisible().catch(() => false); if (onLoginPage) { return { success: false, message: 'X login expired. Run /x-integration to re-authenticate.' }; } } // Find and fill tweet input const tweetInput = page.locator('[data-testid="tweetTextarea_0"]'); await tweetInput.waitFor({ timeout: config.timeouts.elementWait * 2 }); await tweetInput.click(); await page.waitForTimeout(config.timeouts.afterClick / 2); await tweetInput.fill(content); await page.waitForTimeout(config.timeouts.afterFill); // Click post button const postButton = page.locator('[data-testid="tweetButtonInline"]'); await postButton.waitFor({ timeout: config.timeouts.elementWait }); const isDisabled = await postButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { return { success: false, message: 'Post button disabled. Content may be empty or exceed character limit.' }; } await postButton.click(); await page.waitForTimeout(config.timeouts.afterSubmit); return { success: true, message: `Tweet posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` }; } finally { if (context) await context.close(); } } runScript<PostInput>(postTweet); ================================================ FILE: .claude/skills/x-integration/scripts/quote.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Quote Tweet * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","comment":"My thoughts"}' | npx tsx quote.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; interface QuoteInput { tweetUrl: string; comment: string; } async function quoteTweet(input: QuoteInput): Promise<ScriptResult> { const { tweetUrl, comment } = input; if (!tweetUrl) { return { success: false, message: 'Please provide a tweet URL' }; } const validationError = validateContent(comment, 'Comment'); if (validationError) return validationError; let context = null; try { context = await getBrowserContext(); const { page, success, error } = await navigateToTweet(context, tweetUrl); if (!success) { return { success: false, message: error || 'Navigation failed' }; } // Click retweet button to open menu const tweet = page.locator('article[data-testid="tweet"]').first(); const retweetButton = tweet.locator('[data-testid="retweet"]'); await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); await retweetButton.click(); await page.waitForTimeout(config.timeouts.afterClick); // Click quote option const quoteOption = page.getByRole('menuitem').filter({ hasText: /Quote/i }); await quoteOption.waitFor({ timeout: config.timeouts.elementWait }); await quoteOption.click(); await page.waitForTimeout(config.timeouts.afterClick * 1.5); // Find dialog with aria-modal="true" const dialog = page.locator('[role="dialog"][aria-modal="true"]'); await dialog.waitFor({ timeout: config.timeouts.elementWait }); // Fill comment const quoteInput = dialog.locator('[data-testid="tweetTextarea_0"]'); await quoteInput.waitFor({ timeout: config.timeouts.elementWait }); await quoteInput.click(); await page.waitForTimeout(config.timeouts.afterClick / 2); await quoteInput.fill(comment); await page.waitForTimeout(config.timeouts.afterFill); // Click submit button const submitButton = dialog.locator('[data-testid="tweetButton"]'); await submitButton.waitFor({ timeout: config.timeouts.elementWait }); const isDisabled = await submitButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; } await submitButton.click(); await page.waitForTimeout(config.timeouts.afterSubmit); return { success: true, message: `Quote tweet posted: ${comment.slice(0, 50)}${comment.length > 50 ? '...' : ''}` }; } finally { if (context) await context.close(); } } runScript<QuoteInput>(quoteTweet); ================================================ FILE: .claude/skills/x-integration/scripts/reply.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Reply to Tweet * Usage: echo '{"tweetUrl":"https://x.com/user/status/123","content":"Great post!"}' | npx tsx reply.ts */ import { getBrowserContext, navigateToTweet, runScript, validateContent, config, ScriptResult } from '../lib/browser.js'; interface ReplyInput { tweetUrl: string; content: string; } async function replyToTweet(input: ReplyInput): Promise<ScriptResult> { const { tweetUrl, content } = input; if (!tweetUrl) { return { success: false, message: 'Please provide a tweet URL' }; } const validationError = validateContent(content, 'Reply'); if (validationError) return validationError; let context = null; try { context = await getBrowserContext(); const { page, success, error } = await navigateToTweet(context, tweetUrl); if (!success) { return { success: false, message: error || 'Navigation failed' }; } // Click reply button const tweet = page.locator('article[data-testid="tweet"]').first(); const replyButton = tweet.locator('[data-testid="reply"]'); await replyButton.waitFor({ timeout: config.timeouts.elementWait }); await replyButton.click(); await page.waitForTimeout(config.timeouts.afterClick * 1.5); // Find dialog with aria-modal="true" to avoid matching other dialogs const dialog = page.locator('[role="dialog"][aria-modal="true"]'); await dialog.waitFor({ timeout: config.timeouts.elementWait }); // Fill reply content const replyInput = dialog.locator('[data-testid="tweetTextarea_0"]'); await replyInput.waitFor({ timeout: config.timeouts.elementWait }); await replyInput.click(); await page.waitForTimeout(config.timeouts.afterClick / 2); await replyInput.fill(content); await page.waitForTimeout(config.timeouts.afterFill); // Click submit button const submitButton = dialog.locator('[data-testid="tweetButton"]'); await submitButton.waitFor({ timeout: config.timeouts.elementWait }); const isDisabled = await submitButton.getAttribute('aria-disabled'); if (isDisabled === 'true') { return { success: false, message: 'Submit button disabled. Content may be empty or exceed character limit.' }; } await submitButton.click(); await page.waitForTimeout(config.timeouts.afterSubmit); return { success: true, message: `Reply posted: ${content.slice(0, 50)}${content.length > 50 ? '...' : ''}` }; } finally { if (context) await context.close(); } } runScript<ReplyInput>(replyToTweet); ================================================ FILE: .claude/skills/x-integration/scripts/retweet.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Retweet * Usage: echo '{"tweetUrl":"https://x.com/user/status/123"}' | npx tsx retweet.ts */ import { getBrowserContext, navigateToTweet, runScript, config, ScriptResult } from '../lib/browser.js'; interface RetweetInput { tweetUrl: string; } async function retweet(input: RetweetInput): Promise<ScriptResult> { const { tweetUrl } = input; if (!tweetUrl) { return { success: false, message: 'Please provide a tweet URL' }; } let context = null; try { context = await getBrowserContext(); const { page, success, error } = await navigateToTweet(context, tweetUrl); if (!success) { return { success: false, message: error || 'Navigation failed' }; } const tweet = page.locator('article[data-testid="tweet"]').first(); const unretweetButton = tweet.locator('[data-testid="unretweet"]'); const retweetButton = tweet.locator('[data-testid="retweet"]'); // Check if already retweeted const alreadyRetweeted = await unretweetButton.isVisible().catch(() => false); if (alreadyRetweeted) { return { success: true, message: 'Tweet already retweeted' }; } await retweetButton.waitFor({ timeout: config.timeouts.elementWait }); await retweetButton.click(); await page.waitForTimeout(config.timeouts.afterClick); // Click retweet confirm option const retweetConfirm = page.locator('[data-testid="retweetConfirm"]'); await retweetConfirm.waitFor({ timeout: config.timeouts.elementWait }); await retweetConfirm.click(); await page.waitForTimeout(config.timeouts.afterClick * 2); // Verify const nowRetweeted = await unretweetButton.isVisible().catch(() => false); if (nowRetweeted) { return { success: true, message: 'Retweet successful' }; } return { success: false, message: 'Retweet action completed but could not verify success' }; } finally { if (context) await context.close(); } } runScript<RetweetInput>(retweet); ================================================ FILE: .claude/skills/x-integration/scripts/setup.ts ================================================ #!/usr/bin/env npx tsx /** * X Integration - Authentication Setup * Usage: npx tsx setup.ts * * Interactive script - opens browser for manual login */ import { chromium } from 'playwright'; import * as readline from 'readline'; import fs from 'fs'; import path from 'path'; import { config, cleanupLockFiles } from '../lib/browser.js'; async function setup(): Promise<void> { console.log('=== X (Twitter) Authentication Setup ===\n'); console.log('This will open Chrome for you to log in to X.'); console.log('Your login session will be saved for automated interactions.\n'); console.log(`Chrome path: ${config.chromePath}`); console.log(`Profile dir: ${config.browserDataDir}\n`); // Ensure directories exist fs.mkdirSync(path.dirname(config.authPath), { recursive: true }); fs.mkdirSync(config.browserDataDir, { recursive: true }); cleanupLockFiles(); console.log('Launching browser...\n'); const context = await chromium.launchPersistentContext(config.browserDataDir, { executablePath: config.chromePath, headless: false, viewport: config.viewport, args: config.chromeArgs.slice(0, 3), // Use first 3 args for setup (less restrictive) ignoreDefaultArgs: config.chromeIgnoreDefaultArgs, }); const page = context.pages()[0] || await context.newPage(); // Navigate to login page await page.goto('https://x.com/login'); console.log('Please log in to X in the browser window.'); console.log('After you see your home feed, come back here and press Enter.\n'); // Wait for user to complete login const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); await new Promise<void>(resolve => { rl.question('Press Enter when logged in... ', () => { rl.close(); resolve(); }); }); // Verify login by navigating to home and checking for account button console.log('\nVerifying login status...'); await page.goto('https://x.com/home'); await page.waitForTimeout(config.timeouts.pageLoad); const isLoggedIn = await page.locator('[data-testid="SideNav_AccountSwitcher_Button"]').isVisible().catch(() => false); if (isLoggedIn) { // Save auth marker fs.writeFileSync(config.authPath, JSON.stringify({ authenticated: true, timestamp: new Date().toISOString() }, null, 2)); console.log('\n✅ Authentication successful!'); console.log(`Session saved to: ${config.browserDataDir}`); console.log('\nYou can now use X integration features.'); } else { console.log('\n❌ Could not verify login status.'); console.log('Please try again and make sure you are logged in to X.'); } await context.close(); } setup().catch(err => { console.error('Setup failed:', err.message); process.exit(1); }); ================================================ FILE: .github/CODEOWNERS ================================================ # Core code - maintainer only /src/ @gavrielc @gabi-simons /container/ @gavrielc @gabi-simons /groups/ @gavrielc @gabi-simons /launchd/ @gavrielc @gabi-simons /package.json @gavrielc @gabi-simons /package-lock.json @gavrielc @gabi-simons # Skills - open to contributors /.claude/skills/ ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Type of Change - [ ] **Skill** - adds a new skill in `.claude/skills/` - [ ] **Fix** - bug fix or security fix to source code - [ ] **Simplification** - reduces or simplifies source code ## Description ## For Skills - [ ] I have not made any changes to source code - [ ] My skill contains instructions for Claude to follow (not pre-built code) - [ ] I tested this skill on a fresh clone ================================================ FILE: .github/workflows/bump-version.yml ================================================ name: Bump version on: push: branches: [main] paths: ['src/**', 'container/**'] jobs: bump-version: runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} - name: Bump patch version run: | npm version patch --no-git-tag-version git add package.json package-lock.json git diff --cached --quiet && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" VERSION=$(node -p "require('./package.json').version") git commit -m "chore: bump version to $VERSION" git pull --rebase git push ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: pull_request: branches: [main] jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Format check run: npm run format:check - name: Typecheck run: npx tsc --noEmit - name: Tests run: npx vitest run ================================================ FILE: .github/workflows/merge-forward-skills.yml ================================================ name: Merge-forward skill branches on: push: branches: [main] permissions: contents: write issues: write jobs: merge-forward: if: github.repository == 'qwibitai/nanoclaw' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - name: Configure git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Merge main into each skill branch id: merge run: | FAILED="" SUCCEEDED="" # List all remote skill branches SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs) if [ -z "$SKILL_BRANCHES" ]; then echo "No skill branches found." exit 0 fi for BRANCH in $SKILL_BRANCHES; do SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||') echo "" echo "=== Processing $BRANCH ===" # Checkout the skill branch git checkout -B "$BRANCH" "origin/$BRANCH" # Attempt merge if ! git merge main --no-edit; then echo "::warning::Merge conflict in $BRANCH" git merge --abort FAILED="$FAILED $SKILL_NAME" continue fi # Check if there's anything new to push if git diff --quiet "origin/$BRANCH"; then echo "$BRANCH is already up to date with main." SUCCEEDED="$SUCCEEDED $SKILL_NAME" continue fi # Install deps and validate npm ci if ! npm run build; then echo "::warning::Build failed for $BRANCH" git reset --hard "origin/$BRANCH" FAILED="$FAILED $SKILL_NAME" continue fi if ! npm test 2>/dev/null; then echo "::warning::Tests failed for $BRANCH" git reset --hard "origin/$BRANCH" FAILED="$FAILED $SKILL_NAME" continue fi # Push the updated branch git push origin "$BRANCH" SUCCEEDED="$SUCCEEDED $SKILL_NAME" echo "$BRANCH merged and pushed successfully." done echo "" echo "=== Results ===" echo "Succeeded: $SUCCEEDED" echo "Failed: $FAILED" # Export for issue creation echo "failed=$FAILED" >> "$GITHUB_OUTPUT" echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT" - name: Open issue for failed merges if: steps.merge.outputs.failed != '' uses: actions/github-script@v7 with: script: | const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/); const sha = context.sha.substring(0, 7); const body = [ `The merge-forward workflow failed to merge \`main\` (${sha}) into the following skill branches:`, '', ...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`), '', 'Please resolve manually:', '```bash', ...failed.map(s => [ `git checkout skill/${s}`, `git merge main`, `# resolve conflicts, then: git push`, '' ]).flat(), '```', '', `Triggered by push to main: ${context.sha}` ].join('\n'); await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: `Merge-forward failed for ${failed.length} skill branch(es) after ${sha}`, body, labels: ['skill-maintenance'] }); - name: Notify channel forks if: always() uses: actions/github-script@v7 with: github-token: ${{ secrets.FORK_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }} script: | const forks = [ 'nanoclaw-whatsapp', 'nanoclaw-telegram', 'nanoclaw-discord', 'nanoclaw-slack', 'nanoclaw-gmail', 'nanoclaw-docker-sandboxes', ]; const sha = context.sha.substring(0, 7); for (const repo of forks) { try { await github.rest.repos.createDispatchEvent({ owner: 'qwibitai', repo, event_type: 'upstream-main-updated', client_payload: { sha: context.sha }, }); console.log(`Notified ${repo}`); } catch (e) { console.log(`Failed to notify ${repo}: ${e.message}`); } } ================================================ FILE: .github/workflows/update-tokens.yml ================================================ name: Update token count on: workflow_dispatch: push: branches: [main] paths: ['src/**', 'container/**', 'launchd/**', 'CLAUDE.md'] jobs: update-tokens: runs-on: ubuntu-latest steps: - uses: actions/create-github-app-token@v1 id: app-token with: app-id: ${{ secrets.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/checkout@v4 with: token: ${{ steps.app-token.outputs.token }} - uses: actions/setup-python@v5 with: python-version: '3.12' - uses: ./repo-tokens id: tokens with: include: 'src/**/*.ts container/agent-runner/src/**/*.ts container/Dockerfile container/build.sh launchd/com.nanoclaw.plist CLAUDE.md' exclude: 'src/**/*.test.ts' badge-path: 'repo-tokens/badge.svg' - name: Commit if changed run: | git add README.md repo-tokens/badge.svg git diff --cached --quiet && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "docs: update token count to ${{ steps.tokens.outputs.badge }}" git pull --rebase git push ================================================ FILE: .gitignore ================================================ # Dependencies node_modules/ .npm-cache/ # Build output dist/ # Local data & auth store/ data/ logs/ # Groups - only track base structure and specific CLAUDE.md files groups/* !groups/main/ !groups/global/ groups/main/* groups/global/* !groups/main/CLAUDE.md !groups/global/CLAUDE.md # Secrets *.keys.json .env # Temp files .tmp-* # OS .DS_Store # IDE .idea/ .vscode/ # Skills system (local per-installation state) .nanoclaw/ agents-sdk-docs ================================================ FILE: .husky/pre-commit ================================================ npm run format:fix ================================================ FILE: .mcp.json ================================================ { "mcpServers": {} } ================================================ FILE: .nvmrc ================================================ 22 ================================================ FILE: .prettierrc ================================================ { "singleQuote": true } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to NanoClaw will be documented in this file. ## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0) [BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved). - **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669) ================================================ FILE: CLAUDE.md ================================================ # NanoClaw Personal Claude assistant. See [README.md](README.md) for philosophy and setup. See [docs/REQUIREMENTS.md](docs/REQUIREMENTS.md) for architecture decisions. ## Quick Context Single Node.js process with skill-based channel system. Channels (WhatsApp, Telegram, Slack, Discord, Gmail) are skills that self-register at startup. Messages route to Claude Agent SDK running in containers (Linux VMs). Each group has isolated filesystem and memory. ## Key Files | File | Purpose | |------|---------| | `src/index.ts` | Orchestrator: state, message loop, agent invocation | | `src/channels/registry.ts` | Channel registry (self-registration at startup) | | `src/ipc.ts` | IPC watcher and task processing | | `src/router.ts` | Message formatting and outbound routing | | `src/config.ts` | Trigger pattern, paths, intervals | | `src/container-runner.ts` | Spawns agent containers with mounts | | `src/task-scheduler.ts` | Runs scheduled tasks | | `src/db.ts` | SQLite operations | | `groups/{name}/CLAUDE.md` | Per-group memory (isolated) | | `container/skills/agent-browser.md` | Browser automation tool (available to all agents via Bash) | ## Skills | Skill | When to Use | |-------|-------------| | `/setup` | First-time installation, authentication, service configuration | | `/customize` | Adding channels, integrations, changing behavior | | `/debug` | Container issues, logs, troubleshooting | | `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install | | `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch | | `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks | ## Development Run commands directly—don't tell the user to run them. ```bash npm run dev # Run with hot reload npm run build # Compile TypeScript ./container/build.sh # Rebuild agent container ``` Service management: ```bash # macOS (launchd) launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist launchctl kickstart -k gui/$(id -u)/com.nanoclaw # restart # Linux (systemd) systemctl --user start nanoclaw systemctl --user stop nanoclaw systemctl --user restart nanoclaw ``` ## Troubleshooting **WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && (git merge whatsapp/main || { git checkout --theirs package-lock.json && git add package-lock.json && git merge --continue; }) && npm run build`) to install it. Existing auth credentials and groups are preserved. ## Container Build Cache The container buildkit caches the build context aggressively. `--no-cache` alone does NOT invalidate COPY steps — the builder's volume retains stale files. To force a truly clean rebuild, prune the builder then re-run `./container/build.sh`. ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing ## Source Code Changes **Accepted:** Bug fixes, security fixes, simplifications, reducing code. **Not accepted:** Features, capabilities, compatibility, enhancements. These should be skills. ## Skills A [skill](https://code.claude.com/docs/en/skills) is a markdown file in `.claude/skills/` that teaches Claude Code how to transform a NanoClaw installation. A PR that contributes a skill should not modify any source files. Your skill should contain the **instructions** Claude follows to add the feature—not pre-built code. See `/add-telegram` for a good example. ### Why? Every user should have clean and minimal code that does exactly what they need. Skills let users selectively add features to their fork without inheriting code for features they don't want. ### Testing Test your skill by running it on a fresh clone before submitting. ================================================ FILE: CONTRIBUTORS.md ================================================ # Contributors Thanks to everyone who has contributed to NanoClaw! - [Alakazam03](https://github.com/Alakazam03) — Vaibhav Aggarwal - [tydev-new](https://github.com/tydev-new) - [pottertech](https://github.com/pottertech) — Skip Potter - [rgarcia](https://github.com/rgarcia) — Rafael - [AmaxGuan](https://github.com/AmaxGuan) — Lingfeng Guan - [happydog-intj](https://github.com/happydog-intj) — happy dog - [bindoon](https://github.com/bindoon) — 潕量 - [taslim](https://github.com/taslim) — Taslim - [baijunjie](https://github.com/baijunjie) — BaiJunjie - [Michaelliv](https://github.com/Michaelliv) — Michael - [kk17](https://github.com/kk17) — Kyle Zhike Chen ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 Gavriel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ <p align="center"> <img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400"> </p> <p align="center"> An AI assistant that runs agents securely in their own containers. Lightweight, built to be easily understood and completely customized for your needs. </p> <p align="center"> <a href="https://nanoclaw.dev">nanoclaw.dev</a>  •   <a href="README_zh.md">中文</a>  •   <a href="README_ja.md">日本語</a>  •   <a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>  •   <a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a> </p> --- <h2 align="center">🐳 Now Runs in Docker Sandboxes</h2> <p align="center">Every agent gets its own isolated container inside a micro VM.<br>Hypervisor-level isolation. Millisecond startup. No complex setup.</p> **macOS (Apple Silicon)** ```bash curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash ``` **Windows (WSL)** ```bash curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash ``` > Currently supported on macOS (Apple Silicon) and Windows (x86). Linux support coming soon. <p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">Read the announcement →</a>  ·  <a href="docs/docker-sandboxes.md">Manual setup guide →</a></p> --- ## Why I Built NanoClaw [OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory. NanoClaw provides that same core functionality, but in a codebase small enough to understand: one process and a handful of files. Claude agents run in their own Linux containers with filesystem isolation, not merely behind permission checks. ## Quick Start ```bash gh repo fork qwibitai/nanoclaw --clone cd nanoclaw claude ``` <details> <summary>Without GitHub CLI</summary> 1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button) 2. `git clone https://github.com/<your-username>/nanoclaw.git` 3. `cd nanoclaw` 4. `claude` </details> Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration. > **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code). ## Philosophy **Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it. **Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host. **Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs. **Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes. **AI-native.** - No installation wizard; Claude Code guides setup. - No monitoring dashboard; ask Claude what's happening. - No debugging tools; describe the problem and Claude fixes it. **Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need. **Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user. ## What It Supports - **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time. - **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it. - **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated - **Scheduled tasks** - Recurring jobs that run Claude and can message you back - **Web access** - Search and fetch content from the Web - **Container isolation** - Agents are sandboxed in [Docker Sandboxes](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes) (micro VM isolation), Apple Container (macOS), or Docker (macOS/Linux) - **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks - **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills ## Usage Talk to your assistant with the trigger word (default: `@Andy`): ``` @Andy send an overview of the sales pipeline every weekday morning at 9am (has access to my Obsidian vault folder) @Andy review the git history for the past week each Friday and update the README if there's drift @Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing ``` From the main channel (your self-chat), you can manage groups and tasks: ``` @Andy list all scheduled tasks across groups @Andy pause the Monday briefing task @Andy join the Family Chat group ``` ## Customizing NanoClaw doesn't use configuration files. To make changes, just tell Claude Code what you want: - "Change the trigger word to @Bob" - "Remember in the future to make responses shorter and more direct" - "Add a custom greeting when I say good morning" - "Store conversation summaries weekly" Or run `/customize` for guided changes. The codebase is small enough that Claude can safely modify it. ## Contributing **Don't add features. Add skills.** If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork. Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case. ### RFS (Request for Skills) Skills we'd like to see: **Communication Channels** - `/add-signal` - Add Signal as a channel **Session Management** - `/clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK. ## Requirements - macOS or Linux - Node.js 20+ - [Claude Code](https://claude.ai/download) - [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) ## Architecture ``` Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response ``` Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. For the full architecture details, see [docs/SPEC.md](docs/SPEC.md). Key files: - `src/index.ts` - Orchestrator: state, message loop, agent invocation - `src/channels/registry.ts` - Channel registry (self-registration at startup) - `src/ipc.ts` - IPC watcher and task processing - `src/router.ts` - Message formatting and outbound routing - `src/group-queue.ts` - Per-group queue with global concurrency limit - `src/container-runner.ts` - Spawns streaming agent containers - `src/task-scheduler.ts` - Runs scheduled tasks - `src/db.ts` - SQLite operations (messages, groups, sessions, state) - `groups/*/CLAUDE.md` - Per-group memory ## FAQ **Why Docker?** Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. **Can I run this on Linux?** Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`. **Is this secure?** Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. **Why no configuration files?** We don't want configuration sprawl. Every user should customize NanoClaw so that the code does exactly what they want, rather than configuring a generic system. If you prefer having config files, you can tell Claude to add them. **Can I use third-party or open-source models?** Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` This allows you to use: - Local models via [Ollama](https://ollama.ai) with an API proxy - Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc. - Custom model deployments with Anthropic-compatible APIs Note: The model must support the Anthropic API format for best compatibility. **How do I debug issues?** Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw. **Why isn't the setup working for me?** If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md. **What changes will be accepted into the codebase?** Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all. Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills. This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want. ## Community Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42). ## Changelog See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes. ## License MIT ================================================ FILE: README_ja.md ================================================ <p align="center"> <img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400"> </p> <p align="center"> エージェントを専用コンテナで安全に実行するAIアシスタント。軽量で、理解しやすく、あなたのニーズに完全にカスタマイズできるように設計されています。 </p> <p align="center"> <a href="https://nanoclaw.dev">nanoclaw.dev</a>  •   <a href="README.md">English</a>  •   <a href="README_zh.md">中文</a>  •   <a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>  •   <a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a> </p> --- <h2 align="center">🐳 Dockerサンドボックスで動作</h2> <p align="center">各エージェントはマイクロVM内の独立したコンテナで実行されます。<br>ハイパーバイザーレベルの分離。ミリ秒で起動。複雑なセットアップ不要。</p> **macOS (Apple Silicon)** ```bash curl -fsSL https://nanoclaw.dev/install-docker-sandboxes.sh | bash ``` **Windows (WSL)** ```bash curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash ``` > 現在、macOS(Apple Silicon)とWindows(x86)に対応しています。Linux対応は近日公開予定。 <p align="center"><a href="https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes">発表記事を読む →</a>  ·  <a href="docs/docker-sandboxes.md">手動セットアップガイド →</a></p> --- ## NanoClawを作った理由 [OpenClaw](https://github.com/openclaw/openclaw)は素晴らしいプロジェクトですが、理解しきれない複雑なソフトウェアに自分の生活へのフルアクセスを与えたまま安心して眠れるとは思えませんでした。OpenClawは約50万行のコード、53の設定ファイル、70以上の依存関係を持っています。セキュリティはアプリケーションレベル(許可リスト、ペアリングコード)であり、真のOS レベルの分離ではありません。すべてが共有メモリを持つ1つのNodeプロセスで動作します。 NanoClawは同じコア機能を提供しますが、理解できる規模のコードベースで実現しています:1つのプロセスと少数のファイル。Claudeエージェントは単なるパーミッションチェックの背後ではなく、ファイルシステム分離された独自のLinuxコンテナで実行されます。 ## クイックスタート ```bash gh repo fork qwibitai/nanoclaw --clone cd nanoclaw claude ``` <details> <summary>GitHub CLIなしの場合</summary> 1. GitHub上で[qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw)をフォーク(Forkボタンをクリック) 2. `git clone https://github.com/<あなたのユーザー名>/nanoclaw.git` 3. `cd nanoclaw` 4. `claude` </details> その後、`/setup`を実行します。Claude Codeがすべてを処理します:依存関係、認証、コンテナセットアップ、サービス設定。 > **注意:** `/`で始まるコマンド(`/setup`、`/add-whatsapp`など)は[Claude Codeスキル](https://code.claude.com/docs/en/skills)です。通常のターミナルではなく、`claude` CLIプロンプト内で入力してください。Claude Codeをインストールしていない場合は、[claude.com/product/claude-code](https://claude.com/product/claude-code)から入手してください。 ## 設計思想 **理解できる規模。** 1つのプロセス、少数のソースファイル、マイクロサービスなし。NanoClawのコードベース全体を理解したい場合は、Claude Codeに説明を求めるだけです。 **分離によるセキュリティ。** エージェントはLinuxコンテナ(macOSではApple Container、またはDocker)で実行され、明示的にマウントされたものだけが見えます。コマンドはホストではなくコンテナ内で実行されるため、Bashアクセスは安全です。 **個人ユーザー向け。** NanoClawはモノリシックなフレームワークではなく、各ユーザーのニーズに正確にフィットするソフトウェアです。肥大化するのではなく、オーダーメイドになるよう設計されています。自分のフォークを作成し、Claude Codeにニーズに合わせて変更させます。 **カスタマイズ=コード変更。** 設定ファイルの肥大化なし。動作を変えたい?コードを変更するだけ。コードベースは変更しても安全な規模です。 **AIネイティブ。** - インストールウィザードなし — Claude Codeがセットアップを案内。 - モニタリングダッシュボードなし — Claudeに状況を聞くだけ。 - デバッグツールなし — 問題を説明すればClaudeが修正。 **機能追加ではなくスキル。** コードベースに機能(例:Telegram対応)を追加する代わりに、コントリビューターは`/add-telegram`のような[Claude Codeスキル](https://code.claude.com/docs/en/skills)を提出し、あなたのフォークを変換します。あなたが必要なものだけを正確に実行するクリーンなコードが手に入ります。 **最高のハーネス、最高のモデル。** NanoClawはClaude Agent SDK上で動作します。つまり、Claude Codeを直接実行しているということです。Claude Codeは高い能力を持ち、そのコーディングと問題解決能力によってNanoClawを変更・拡張し、各ユーザーに合わせてカスタマイズできます。 ## サポート機能 - **マルチチャネルメッセージング** - WhatsApp、Telegram、Discord、Slack、Gmailからアシスタントと会話。`/add-whatsapp`や`/add-telegram`などのスキルでチャネルを追加。1つでも複数でも同時に実行可能。 - **グループごとの分離コンテキスト** - 各グループは独自の`CLAUDE.md`メモリ、分離されたファイルシステムを持ち、そのファイルシステムのみがマウントされた専用コンテナサンドボックスで実行。 - **メインチャネル** - 管理制御用のプライベートチャネル(セルフチャット)。各グループは完全に分離。 - **スケジュールタスク** - Claudeを実行し、メッセージを返せる定期ジョブ。 - **Webアクセス** - Webからのコンテンツ検索・取得。 - **コンテナ分離** - エージェントは[Dockerサンドボックス](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes)(マイクロVM分離)、Apple Container(macOS)、またはDocker(macOS/Linux)でサンドボックス化。 - **エージェントスウォーム** - 複雑なタスクで協力する専門エージェントチームを起動。 - **オプション連携** - Gmail(`/add-gmail`)などをスキルで追加。 ## 使い方 トリガーワード(デフォルト:`@Andy`)でアシスタントに話しかけます: ``` @Andy 毎朝9時に営業パイプラインの概要を送って(Obsidian vaultフォルダにアクセス可能) @Andy 毎週金曜に過去1週間のgit履歴をレビューして、差異があればREADMEを更新して @Andy 毎週月曜の朝8時に、Hacker NewsとTechCrunchからAI関連のニュースをまとめてブリーフィングを送って ``` メインチャネル(セルフチャット)から、グループやタスクを管理できます: ``` @Andy 全グループのスケジュールタスクを一覧表示して @Andy 月曜のブリーフィングタスクを一時停止して @Andy Family Chatグループに参加して ``` ## カスタマイズ NanoClawは設定ファイルを使いません。変更するには、Claude Codeに伝えるだけです: - 「トリガーワードを@Bobに変更して」 - 「今後はレスポンスをもっと短く直接的にして」 - 「おはようと言ったらカスタム挨拶を追加して」 - 「会話の要約を毎週保存して」 または`/customize`を実行してガイド付きの変更を行えます。 コードベースは十分に小さいため、Claudeが安全に変更できます。 ## コントリビューション **機能を追加するのではなく、スキルを追加してください。** Telegram対応を追加したい場合、コアコードベースにTelegramを追加するPRを作成しないでください。代わりに、NanoClawをフォークし、ブランチでコード変更を行い、PRを開いてください。あなたのPRから`skill/telegram`ブランチを作成し、他のユーザーが自分のフォークにマージできるようにします。 ユーザーは自分のフォークで`/add-telegram`を実行するだけで、あらゆるユースケースに対応しようとする肥大化したシステムではなく、必要なものだけを正確に実行するクリーンなコードが手に入ります。 ### RFS(スキル募集) 私たちが求めているスキル: **コミュニケーションチャネル** - `/add-signal` - Signalをチャネルとして追加 **セッション管理** - `/clear` - 会話をコンパクト化する`/clear`コマンドの追加(同一セッション内で重要な情報を保持しながらコンテキストを要約)。Claude Agent SDKを通じてプログラム的にコンパクト化をトリガーする方法の解明が必要。 ## 必要条件 - macOSまたはLinux - Node.js 20以上 - [Claude Code](https://claude.ai/download) - [Apple Container](https://github.com/apple/container)(macOS)または[Docker](https://docker.com/products/docker-desktop)(macOS/Linux) ## アーキテクチャ ``` チャネル --> SQLite --> ポーリングループ --> コンテナ(Claude Agent SDK) --> レスポンス ``` 単一のNode.jsプロセス。チャネルはスキルで追加され、起動時に自己登録します — オーケストレーターは認証情報が存在するチャネルを接続します。エージェントはファイルシステム分離された独立したLinuxコンテナで実行されます。マウントされたディレクトリのみアクセス可能。グループごとのメッセージキューと同時実行制御。ファイルシステム経由のIPC。 詳細なアーキテクチャについては、[docs/SPEC.md](docs/SPEC.md)を参照してください。 主要ファイル: - `src/index.ts` - オーケストレーター:状態、メッセージループ、エージェント呼び出し - `src/channels/registry.ts` - チャネルレジストリ(起動時の自己登録) - `src/ipc.ts` - IPCウォッチャーとタスク処理 - `src/router.ts` - メッセージフォーマットとアウトバウンドルーティング - `src/group-queue.ts` - グローバル同時実行制限付きのグループごとのキュー - `src/container-runner.ts` - ストリーミングエージェントコンテナの起動 - `src/task-scheduler.ts` - スケジュールタスクの実行 - `src/db.ts` - SQLite操作(メッセージ、グループ、セッション、状態) - `groups/*/CLAUDE.md` - グループごとのメモリ ## FAQ **なぜDockerなのか?** Dockerはクロスプラットフォーム対応(macOS、Linux、さらにWSL2経由のWindows)と成熟したエコシステムを提供します。macOSでは、`/convert-to-apple-container`でオプションとしてApple Containerに切り替え、より軽量なネイティブランタイムを使用できます。 **Linuxで実行できますか?** はい。DockerがデフォルトのランタイムでmacOSとLinuxの両方で動作します。`/setup`を実行するだけです。 **セキュリティは大丈夫ですか?** エージェントはアプリケーションレベルのパーミッションチェックの背後ではなく、コンテナで実行されます。明示的にマウントされたディレクトリのみアクセスできます。実行するものをレビューすべきですが、コードベースは十分に小さいため実際にレビュー可能です。完全なセキュリティモデルについては[docs/SECURITY.md](docs/SECURITY.md)を参照してください。 **なぜ設定ファイルがないのか?** 設定の肥大化を避けたいからです。すべてのユーザーがNanoClawをカスタマイズし、汎用的なシステムを設定するのではなく、コードが必要なことを正確に実行するようにすべきです。設定ファイルが欲しい場合は、Claudeに追加するよう伝えることができます。 **サードパーティやオープンソースモデルを使えますか?** はい。NanoClawはClaude API互換のモデルエンドポイントに対応しています。`.env`ファイルで以下の環境変数を設定してください: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` 以下が使用可能です: - [Ollama](https://ollama.ai)とAPIプロキシ経由のローカルモデル - [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai)等でホストされたオープンソースモデル - Anthropic互換APIのカスタムモデルデプロイメント 注意:最高の互換性のため、モデルはAnthropic APIフォーマットに対応している必要があります。 **問題のデバッグ方法は?** Claude Codeに聞いてください。「スケジューラーが動いていないのはなぜ?」「最近のログには何がある?」「このメッセージに返信がなかったのはなぜ?」これがNanoClawの基盤となるAIネイティブなアプローチです。 **セットアップがうまくいかない場合は?** 問題がある場合、セットアップ中にClaudeが動的に修正を試みます。それでもうまくいかない場合は、`claude`を実行してから`/debug`を実行してください。Claudeが他のユーザーにも影響する可能性のある問題を見つけた場合は、セットアップのSKILL.mdを修正するPRを開いてください。 **どのような変更がコードベースに受け入れられますか?** セキュリティ修正、バグ修正、明確な改善のみが基本設定に受け入れられます。それだけです。 それ以外のすべて(新機能、OS互換性、ハードウェアサポート、機能拡張)はスキルとしてコントリビューションすべきです。 これにより、基本システムを最小限に保ち、すべてのユーザーが不要な機能を継承することなく、自分のインストールをカスタマイズできます。 ## コミュニティ 質問やアイデアは?[Discordに参加](https://discord.gg/VDdww8qS42)してください。 ## 変更履歴 破壊的変更と移行ノートについては[CHANGELOG.md](CHANGELOG.md)を参照してください。 ## ライセンス MIT ================================================ FILE: README_zh.md ================================================ <p align="center"> <img src="assets/nanoclaw-logo.png" alt="NanoClaw" width="400"> </p> <p align="center"> NanoClaw —— 您的专属 Claude 助手,在容器中安全运行。它轻巧易懂,并能根据您的个人需求灵活定制。 </p> <p align="center"> <a href="https://nanoclaw.dev">nanoclaw.dev</a>  •   <a href="README.md">English</a>  •   <a href="README_ja.md">日本語</a>  •   <a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>  •   <a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a> </p> 通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。 **新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。 ## 我为什么创建这个项目 [OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。 NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。 ## 快速开始 ```bash git clone https://github.com/qwibitai/nanoclaw.git cd nanoclaw claude ``` 然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。 > **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。 ## 设计哲学 **小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。 **通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。 **为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。 **定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。 **AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。 **技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。 **最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。 ## 功能支持 - **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。 - **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。 - **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离 - **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息 - **网络访问** - 搜索和抓取网页内容 - **容器隔离** - 智能体在 Apple Container (macOS) 或 Docker (macOS/Linux) 的沙箱中运行 - **智能体集群(Agent Swarms)** - 启动多个专业智能体团队,协作完成复杂任务(首个支持此功能的个人 AI 助手) - **可选集成** - 通过技能添加 Gmail (`/add-gmail`) 等更多功能 ## 使用方法 使用触发词(默认为 `@Andy`)与您的助手对话: ``` @Andy 每周一到周五早上9点,给我发一份销售渠道的概览(需要访问我的 Obsidian vault 文件夹) @Andy 每周五回顾过去一周的 git 历史,如果与 README 有出入,就更新它 @Andy 每周一早上8点,从 Hacker News 和 TechCrunch 收集关于 AI 发展的资讯,然后发给我一份简报 ``` 在主频道(您的self-chat)中,可以管理群组和任务: ``` @Andy 列出所有群组的计划任务 @Andy 暂停周一简报任务 @Andy 加入"家庭聊天"群组 ``` ## 定制 没有需要学习的配置文件。直接告诉 Claude Code 您想要什么: - "把触发词改成 @Bob" - "记住以后回答要更简短直接" - "当我说早上好的时候,加一个自定义的问候" - "每周存储一次对话摘要" 或者运行 `/customize` 进行引导式修改。 代码库足够小,Claude 可以安全地修改它。 ## 贡献 **不要添加功能,而是添加技能。** 如果您想添加 Telegram 支持,不要创建一个 PR 同时添加 Telegram 和 WhatsApp。而是贡献一个技能文件 (`.claude/skills/add-telegram/SKILL.md`),教 Claude Code 如何改造一个 NanoClaw 安装以使用 Telegram。 然后用户在自己的 fork 上运行 `/add-telegram`,就能得到只做他们需要事情的整洁代码,而不是一个试图支持所有用例的臃肿系统。 ### RFS (技能征集) 我们希望看到的技能: **通信渠道** - `/add-signal` - 添加 Signal 作为渠道 **会话管理** - `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。 ## 系统要求 - macOS 或 Linux - Node.js 20+ - [Claude Code](https://claude.ai/download) - [Apple Container](https://github.com/apple/container) (macOS) 或 [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) ## 架构 ``` 渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应 ``` 单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。 完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。 关键文件: - `src/index.ts` - 编排器:状态管理、消息循环、智能体调用 - `src/channels/registry.ts` - 渠道注册表(启动时自注册) - `src/ipc.ts` - IPC 监听与任务处理 - `src/router.ts` - 消息格式化与出站路由 - `src/group-queue.ts` - 带全局并发限制的群组队列 - `src/container-runner.ts` - 生成流式智能体容器 - `src/task-scheduler.ts` - 运行计划任务 - `src/db.ts` - SQLite 操作(消息、群组、会话、状态) - `groups/*/CLAUDE.md` - 各群组的记忆 ## FAQ **为什么是 Docker?** Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。 **我可以在 Linux 上运行吗?** 可以。Docker 是默认的容器运行时,在 macOS 和 Linux 上都可以使用。只需运行 `/setup`。 **这个项目安全吗?** 智能体在容器中运行,而不是在应用级别的权限检查之后。它们只能访问被明确挂载的目录。您仍然应该审查您运行的代码,但这个代码库小到您真的可以做到。完整的安全模型请见 [docs/SECURITY.md](docs/SECURITY.md)。 **为什么没有配置文件?** 我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。 **我可以使用第三方或开源模型吗?** 可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量: ```bash ANTHROPIC_BASE_URL=https://your-api-endpoint.com ANTHROPIC_AUTH_TOKEN=your-token-here ``` 这使您能够使用: - 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型 - 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型 - 兼容 Anthropic API 格式的自定义模型部署 注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。 **我该如何调试问题?** 问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。 **为什么我的安装不成功?** 如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。 **什么样的代码更改会被接受?** 安全修复、bug 修复,以及对基础配置的明确改进。仅此而已。 其他一切(新功能、操作系统兼容性、硬件支持、增强功能)都应该作为技能来贡献。 这使得基础系统保持最小化,并让每个用户可以定制他们的安装,而无需继承他们不想要的功能。 ## 社区 有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。 ## 更新日志 破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。 ## 许可证 MIT ================================================ FILE: config-examples/mount-allowlist.json ================================================ { "allowedRoots": [ { "path": "~/projects", "allowReadWrite": true, "description": "Development projects" }, { "path": "~/repos", "allowReadWrite": true, "description": "Git repositories" }, { "path": "~/Documents/work", "allowReadWrite": false, "description": "Work documents (read-only)" } ], "blockedPatterns": [ "password", "secret", "token" ], "nonMainReadOnly": true } ================================================ FILE: container/Dockerfile ================================================ # NanoClaw Agent Container # Runs Claude Agent SDK in isolated Linux VM with browser automation FROM node:22-slim # Install system dependencies for Chromium RUN apt-get update && apt-get install -y \ chromium \ fonts-liberation \ fonts-noto-cjk \ fonts-noto-color-emoji \ libgbm1 \ libnss3 \ libatk-bridge2.0-0 \ libgtk-3-0 \ libx11-xcb1 \ libxcomposite1 \ libxdamage1 \ libxrandr2 \ libasound2 \ libpangocairo-1.0-0 \ libcups2 \ libdrm2 \ libxshmfence1 \ curl \ git \ && rm -rf /var/lib/apt/lists/* # Set Chromium path for agent-browser ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium # Install agent-browser and claude-code globally RUN npm install -g agent-browser @anthropic-ai/claude-code # Create app directory WORKDIR /app # Copy package files first for better caching COPY agent-runner/package*.json ./ # Install dependencies RUN npm install # Copy source code COPY agent-runner/ ./ # Build TypeScript RUN npm run build # Create workspace directories RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input # Create entrypoint script # Container input (prompt, group info) is passed via stdin JSON. # Credentials are injected by the host's credential proxy — never passed here. # Follow-up messages arrive via IPC files in /workspace/ipc/input/ RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh # Set ownership to node user (non-root) for writable directories RUN chown -R node:node /workspace && chmod 777 /home/node # Switch to non-root user (required for --dangerously-skip-permissions) USER node # Set working directory to group workspace WORKDIR /workspace/group # Entry point reads JSON from stdin, outputs JSON to stdout ENTRYPOINT ["/app/entrypoint.sh"] ================================================ FILE: container/agent-runner/package.json ================================================ { "name": "nanoclaw-agent-runner", "version": "1.0.0", "type": "module", "description": "Container-side agent runner for NanoClaw", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.76", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "zod": "^4.0.0" }, "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } } ================================================ FILE: container/agent-runner/src/index.ts ================================================ /** * NanoClaw Agent Runner * Runs inside a container, receives config via stdin, outputs result to stdout * * Input protocol: * Stdin: Full ContainerInput JSON (read until EOF, like before) * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/ * Files: {type:"message", text:"..."}.json — polled and consumed * Sentinel: /workspace/ipc/input/_close — signals session end * * Stdout protocol: * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs. * Multiple results may be emitted (one per agent teams result). * Final marker after loop ends signals completion. */ import fs from 'fs'; import path from 'path'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; interface ContainerInput { prompt: string; sessionId?: string; groupFolder: string; chatJid: string; isMain: boolean; isScheduledTask?: boolean; assistantName?: string; } interface ContainerOutput { status: 'success' | 'error'; result: string | null; newSessionId?: string; error?: string; } interface SessionEntry { sessionId: string; fullPath: string; summary: string; firstPrompt: string; } interface SessionsIndex { entries: SessionEntry[]; } interface SDKUserMessage { type: 'user'; message: { role: 'user'; content: string }; parent_tool_use_id: null; session_id: string; } const IPC_INPUT_DIR = '/workspace/ipc/input'; const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; /** * Push-based async iterable for streaming user messages to the SDK. * Keeps the iterable alive until end() is called, preventing isSingleUserTurn. */ class MessageStream { private queue: SDKUserMessage[] = []; private waiting: (() => void) | null = null; private done = false; push(text: string): void { this.queue.push({ type: 'user', message: { role: 'user', content: text }, parent_tool_use_id: null, session_id: '', }); this.waiting?.(); } end(): void { this.done = true; this.waiting?.(); } async *[Symbol.asyncIterator](): AsyncGenerator<SDKUserMessage> { while (true) { while (this.queue.length > 0) { yield this.queue.shift()!; } if (this.done) return; await new Promise<void>(r => { this.waiting = r; }); this.waiting = null; } } } async function readStdin(): Promise<string> { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { data += chunk; }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); } const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; function writeOutput(output: ContainerOutput): void { console.log(OUTPUT_START_MARKER); console.log(JSON.stringify(output)); console.log(OUTPUT_END_MARKER); } function log(message: string): void { console.error(`[agent-runner] ${message}`); } function getSessionSummary(sessionId: string, transcriptPath: string): string | null { const projectDir = path.dirname(transcriptPath); const indexPath = path.join(projectDir, 'sessions-index.json'); if (!fs.existsSync(indexPath)) { log(`Sessions index not found at ${indexPath}`); return null; } try { const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8')); const entry = index.entries.find(e => e.sessionId === sessionId); if (entry?.summary) { return entry.summary; } } catch (err) { log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`); } return null; } /** * Archive the full transcript to conversations/ before compaction. */ function createPreCompactHook(assistantName?: string): HookCallback { return async (input, _toolUseId, _context) => { const preCompact = input as PreCompactHookInput; const transcriptPath = preCompact.transcript_path; const sessionId = preCompact.session_id; if (!transcriptPath || !fs.existsSync(transcriptPath)) { log('No transcript found for archiving'); return {}; } try { const content = fs.readFileSync(transcriptPath, 'utf-8'); const messages = parseTranscript(content); if (messages.length === 0) { log('No messages to archive'); return {}; } const summary = getSessionSummary(sessionId, transcriptPath); const name = summary ? sanitizeFilename(summary) : generateFallbackName(); const conversationsDir = '/workspace/group/conversations'; fs.mkdirSync(conversationsDir, { recursive: true }); const date = new Date().toISOString().split('T')[0]; const filename = `${date}-${name}.md`; const filePath = path.join(conversationsDir, filename); const markdown = formatTranscriptMarkdown(messages, summary, assistantName); fs.writeFileSync(filePath, markdown); log(`Archived conversation to ${filePath}`); } catch (err) { log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`); } return {}; }; } function sanitizeFilename(summary: string): string { return summary .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 50); } function generateFallbackName(): string { const time = new Date(); return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`; } interface ParsedMessage { role: 'user' | 'assistant'; content: string; } function parseTranscript(content: string): ParsedMessage[] { const messages: ParsedMessage[] = []; for (const line of content.split('\n')) { if (!line.trim()) continue; try { const entry = JSON.parse(line); if (entry.type === 'user' && entry.message?.content) { const text = typeof entry.message.content === 'string' ? entry.message.content : entry.message.content.map((c: { text?: string }) => c.text || '').join(''); if (text) messages.push({ role: 'user', content: text }); } else if (entry.type === 'assistant' && entry.message?.content) { const textParts = entry.message.content .filter((c: { type: string }) => c.type === 'text') .map((c: { text: string }) => c.text); const text = textParts.join(''); if (text) messages.push({ role: 'assistant', content: text }); } } catch { } } return messages; } function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string { const now = new Date(); const formatDateTime = (d: Date) => d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); const lines: string[] = []; lines.push(`# ${title || 'Conversation'}`); lines.push(''); lines.push(`Archived: ${formatDateTime(now)}`); lines.push(''); lines.push('---'); lines.push(''); for (const msg of messages) { const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant'); const content = msg.content.length > 2000 ? msg.content.slice(0, 2000) + '...' : msg.content; lines.push(`**${sender}**: ${content}`); lines.push(''); } return lines.join('\n'); } /** * Check for _close sentinel. */ function shouldClose(): boolean { if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) { try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } return true; } return false; } /** * Drain all pending IPC input messages. * Returns messages found, or empty array. */ function drainIpcInput(): string[] { try { fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); const files = fs.readdirSync(IPC_INPUT_DIR) .filter(f => f.endsWith('.json')) .sort(); const messages: string[] = []; for (const file of files) { const filePath = path.join(IPC_INPUT_DIR, file); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); fs.unlinkSync(filePath); if (data.type === 'message' && data.text) { messages.push(data.text); } } catch (err) { log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`); try { fs.unlinkSync(filePath); } catch { /* ignore */ } } } return messages; } catch (err) { log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`); return []; } } /** * Wait for a new IPC message or _close sentinel. * Returns the messages as a single string, or null if _close. */ function waitForIpcMessage(): Promise<string | null> { return new Promise((resolve) => { const poll = () => { if (shouldClose()) { resolve(null); return; } const messages = drainIpcInput(); if (messages.length > 0) { resolve(messages.join('\n')); return; } setTimeout(poll, IPC_POLL_MS); }; poll(); }); } /** * Run a single query and stream results via writeOutput. * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false, * allowing agent teams subagents to run to completion. * Also pipes IPC messages into the stream during the query. */ async function runQuery( prompt: string, sessionId: string | undefined, mcpServerPath: string, containerInput: ContainerInput, sdkEnv: Record<string, string | undefined>, resumeAt?: string, ): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> { const stream = new MessageStream(); stream.push(prompt); // Poll IPC for follow-up messages and _close sentinel during the query let ipcPolling = true; let closedDuringQuery = false; const pollIpcDuringQuery = () => { if (!ipcPolling) return; if (shouldClose()) { log('Close sentinel detected during query, ending stream'); closedDuringQuery = true; stream.end(); ipcPolling = false; return; } const messages = drainIpcInput(); for (const text of messages) { log(`Piping IPC message into active query (${text.length} chars)`); stream.push(text); } setTimeout(pollIpcDuringQuery, IPC_POLL_MS); }; setTimeout(pollIpcDuringQuery, IPC_POLL_MS); let newSessionId: string | undefined; let lastAssistantUuid: string | undefined; let messageCount = 0; let resultCount = 0; // Load global CLAUDE.md as additional system context (shared across all groups) const globalClaudeMdPath = '/workspace/global/CLAUDE.md'; let globalClaudeMd: string | undefined; if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) { globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8'); } // Discover additional directories mounted at /workspace/extra/* // These are passed to the SDK so their CLAUDE.md files are loaded automatically const extraDirs: string[] = []; const extraBase = '/workspace/extra'; if (fs.existsSync(extraBase)) { for (const entry of fs.readdirSync(extraBase)) { const fullPath = path.join(extraBase, entry); if (fs.statSync(fullPath).isDirectory()) { extraDirs.push(fullPath); } } } if (extraDirs.length > 0) { log(`Additional directories: ${extraDirs.join(', ')}`); } for await (const message of query({ prompt: stream, options: { cwd: '/workspace/group', additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined, resume: sessionId, resumeSessionAt: resumeAt, systemPrompt: globalClaudeMd ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd } : undefined, allowedTools: [ 'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'WebSearch', 'WebFetch', 'Task', 'TaskOutput', 'TaskStop', 'TeamCreate', 'TeamDelete', 'SendMessage', 'TodoWrite', 'ToolSearch', 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*' ], env: sdkEnv, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, settingSources: ['project', 'user'], mcpServers: { nanoclaw: { command: 'node', args: [mcpServerPath], env: { NANOCLAW_CHAT_JID: containerInput.chatJid, NANOCLAW_GROUP_FOLDER: containerInput.groupFolder, NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0', }, }, }, hooks: { PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }], }, } })) { messageCount++; const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type; log(`[msg #${messageCount}] type=${msgType}`); if (message.type === 'assistant' && 'uuid' in message) { lastAssistantUuid = (message as { uuid: string }).uuid; } if (message.type === 'system' && message.subtype === 'init') { newSessionId = message.session_id; log(`Session initialized: ${newSessionId}`); } if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') { const tn = message as { task_id: string; status: string; summary: string }; log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`); } if (message.type === 'result') { resultCount++; const textResult = 'result' in message ? (message as { result?: string }).result : null; log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`); writeOutput({ status: 'success', result: textResult || null, newSessionId }); } } ipcPolling = false; log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`); return { newSessionId, lastAssistantUuid, closedDuringQuery }; } async function main(): Promise<void> { let containerInput: ContainerInput; try { const stdinData = await readStdin(); containerInput = JSON.parse(stdinData); try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ } log(`Received input for group: ${containerInput.groupFolder}`); } catch (err) { writeOutput({ status: 'error', result: null, error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}` }); process.exit(1); } // Credentials are injected by the host's credential proxy via ANTHROPIC_BASE_URL. // No real secrets exist in the container environment. const sdkEnv: Record<string, string | undefined> = { ...process.env }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js'); let sessionId = containerInput.sessionId; fs.mkdirSync(IPC_INPUT_DIR, { recursive: true }); // Clean up stale _close sentinel from previous container runs try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ } // Build initial prompt (drain any pending IPC messages too) let prompt = containerInput.prompt; if (containerInput.isScheduledTask) { prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`; } const pending = drainIpcInput(); if (pending.length > 0) { log(`Draining ${pending.length} pending IPC messages into initial prompt`); prompt += '\n' + pending.join('\n'); } // Query loop: run query → wait for IPC message → run new query → repeat let resumeAt: string | undefined; try { while (true) { log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`); const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt); if (queryResult.newSessionId) { sessionId = queryResult.newSessionId; } if (queryResult.lastAssistantUuid) { resumeAt = queryResult.lastAssistantUuid; } // If _close was consumed during the query, exit immediately. // Don't emit a session-update marker (it would reset the host's // idle timer and cause a 30-min delay before the next _close). if (queryResult.closedDuringQuery) { log('Close sentinel consumed during query, exiting'); break; } // Emit session update so host can track it writeOutput({ status: 'success', result: null, newSessionId: sessionId }); log('Query ended, waiting for next IPC message...'); // Wait for the next message or _close sentinel const nextMessage = await waitForIpcMessage(); if (nextMessage === null) { log('Close sentinel received, exiting'); break; } log(`Got new message (${nextMessage.length} chars), starting new query`); prompt = nextMessage; } } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); log(`Agent error: ${errorMessage}`); writeOutput({ status: 'error', result: null, newSessionId: sessionId, error: errorMessage }); process.exit(1); } } main(); ================================================ FILE: container/agent-runner/src/ipc-mcp-stdio.ts ================================================ /** * Stdio MCP Server for NanoClaw * Standalone process that agent teams subagents can inherit. * Reads context from environment variables, writes IPC files for the host. */ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { z } from 'zod'; import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; const IPC_DIR = '/workspace/ipc'; const MESSAGES_DIR = path.join(IPC_DIR, 'messages'); const TASKS_DIR = path.join(IPC_DIR, 'tasks'); // Context from environment variables (set by the agent runner) const chatJid = process.env.NANOCLAW_CHAT_JID!; const groupFolder = process.env.NANOCLAW_GROUP_FOLDER!; const isMain = process.env.NANOCLAW_IS_MAIN === '1'; function writeIpcFile(dir: string, data: object): string { fs.mkdirSync(dir, { recursive: true }); const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`; const filepath = path.join(dir, filename); // Atomic write: temp file then rename const tempPath = `${filepath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify(data, null, 2)); fs.renameSync(tempPath, filepath); return filename; } const server = new McpServer({ name: 'nanoclaw', version: '1.0.0', }); server.tool( 'send_message', "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.", { text: z.string().describe('The message text to send'), sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'), }, async (args) => { const data: Record<string, string | undefined> = { type: 'message', chatJid, text: args.text, sender: args.sender || undefined, groupFolder, timestamp: new Date().toISOString(), }; writeIpcFile(MESSAGES_DIR, data); return { content: [{ type: 'text' as const, text: 'Message sent.' }] }; }, ); server.tool( 'schedule_task', `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead. CONTEXT MODE - Choose based on task type: \u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions. \u2022 "isolated": Task runs in a fresh session with no conversation history. Use for independent tasks that don't need prior context. When using isolated mode, include all necessary context in the prompt itself. If unsure which mode to use, you can ask the user. Examples: - "Remind me about our discussion" \u2192 group (needs conversation context) - "Check the weather every morning" \u2192 isolated (self-contained task) - "Follow up on my request" \u2192 group (needs to know what was requested) - "Generate a daily report" \u2192 isolated (just needs instructions in prompt) MESSAGING BEHAVIOR - The task agent's output is sent to the user or group. It can also use send_message for immediate delivery, or wrap output in <internal> tags to suppress it. Include guidance in the prompt about whether the agent should: \u2022 Always send a message (e.g., reminders, daily briefings) \u2022 Only send a message when there's something to report (e.g., "notify me if...") \u2022 Never send a message (background maintenance tasks) SCHEDULE VALUE FORMAT (all times are LOCAL timezone): \u2022 cron: Standard cron expression (e.g., "*/5 * * * *" for every 5 minutes, "0 9 * * *" for daily at 9am LOCAL time) \u2022 interval: Milliseconds between runs (e.g., "300000" for 5 minutes, "3600000" for 1 hour) \u2022 once: Local time WITHOUT "Z" suffix (e.g., "2026-02-01T15:30:00"). Do NOT use UTC/Z suffix.`, { prompt: z.string().describe('What the agent should do when the task runs. For isolated mode, include all necessary context here.'), schedule_type: z.enum(['cron', 'interval', 'once']).describe('cron=recurring at specific times, interval=recurring every N ms, once=run once at specific time'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), }, async (args) => { // Validate schedule_value before writing IPC if (args.schedule_type === 'cron') { try { CronExpressionParser.parse(args.schedule_value); } catch { return { content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}". Use format like "0 9 * * *" (daily 9am) or "*/5 * * * *" (every 5 min).` }], isError: true, }; } } else if (args.schedule_type === 'interval') { const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}". Must be positive milliseconds (e.g., "300000" for 5 min).` }], isError: true, }; } } else if (args.schedule_type === 'once') { if (/[Zz]$/.test(args.schedule_value) || /[+-]\d{2}:\d{2}$/.test(args.schedule_value)) { return { content: [{ type: 'text' as const, text: `Timestamp must be local time without timezone suffix. Got "${args.schedule_value}" — use format like "2026-02-01T15:30:00".` }], isError: true, }; } const date = new Date(args.schedule_value); if (isNaN(date.getTime())) { return { content: [{ type: 'text' as const, text: `Invalid timestamp: "${args.schedule_value}". Use local time format like "2026-02-01T15:30:00".` }], isError: true, }; } } // Non-main groups can only schedule for themselves const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid; const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const data = { type: 'schedule_task', taskId, prompt: args.prompt, schedule_type: args.schedule_type, schedule_value: args.schedule_value, context_mode: args.context_mode || 'group', targetJid, createdBy: groupFolder, timestamp: new Date().toISOString(), }; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }], }; }, ); server.tool( 'list_tasks', "List all scheduled tasks. From main: shows all tasks. From other groups: shows only that group's tasks.", {}, async () => { const tasksFile = path.join(IPC_DIR, 'current_tasks.json'); try { if (!fs.existsSync(tasksFile)) { return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; } const allTasks = JSON.parse(fs.readFileSync(tasksFile, 'utf-8')); const tasks = isMain ? allTasks : allTasks.filter((t: { groupFolder: string }) => t.groupFolder === groupFolder); if (tasks.length === 0) { return { content: [{ type: 'text' as const, text: 'No scheduled tasks found.' }] }; } const formatted = tasks .map( (t: { id: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string }) => `- [${t.id}] ${t.prompt.slice(0, 50)}... (${t.schedule_type}: ${t.schedule_value}) - ${t.status}, next: ${t.next_run || 'N/A'}`, ) .join('\n'); return { content: [{ type: 'text' as const, text: `Scheduled tasks:\n${formatted}` }] }; } catch (err) { return { content: [{ type: 'text' as const, text: `Error reading tasks: ${err instanceof Error ? err.message : String(err)}` }], }; } }, ); server.tool( 'pause_task', 'Pause a scheduled task. It will not run until resumed.', { task_id: z.string().describe('The task ID to pause') }, async (args) => { const data = { type: 'pause_task', taskId: args.task_id, groupFolder, isMain, timestamp: new Date().toISOString(), }; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Task ${args.task_id} pause requested.` }] }; }, ); server.tool( 'resume_task', 'Resume a paused task.', { task_id: z.string().describe('The task ID to resume') }, async (args) => { const data = { type: 'resume_task', taskId: args.task_id, groupFolder, isMain, timestamp: new Date().toISOString(), }; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Task ${args.task_id} resume requested.` }] }; }, ); server.tool( 'cancel_task', 'Cancel and delete a scheduled task.', { task_id: z.string().describe('The task ID to cancel') }, async (args) => { const data = { type: 'cancel_task', taskId: args.task_id, groupFolder, isMain, timestamp: new Date().toISOString(), }; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Task ${args.task_id} cancellation requested.` }] }; }, ); server.tool( 'update_task', 'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.', { task_id: z.string().describe('The task ID to update'), prompt: z.string().optional().describe('New prompt for the task'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), }, async (args) => { // Validate schedule_value if provided if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) { if (args.schedule_value) { try { CronExpressionParser.parse(args.schedule_value); } catch { return { content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }], isError: true, }; } } } if (args.schedule_type === 'interval' && args.schedule_value) { const ms = parseInt(args.schedule_value, 10); if (isNaN(ms) || ms <= 0) { return { content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }], isError: true, }; } } const data: Record<string, string | undefined> = { type: 'update_task', taskId: args.task_id, groupFolder, isMain: String(isMain), timestamp: new Date().toISOString(), }; if (args.prompt !== undefined) data.prompt = args.prompt; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] }; }, ); server.tool( 'register_group', `Register a new chat/group so the agent can respond to messages there. Main group only. Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`, { jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'), name: z.string().describe('Display name for the group'), folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'), trigger: z.string().describe('Trigger word (e.g., "@Andy")'), }, async (args) => { if (!isMain) { return { content: [{ type: 'text' as const, text: 'Only the main group can register new groups.' }], isError: true, }; } const data = { type: 'register_group', jid: args.jid, name: args.name, folder: args.folder, trigger: args.trigger, timestamp: new Date().toISOString(), }; writeIpcFile(TASKS_DIR, data); return { content: [{ type: 'text' as const, text: `Group "${args.name}" registered. It will start receiving messages immediately.` }], }; }, ); // Start the stdio transport const transport = new StdioServerTransport(); await server.connect(transport); ================================================ FILE: container/agent-runner/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ================================================ FILE: container/build.sh ================================================ #!/bin/bash # Build the NanoClaw agent container image set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" IMAGE_NAME="nanoclaw-agent" TAG="${1:-latest}" CONTAINER_RUNTIME="${CONTAINER_RUNTIME:-docker}" echo "Building NanoClaw agent container image..." echo "Image: ${IMAGE_NAME}:${TAG}" ${CONTAINER_RUNTIME} build -t "${IMAGE_NAME}:${TAG}" . echo "" echo "Build complete!" echo "Image: ${IMAGE_NAME}:${TAG}" echo "" echo "Test with:" echo " echo '{\"prompt\":\"What is 2+2?\",\"groupFolder\":\"test\",\"chatJid\":\"test@g.us\",\"isMain\":false}' | ${CONTAINER_RUNTIME} run -i ${IMAGE_NAME}:${TAG}" ================================================ FILE: container/skills/agent-browser/SKILL.md ================================================ --- name: agent-browser description: Browse the web for any task — research topics, read articles, interact with web apps, fill forms, take screenshots, extract data, and test web pages. Use whenever a browser would be useful, not just when the user explicitly asks. allowed-tools: Bash(agent-browser:*) --- # Browser Automation with agent-browser ## Quick start ```bash agent-browser open <url> # Navigate to page agent-browser snapshot -i # Get interactive elements with refs agent-browser click @e1 # Click element by ref agent-browser fill @e2 "text" # Fill input by ref agent-browser close # Close browser ``` ## Core workflow 1. Navigate: `agent-browser open <url>` 2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) 3. Interact using refs from the snapshot 4. Re-snapshot after navigation or significant DOM changes ## Commands ### Navigation ```bash agent-browser open <url> # Navigate to URL agent-browser back # Go back agent-browser forward # Go forward agent-browser reload # Reload page agent-browser close # Close browser ``` ### Snapshot (page analysis) ```bash agent-browser snapshot # Full accessibility tree agent-browser snapshot -i # Interactive elements only (recommended) agent-browser snapshot -c # Compact output agent-browser snapshot -d 3 # Limit depth to 3 agent-browser snapshot -s "#main" # Scope to CSS selector ``` ### Interactions (use @refs from snapshot) ```bash agent-browser click @e1 # Click agent-browser dblclick @e1 # Double-click agent-browser fill @e2 "text" # Clear and type agent-browser type @e2 "text" # Type without clearing agent-browser press Enter # Press key agent-browser hover @e1 # Hover agent-browser check @e1 # Check checkbox agent-browser uncheck @e1 # Uncheck checkbox agent-browser select @e1 "value" # Select dropdown option agent-browser scroll down 500 # Scroll page agent-browser upload @e1 file.pdf # Upload files ``` ### Get information ```bash agent-browser get text @e1 # Get element text agent-browser get html @e1 # Get innerHTML agent-browser get value @e1 # Get input value agent-browser get attr @e1 href # Get attribute agent-browser get title # Get page title agent-browser get url # Get current URL agent-browser get count ".item" # Count matching elements ``` ### Screenshots & PDF ```bash agent-browser screenshot # Save to temp directory agent-browser screenshot path.png # Save to specific path agent-browser screenshot --full # Full page agent-browser pdf output.pdf # Save as PDF ``` ### Wait ```bash agent-browser wait @e1 # Wait for element agent-browser wait 2000 # Wait milliseconds agent-browser wait --text "Success" # Wait for text agent-browser wait --url "**/dashboard" # Wait for URL pattern agent-browser wait --load networkidle # Wait for network idle ``` ### Semantic locators (alternative to refs) ```bash agent-browser find role button click --name "Submit" agent-browser find text "Sign In" click agent-browser find label "Email" fill "user@test.com" agent-browser find placeholder "Search" type "query" ``` ### Authentication with saved state ```bash # Login once agent-browser open https://app.example.com/login agent-browser snapshot -i agent-browser fill @e1 "username" agent-browser fill @e2 "password" agent-browser click @e3 agent-browser wait --url "**/dashboard" agent-browser state save auth.json # Later: load saved state agent-browser state load auth.json agent-browser open https://app.example.com/dashboard ``` ### Cookies & Storage ```bash agent-browser cookies # Get all cookies agent-browser cookies set name value # Set cookie agent-browser cookies clear # Clear cookies agent-browser storage local # Get localStorage agent-browser storage local set k v # Set value ``` ### JavaScript ```bash agent-browser eval "document.title" # Run JavaScript ``` ## Example: Form submission ```bash agent-browser open https://example.com/form agent-browser snapshot -i # Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] agent-browser fill @e1 "user@example.com" agent-browser fill @e2 "password123" agent-browser click @e3 agent-browser wait --load networkidle agent-browser snapshot -i # Check result ``` ## Example: Data extraction ```bash agent-browser open https://example.com/products agent-browser snapshot -i agent-browser get text @e1 # Get product title agent-browser get attr @e2 href # Get link URL agent-browser screenshot products.png ``` ================================================ FILE: container/skills/capabilities/SKILL.md ================================================ --- name: capabilities description: Show what this NanoClaw instance can do — installed skills, available tools, and system info. Read-only. Use when the user asks what the bot can do, what's installed, or runs /capabilities. --- # /capabilities — System Capabilities Report Generate a structured read-only report of what this NanoClaw instance can do. **Main-channel check:** Only the main channel has `/workspace/project` mounted. Run: ```bash test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN" ``` If `NOT_MAIN`, respond with: > This command is available in your main chat only. Send `/capabilities` there to see what I can do. Then stop — do not generate the report. ## How to gather the information Run these commands and compile the results into the report format below. ### 1. Installed skills List skill directories available to you: ```bash ls -1 /home/node/.claude/skills/ 2>/dev/null || echo "No skills found" ``` Each directory is an installed skill. The directory name is the skill name (e.g., `agent-browser` → `/agent-browser`). ### 2. Available tools Read the allowed tools from your SDK configuration. You always have access to: - **Core:** Bash, Read, Write, Edit, Glob, Grep - **Web:** WebSearch, WebFetch - **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage - **Other:** TodoWrite, ToolSearch, Skill, NotebookEdit - **MCP:** mcp__nanoclaw__* (messaging, tasks, group management) ### 3. MCP server tools The NanoClaw MCP server exposes these tools (via `mcp__nanoclaw__*` prefix): - `send_message` — send a message to the user/group - `schedule_task` — schedule a recurring or one-time task - `list_tasks` — list scheduled tasks - `pause_task` — pause a scheduled task - `resume_task` — resume a paused task - `cancel_task` — cancel and delete a task - `update_task` — update an existing task - `register_group` — register a new chat/group (main only) ### 4. Container skills (Bash tools) Check for executable tools in the container: ```bash which agent-browser 2>/dev/null && echo "agent-browser: available" || echo "agent-browser: not found" ``` ### 5. Group info ```bash ls /workspace/group/CLAUDE.md 2>/dev/null && echo "Group memory: yes" || echo "Group memory: no" ls /workspace/extra/ 2>/dev/null && echo "Extra mounts: $(ls /workspace/extra/ 2>/dev/null | wc -l | tr -d ' ')" || echo "Extra mounts: none" ``` ## Report format Present the report as a clean, readable message. Example: ``` 📋 *NanoClaw Capabilities* *Installed Skills:* • /agent-browser — Browse the web, fill forms, extract data • /capabilities — This report (list all found skills) *Tools:* • Core: Bash, Read, Write, Edit, Glob, Grep • Web: WebSearch, WebFetch • Orchestration: Task, TeamCreate, SendMessage • MCP: send_message, schedule_task, list_tasks, pause/resume/cancel/update_task, register_group *Container Tools:* • agent-browser: ✓ *System:* • Group memory: yes/no • Extra mounts: N directories • Main channel: yes ``` Adapt the output based on what you actually find — don't list things that aren't installed. **See also:** `/status` for a quick health check of session, workspace, and tasks. ================================================ FILE: container/skills/status/SKILL.md ================================================ --- name: status description: Quick read-only health check — session context, workspace mounts, tool availability, and task snapshot. Use when the user asks for system status or runs /status. --- # /status — System Status Check Generate a quick read-only status report of the current agent environment. **Main-channel check:** Only the main channel has `/workspace/project` mounted. Run: ```bash test -d /workspace/project && echo "MAIN" || echo "NOT_MAIN" ``` If `NOT_MAIN`, respond with: > This command is available in your main chat only. Send `/status` there to check system status. Then stop — do not generate the report. ## How to gather the information Run the checks below and compile results into the report format. ### 1. Session context ```bash echo "Timestamp: $(date)" echo "Working dir: $(pwd)" echo "Channel: main" ``` ### 2. Workspace and mount visibility ```bash echo "=== Workspace ===" ls /workspace/ 2>/dev/null echo "=== Group folder ===" ls /workspace/group/ 2>/dev/null | head -20 echo "=== Extra mounts ===" ls /workspace/extra/ 2>/dev/null || echo "none" echo "=== IPC ===" ls /workspace/ipc/ 2>/dev/null ``` ### 3. Tool availability Confirm which tool families are available to you: - **Core:** Bash, Read, Write, Edit, Glob, Grep - **Web:** WebSearch, WebFetch - **Orchestration:** Task, TaskOutput, TaskStop, TeamCreate, TeamDelete, SendMessage - **MCP:** mcp__nanoclaw__* (send_message, schedule_task, list_tasks, pause_task, resume_task, cancel_task, update_task, register_group) ### 4. Container utilities ```bash which agent-browser 2>/dev/null && echo "agent-browser: available" || echo "agent-browser: not installed" node --version 2>/dev/null claude --version 2>/dev/null ``` ### 5. Task snapshot Use the MCP tool to list tasks: ``` Call mcp__nanoclaw__list_tasks to get scheduled tasks. ``` If no tasks exist, report "No scheduled tasks." ## Report format Present as a clean, readable message: ``` 🔍 *NanoClaw Status* *Session:* • Channel: main • Time: 2026-03-14 09:30 UTC • Working dir: /workspace/group *Workspace:* • Group folder: ✓ (N files) • Extra mounts: none / N directories • IPC: ✓ (messages, tasks, input) *Tools:* • Core: ✓ Web: ✓ Orchestration: ✓ MCP: ✓ *Container:* • agent-browser: ✓ / not installed • Node: vXX.X.X • Claude Code: vX.X.X *Scheduled Tasks:* • N active tasks / No scheduled tasks ``` Adapt based on what you actually find. Keep it concise — this is a quick health check, not a deep diagnostic. **See also:** `/capabilities` for a full list of installed skills and tools. ================================================ FILE: docs/APPLE-CONTAINER-NETWORKING.md ================================================ # Apple Container Networking Setup (macOS 26) Apple Container's vmnet networking requires manual configuration for containers to access the internet. Without this, containers can communicate with the host but cannot reach external services (DNS, HTTPS, APIs). ## Quick Setup Run these two commands (requires `sudo`): ```bash # 1. Enable IP forwarding so the host routes container traffic sudo sysctl -w net.inet.ip.forwarding=1 # 2. Enable NAT so container traffic gets masqueraded through your internet interface echo "nat on en0 from 192.168.64.0/24 to any -> (en0)" | sudo pfctl -ef - ``` > **Note:** Replace `en0` with your active internet interface. Check with: `route get 8.8.8.8 | grep interface` ## Making It Persistent These settings reset on reboot. To make them permanent: **IP Forwarding** — add to `/etc/sysctl.conf`: ``` net.inet.ip.forwarding=1 ``` **NAT Rules** — add to `/etc/pf.conf` (before any existing rules): ``` nat on en0 from 192.168.64.0/24 to any -> (en0) ``` Then reload: `sudo pfctl -f /etc/pf.conf` ## IPv6 DNS Issue By default, DNS resolvers return IPv6 (AAAA) records before IPv4 (A) records. Since our NAT only handles IPv4, Node.js applications inside containers will try IPv6 first and fail. The container image and runner are configured to prefer IPv4 via: ``` NODE_OPTIONS=--dns-result-order=ipv4first ``` This is set both in the `Dockerfile` and passed via `-e` flag in `container-runner.ts`. ## Verification ```bash # Check IP forwarding is enabled sysctl net.inet.ip.forwarding # Expected: net.inet.ip.forwarding: 1 # Test container internet access container run --rm --entrypoint curl nanoclaw-agent:latest \ -s4 --connect-timeout 5 -o /dev/null -w "%{http_code}" https://api.anthropic.com # Expected: 404 # Check bridge interface (only exists when a container is running) ifconfig bridge100 ``` ## Troubleshooting | Symptom | Cause | Fix | |---------|-------|-----| | `curl: (28) Connection timed out` | IP forwarding disabled | `sudo sysctl -w net.inet.ip.forwarding=1` | | HTTP works, HTTPS times out | IPv6 DNS resolution | Add `NODE_OPTIONS=--dns-result-order=ipv4first` | | `Could not resolve host` | DNS not forwarded | Check bridge100 exists, verify pfctl NAT rules | | Container hangs after output | Missing `process.exit(0)` in agent-runner | Rebuild container image | ## How It Works ``` Container VM (192.168.64.x) │ ├── eth0 → gateway 192.168.64.1 │ bridge100 (192.168.64.1) ← host bridge, created by vmnet when container runs │ ├── IP forwarding (sysctl) routes packets from bridge100 → en0 │ ├── NAT (pfctl) masquerades 192.168.64.0/24 → en0's IP │ en0 (your WiFi/Ethernet) → Internet ``` ## References - [apple/container#469](https://github.com/apple/container/issues/469) — No network from container on macOS 26 - [apple/container#656](https://github.com/apple/container/issues/656) — Cannot access internet URLs during building ================================================ FILE: docs/DEBUG_CHECKLIST.md ================================================ # NanoClaw Debug Checklist ## Known Issues (2026-02-08) ### 1. [FIXED] Resume branches from stale tree position When agent teams spawns subagent CLI processes, they write to the same session JSONL. On subsequent `query()` resumes, the CLI reads the JSONL but may pick a stale branch tip (from before the subagent activity), causing the agent's response to land on a branch the host never receives a `result` for. **Fix**: pass `resumeSessionAt` with the last assistant message UUID to explicitly anchor each resume. ### 2. IDLE_TIMEOUT == CONTAINER_TIMEOUT (both 30 min) Both timers fire at the same time, so containers always exit via hard SIGKILL (code 137) instead of graceful `_close` sentinel shutdown. The idle timeout should be shorter (e.g., 5 min) so containers wind down between messages, while container timeout stays at 30 min as a safety net for stuck agents. ### 3. Cursor advanced before agent succeeds `processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout. ## Quick Status Check ```bash # 1. Is the service running? launchctl list | grep nanoclaw # Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) # 2. Any running containers? container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 3. Any stopped/orphaned containers? container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 4. Recent errors in service log? grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 # 5. Is WhatsApp connected? (look for last connection event) grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 # 6. Are groups loaded? grep 'groupCount' logs/nanoclaw.log | tail -3 ``` ## Session Transcript Branching ```bash # Check for concurrent CLI processes in session debug logs ls -la data/sessions/<group>/.claude/debug/ # Count unique SDK processes that handled messages # Each .txt file = one CLI subprocess. Multiple = concurrent queries. # Check parentUuid branching in transcript python3 -c " import json, sys lines = open('data/sessions/<group>/.claude/projects/-workspace-group/<session>.jsonl').read().strip().split('\n') for i, line in enumerate(lines): try: d = json.loads(line) if d.get('type') == 'user' and d.get('message'): parent = d.get('parentUuid', 'ROOT')[:8] content = str(d['message'].get('content', ''))[:60] print(f'L{i+1} parent={parent} {content}') except: pass " ``` ## Container Timeout Investigation ```bash # Check for recent timeouts grep -E 'Container timeout|timed out' logs/nanoclaw.log | tail -10 # Check container log files for the timed-out container ls -lt groups/*/logs/container-*.log | head -10 # Read the most recent container log (replace path) cat groups/<group>/logs/container-<timestamp>.log # Check if retries were scheduled and what happened grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 ``` ## Agent Not Responding ```bash # Check if messages are being received from WhatsApp grep 'New messages' logs/nanoclaw.log | tail -10 # Check if messages are being processed (container spawned) grep -E 'Processing messages|Spawning container' logs/nanoclaw.log | tail -10 # Check if messages are being piped to active container grep -E 'Piped messages|sendMessage' logs/nanoclaw.log | tail -10 # Check the queue state — any active containers? grep -E 'Starting container|Container active|concurrency limit' logs/nanoclaw.log | tail -10 # Check lastAgentTimestamp vs latest message timestamp sqlite3 store/messages.db "SELECT chat_jid, MAX(timestamp) as latest FROM messages GROUP BY chat_jid ORDER BY latest DESC LIMIT 5;" ``` ## Container Mount Issues ```bash # Check mount validation logs (shows on container spawn) grep -E 'Mount validated|Mount.*REJECTED|mount' logs/nanoclaw.log | tail -10 # Verify the mount allowlist is readable cat ~/.config/nanoclaw/mount-allowlist.json # Check group's container_config in DB sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;" # Test-run a container to check mounts (dry run) # Replace <group-folder> with the group's folder name container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ ``` ## WhatsApp Auth Issues ```bash # Check if QR code was requested (means auth expired) grep 'QR\|authentication required\|qr' logs/nanoclaw.log | tail -5 # Check auth files exist ls -la store/auth/ # Re-authenticate if needed npm run auth ``` ## Service Management ```bash # Restart the service launchctl kickstart -k gui/$(id -u)/com.nanoclaw # View live logs tail -f logs/nanoclaw.log # Stop the service (careful — running containers are detached, not killed) launchctl bootout gui/$(id -u)/com.nanoclaw # Start the service launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist # Rebuild after code changes npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw ``` ================================================ FILE: docs/REQUIREMENTS.md ================================================ # NanoClaw Requirements Original requirements and design decisions from the project creator. --- ## Why This Exists This is a lightweight, secure alternative to OpenClaw (formerly ClawBot). That project became a monstrosity - 4-5 different processes running different gateways, endless configuration files, endless integrations. It's a security nightmare where agents don't run in isolated processes; there's all kinds of leaky workarounds trying to prevent them from accessing parts of the system they shouldn't. It's impossible for anyone to realistically understand the whole codebase. When you run it you're kind of just yoloing it. NanoClaw gives you the core functionality without that mess. --- ## Philosophy ### Small Enough to Understand The entire codebase should be something you can read and understand. One Node.js process. A handful of source files. No microservices, no message queues, no abstraction layers. ### Security Through True Isolation Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. ### Built for One User This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration. ### Customization = Code Changes No configuration sprawl. If you want different behavior, modify the code. The codebase is small enough that this is safe and practical. Very minimal things like the trigger word are in config. Everything else - just change the code to do what you want. ### AI-Native Development I don't need an installation wizard - Claude Code guides the setup. I don't need a monitoring dashboard - I ask Claude Code what's happening. I don't need elaborate logging UIs - I ask Claude to read the logs. I don't need debugging tools - I describe the problem and Claude fixes it. The codebase assumes you have an AI collaborator. It doesn't need to be excessively self-documenting or self-debugging because Claude is always there. ### Skills Over Features When people contribute, they shouldn't add "Telegram support alongside WhatsApp." They should contribute a skill like `/add-telegram` that transforms the codebase. Users fork the repo, run skills to customize, and end up with clean code that does exactly what they need - not a bloated system trying to support everyone's use case simultaneously. --- ## RFS (Request for Skills) Skills we'd love contributors to build: ### Communication Channels Skills to add or switch to different messaging platforms: - `/add-telegram` - Add Telegram as an input channel - `/add-slack` - Add Slack as an input channel - `/add-discord` - Add Discord as an input channel - `/add-sms` - Add SMS via Twilio or similar - `/convert-to-telegram` - Replace WhatsApp with Telegram entirely ### Container Runtime The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container: - `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only) ### Platform Support - `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) - `/setup-windows` - Windows support via WSL2 + Docker --- ## Vision A personal Claude assistant accessible via WhatsApp, with minimal custom code. **Core components:** - **Claude Agent SDK** as the core agent - **Containers** for isolated agent execution (Linux VMs) - **WhatsApp** as the primary I/O channel - **Persistent memory** per conversation and globally - **Scheduled tasks** that run Claude and can message back - **Web access** for search and browsing - **Browser automation** via agent-browser **Implementation approach:** - Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) - Minimal glue code - File-based systems where possible (CLAUDE.md for memory, folders for groups) --- ## Architecture Decisions ### Message Routing - A router listens to WhatsApp and routes messages based on configuration - Only messages from registered groups are processed - Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Unregistered groups are ignored completely ### Memory System - **Per-group memory**: Each group has a folder with its own `CLAUDE.md` - **Global memory**: Root `CLAUDE.md` is read by all groups, but only writable from "main" (self-chat) - **Files**: Groups can create/read files in their folder and reference them - Agent runs in the group's folder, automatically inherits both CLAUDE.md files ### Session Management - Each group maintains a conversation session (via Claude Agent SDK) - Sessions auto-compact when context gets too long, preserving critical information ### Container Isolation - All agents run inside containers (lightweight Linux VMs) - Each agent invocation spawns a container with mounted directories - Containers provide filesystem isolation - agents can only see mounted paths - Bash access is safe because commands run inside the container, not on the host - Browser automation via agent-browser with Chromium in the container ### Scheduled Tasks - Users can ask Claude to schedule recurring or one-time tasks from any group - Tasks run as full agents in the context of the group that created them - Tasks have access to all tools including Bash (safe in container) - Tasks can optionally send messages to their group via `send_message` tool, or complete silently - Task runs are logged to the database with duration and result - Schedule types: cron expressions, intervals (ms), or one-time (ISO timestamp) - From main: can schedule tasks for any group, view/manage all tasks - From other groups: can only manage that group's tasks ### Group Management - New groups are added explicitly via the main channel - Groups are registered in SQLite (via the main channel or IPC `register_group` command) - Each group gets a dedicated folder under `groups/` - Groups can have additional directories mounted via `containerConfig` ### Main Channel Privileges - Main channel is the admin/control group (typically self-chat) - Can write to global memory (`groups/CLAUDE.md`) - Can schedule tasks for any group - Can view and manage tasks from all groups - Can configure additional directory mounts for any group --- ## Integration Points ### WhatsApp - Using baileys library for WhatsApp Web connection - Messages stored in SQLite, polled by router - QR code authentication during setup ### Scheduler - Built-in scheduler runs on the host, spawns containers for task execution - Custom `nanoclaw` MCP server (inside container) provides scheduling tools - Tools: `schedule_task`, `list_tasks`, `pause_task`, `resume_task`, `cancel_task`, `send_message` - Tasks stored in SQLite with run history - Scheduler loop checks for due tasks every minute - Tasks execute Claude Agent SDK in containerized group context ### Web Access - Built-in WebSearch and WebFetch tools - Standard Claude Agent SDK capabilities ### Browser Automation - agent-browser CLI with Chromium in container - Snapshot-based interaction with element references (@e1, @e2, etc.) - Screenshots, PDFs, video recording - Authentication state persistence --- ## Setup & Customization ### Philosophy - Minimal configuration files - Setup and customization done via Claude Code - Users clone the repo and run Claude Code to configure - Each user gets a custom setup matching their exact needs ### Skills - `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services - `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) - `/update` - Pull upstream changes, merge with customizations, run migrations ### Deployment - Runs on local Mac via launchd - Single Node.js process handles everything --- ## Personal Configuration (Reference) These are the creator's settings, stored here for reference: - **Trigger**: `@Andy` (case insensitive) - **Response prefix**: `Andy:` - **Persona**: Default Claude (no custom personality) - **Main channel**: Self-chat (messaging yourself in WhatsApp) --- ## Project Name **NanoClaw** - A reference to Clawdbot (now OpenClaw). ================================================ FILE: docs/SDK_DEEP_DIVE.md ================================================ # Claude Agent SDK Deep Dive Findings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 to understand how `query()` works, why agent teams subagents were being killed, and how to fix it. Supplemented with official SDK reference docs. ## Architecture ``` Agent Runner (our code) └── query() → SDK (sdk.mjs) └── spawns CLI subprocess (cli.js) └── Claude API calls, tool execution └── Task tool → spawns subagent subprocesses ``` The SDK spawns `cli.js` as a child process with `--output-format stream-json --input-format stream-json --print --verbose` flags. Communication happens via JSON-lines on stdin/stdout. `query()` returns a `Query` object extending `AsyncGenerator<SDKMessage, void>`. Internally: - SDK spawns CLI as a child process, communicates via stdin/stdout JSON lines - SDK's `readMessages()` reads from CLI stdout, enqueues into internal stream - `readSdkMessages()` async generator yields from that stream - `[Symbol.asyncIterator]` returns `readSdkMessages()` - Iterator returns `done: true` only when CLI closes stdout Both V1 (`query()`) and V2 (`createSession`/`send`/`stream`) use the exact same three-layer architecture: ``` SDK (sdk.mjs) CLI Process (cli.js) -------------- -------------------- XX Transport ------> stdin reader (bd1) (spawn cli.js) | $X Query <------ stdout writer (JSON-lines) | EZ() recursive generator | Anthropic Messages API ``` ## The Core Agent Loop (EZ) Inside the CLI, the agentic loop is a **recursive async generator called `EZ()`**, not an iterative while loop: ``` EZ({ messages, systemPrompt, canUseTool, maxTurns, turnCount=1, ... }) ``` Each invocation = one API call to Claude (one "turn"). ### Flow per turn: 1. **Prepare messages** — trim context, run compaction if needed 2. **Call the Anthropic API** (via `mW1` streaming function) 3. **Extract tool_use blocks** from the response 4. **Branch:** - If **no tool_use blocks** → stop (run stop hooks, return) - If **tool_use blocks present** → execute tools, increment turnCount, recurse All complex logic — the agent loop, tool execution, background tasks, teammate orchestration — runs inside the CLI subprocess. `query()` is a thin transport wrapper. ## query() Options Full `Options` type from the official docs: | Property | Type | Default | Description | |----------|------|---------|-------------| | `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations | | `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access | | `agents` | `Record<string, AgentDefinition>` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) | | `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` | | `allowedTools` | `string[]` | All tools | List of allowed tool names | | `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) | | `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage | | `continue` | `boolean` | `false` | Continue the most recent conversation | | `cwd` | `string` | `process.cwd()` | Current working directory | | `disallowedTools` | `string[]` | `[]` | List of disallowed tool names | | `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding | | `env` | `Dict<string>` | `process.env` | Environment variables | | `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime | | `fallbackModel` | `string` | `undefined` | Model to use if primary fails | | `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original | | `hooks` | `Partial<Record<HookEvent, HookCallbackMatcher[]>>` | `{}` | Hook callbacks for events | | `includePartialMessages` | `boolean` | `false` | Include partial message events (streaming) | | `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query | | `maxThinkingTokens` | `number` | `undefined` | Maximum tokens for thinking process | | `maxTurns` | `number` | `undefined` | Maximum conversation turns | | `mcpServers` | `Record<string, McpServerConfig>` | `{}` | MCP server configurations | | `model` | `string` | Default from CLI | Claude model to use | | `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | Structured output format | | `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable | | `permissionMode` | `PermissionMode` | `'default'` | Permission mode | | `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths | | `resume` | `string` | `undefined` | Session ID to resume | | `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID | | `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration | | `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md | | `stderr` | `(data: string) => void` | `undefined` | Callback for stderr output | | `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` | | `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration | ### PermissionMode ```typescript type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan'; ``` ### SettingSource ```typescript type SettingSource = 'user' | 'project' | 'local'; // 'user' → ~/.claude/settings.json // 'project' → .claude/settings.json (version controlled) // 'local' → .claude/settings.local.json (gitignored) ``` When omitted, SDK loads NO filesystem settings (isolation by default). Precedence: local > project > user. Programmatic options always override filesystem settings. ### AgentDefinition Programmatic subagents (NOT agent teams — these are simpler, no inter-agent coordination): ```typescript type AgentDefinition = { description: string; // When to use this agent tools?: string[]; // Allowed tools (inherits all if omitted) prompt: string; // Agent's system prompt model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; } ``` ### McpServerConfig ```typescript type McpServerConfig = | { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> } | { type: 'sse'; url: string; headers?: Record<string, string> } | { type: 'http'; url: string; headers?: Record<string, string> } | { type: 'sdk'; name: string; instance: McpServer } // in-process ``` ### SdkBeta ```typescript type SdkBeta = 'context-1m-2025-08-07'; // Enables 1M token context window for Opus 4.6, Sonnet 4.5, Sonnet 4 ``` ### CanUseTool ```typescript type CanUseTool = ( toolName: string, input: ToolInput, options: { signal: AbortSignal; suggestions?: PermissionUpdate[] } ) => Promise<PermissionResult>; type PermissionResult = | { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] } | { behavior: 'deny'; message: string; interrupt?: boolean }; ``` ## SDKMessage Types `query()` can yield 16 message types. The official docs show a simplified union of 7, but `sdk.d.ts` has the full set: | Type | Subtype | Purpose | |------|---------|---------| | `system` | `init` | Session initialized, contains session_id, tools, model | | `system` | `task_notification` | Background agent completed/failed/stopped | | `system` | `compact_boundary` | Conversation was compacted | | `system` | `status` | Status change (e.g. compacting) | | `system` | `hook_started` | Hook execution started | | `system` | `hook_progress` | Hook progress output | | `system` | `hook_response` | Hook completed | | `system` | `files_persisted` | Files saved | | `assistant` | — | Claude's response (text + tool calls) | | `user` | — | User message (internal) | | `user` (replay) | — | Replayed user message on resume | | `result` | `success` / `error_*` | Final result of a prompt processing round | | `stream_event` | — | Partial streaming (when includePartialMessages) | | `tool_progress` | — | Long-running tool progress | | `auth_status` | — | Authentication state changes | | `tool_use_summary` | — | Summary of preceding tool uses | ### SDKTaskNotificationMessage (sdk.d.ts:1507) ```typescript type SDKTaskNotificationMessage = { type: 'system'; subtype: 'task_notification'; task_id: string; status: 'completed' | 'failed' | 'stopped'; output_file: string; summary: string; uuid: UUID; session_id: string; }; ``` ### SDKResultMessage (sdk.d.ts:1375) Two variants with shared fields: ```typescript // Shared fields on both variants: // uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns, // total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials // Success: type SDKResultSuccess = { type: 'result'; subtype: 'success'; result: string; structured_output?: unknown; // ...shared fields }; // Error: type SDKResultError = { type: 'result'; subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries'; errors: string[]; // ...shared fields }; ``` Useful fields on result: `total_cost_usd`, `duration_ms`, `num_turns`, `modelUsage` (per-model breakdown with `costUSD`, `inputTokens`, `outputTokens`, `contextWindow`). ### SDKAssistantMessage ```typescript type SDKAssistantMessage = { type: 'assistant'; uuid: UUID; session_id: string; message: APIAssistantMessage; // From Anthropic SDK parent_tool_use_id: string | null; // Non-null when from subagent }; ``` ### SDKSystemMessage (init) ```typescript type SDKSystemMessage = { type: 'system'; subtype: 'init'; uuid: UUID; session_id: string; apiKeySource: ApiKeySource; cwd: string; tools: string[]; mcp_servers: { name: string; status: string }[]; model: string; permissionMode: PermissionMode; slash_commands: string[]; output_style: string; }; ``` ## Turn Behavior: When the Agent Stops vs Continues ### When the Agent STOPS (no more API calls) **1. No tool_use blocks in response (THE PRIMARY CASE)** Claude responded with text only — it decided it has completed the task. The API's `stop_reason` will be `"end_turn"`. The SDK does NOT make this decision — it's entirely driven by Claude's model output. **2. Max turns exceeded** — Results in `SDKResultError` with `subtype: "error_max_turns"`. **3. Abort signal** — User interruption via `abortController`. **4. Budget exceeded** — `totalCost >= maxBudgetUsd` → `"error_max_budget_usd"`. **5. Stop hook prevents continuation** — Hook returns `{preventContinuation: true}`. ### When the Agent CONTINUES (makes another API call) **1. Response contains tool_use blocks (THE PRIMARY CASE)** — Execute tools, increment turnCount, recurse into EZ. **2. max_output_tokens recovery** — Up to 3 retries with a "break your work into smaller pieces" context message. **3. Stop hook blocking errors** — Errors fed back as context messages, loop continues. **4. Model fallback** — Retry with fallback model (one-time). ### Decision Table | Condition | Action | Result Type | |-----------|--------|-------------| | Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues | | Response has NO `tool_use` blocks | Run stop hooks, return | `success` | | `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` | | `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` | | `abortController.signal.aborted` | Yield interrupted msg | depends on context | | `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues | | Stop hook `preventContinuation` | Return immediately | `success` | | Stop hook blocking error | Feed error back, recurse | continues | | Model fallback error | Retry with fallback model (one-time) | continues | ## Subagent Execution Modes ### Case 1: Synchronous Subagents (`run_in_background: false`) — BLOCKS Parent agent calls Task tool → `VR()` runs `EZ()` for subagent → parent waits for full result → tool result returned to parent → parent continues. The subagent runs the full recursive EZ loop. The parent's tool execution is suspended via `await`. There is a mid-execution "promotion" mechanism: a synchronous subagent can be promoted to background via `Promise.race()` against a `backgroundSignal` promise. ### Case 2: Background Tasks (`run_in_background: true`) — DOES NOT WAIT - **Bash tool:** Command spawned, tool returns immediately with empty result + `backgroundTaskId` - **Task/Agent tool:** Subagent launched in fire-and-forget wrapper (`g01()`), tool returns immediately with `status: "async_launched"` + `outputFile` path Zero "wait for background tasks" logic before emitting the `type: "result"` message. When a background task completes, an `SDKTaskNotificationMessage` is emitted separately. ### Case 3: Agent Teams (TeammateTool / SendMessage) — RESULT FIRST, THEN POLLING The team leader runs its normal EZ loop, which includes spawning teammates. When the leader's EZ loop finishes, `type: "result"` is emitted. Then the leader enters a post-result polling loop: ```javascript while (true) { // Check if no active teammates AND no running tasks → break // Check for unread messages from teammates → re-inject as new prompt, restart EZ loop // If stdin closed with active teammates → inject shutdown prompt // Poll every 500ms } ``` From the SDK consumer's perspective: you receive the initial `type: "result"`, but the AsyncGenerator may continue yielding more messages as the team leader processes teammate responses and re-enters the agent loop. The generator only truly finishes when all teammates have shut down. ## The isSingleUserTurn Problem From sdk.mjs: ```javascript QK = typeof X === "string" // isSingleUserTurn = true when prompt is a string ``` When `isSingleUserTurn` is true and the first `result` message arrives: ```javascript if (this.isSingleUserTurn) { this.transport.endInput(); // closes stdin to CLI } ``` This triggers a chain reaction: 1. SDK closes CLI stdin 2. CLI detects stdin close 3. Polling loop sees `D = true` (stdin closed) with active teammates 4. Injects shutdown prompt → leader sends `shutdown_request` to all teammates 5. **Teammates get killed mid-research** The shutdown prompt (found via `BGq` variable in minified cli.js): ``` You are running in non-interactive mode and cannot return a response to the user until your team is shut down. You MUST shut down your team before preparing your final response: 1. Use requestShutdown to ask each team member to shut down gracefully 2. Wait for shutdown approvals 3. Use the cleanup operation to clean up the team 4. Only then provide your final response to the user ``` ### The practical problem With V1 `query()` + string prompt + agent teams: 1. Leader spawns teammates, they start researching 2. Leader's EZ loop ends ("I've dispatched the team, they're working on it") 3. `type: "result"` emitted 4. SDK sees `isSingleUserTurn = true` → closes stdin immediately 5. Polling loop detects stdin closed + active teammates → injects shutdown prompt 6. Leader sends `shutdown_request` to all teammates 7. **Teammates could be 10 seconds into a 5-minute research task and they get told to stop** ## The Fix: Streaming Input Mode Instead of passing a string prompt (which sets `isSingleUserTurn = true`), pass an `AsyncIterable<SDKUserMessage>`: ```typescript // Before (broken for agent teams): query({ prompt: "do something" }) // After (keeps CLI alive): query({ prompt: asyncIterableOfMessages }) ``` When prompt is an `AsyncIterable`: - `isSingleUserTurn = false` - SDK does NOT close stdin after first result - CLI stays alive, continues processing - Background agents keep running - `task_notification` messages flow through the iterator - We control when to end the iterable ### Additional Benefit: Streaming New Messages With the async iterable approach, we can push new incoming WhatsApp messages into the iterable while the agent is still working. Instead of queuing messages until the container exits and spawning a new container, we stream them directly into the running session. ### Intended Lifecycle with Agent Teams With the async iterable fix (`isSingleUserTurn = false`), stdin stays open so the CLI never hits the teammate check or shutdown prompt injection: ``` 1. system/init → session initialized 2. assistant/user → Claude reasoning, tool calls, tool results 3. ... → more assistant/user turns (spawning subagents, etc.) 4. result #1 → lead agent's first response (capture) 5. task_notification(s) → background agents complete/fail/stop 6. assistant/user → lead agent continues (processing subagent results) 7. result #2 → lead agent's follow-up response (capture) 8. [iterator done] → CLI closed stdout, all done ``` All results are meaningful — capture every one, not just the first. ## V1 vs V2 API ### V1: `query()` — One-shot async generator ```typescript const q = query({ prompt: "...", options: {...} }); for await (const msg of q) { /* process events */ } ``` - When `prompt` is a string: `isSingleUserTurn = true` → stdin auto-closes after first result - For multi-turn: must pass an `AsyncIterable<SDKUserMessage>` and manage coordination yourself ### V2: `createSession()` + `send()` / `stream()` — Persistent session ```typescript await using session = unstable_v2_createSession({ model: "..." }); await session.send("first message"); for await (const msg of session.stream()) { /* events */ } await session.send("follow-up"); for await (const msg of session.stream()) { /* events */ } ``` - `isSingleUserTurn = false` always → stdin stays open - `send()` enqueues into an async queue (`QX`) - `stream()` yields from the same message generator, stopping on `result` type - Multi-turn is natural — just alternate `send()` / `stream()` - V2 does NOT call V1 `query()` internally — both independently create Transport + Query ### Comparison Table | Aspect | V1 | V2 | |--------|----|----| | `isSingleUserTurn` | `true` for string prompt | always `false` | | Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` | | stdin lifecycle | Auto-closes after first result | Stays open until `close()` | | Agentic loop | Identical `EZ()` | Identical `EZ()` | | Stop conditions | Same | Same | | Session persistence | Must pass `resume` to new `query()` | Built-in via session object | | API stability | Stable | Unstable preview (`unstable_v2_*` prefix) | **Key finding: Zero difference in turn behavior.** Both use the same CLI process, the same `EZ()` recursive generator, and the same decision logic. ## Hook Events ```typescript type HookEvent = | 'PreToolUse' // Before tool execution | 'PostToolUse' // After successful tool execution | 'PostToolUseFailure' // After failed tool execution | 'Notification' // Notification messages | 'UserPromptSubmit' // User prompt submitted | 'SessionStart' // Session started (startup/resume/clear/compact) | 'SessionEnd' // Session ended | 'Stop' // Agent stopping | 'SubagentStart' // Subagent spawned | 'SubagentStop' // Subagent stopped | 'PreCompact' // Before conversation compaction | 'PermissionRequest'; // Permission being requested ``` ### Hook Configuration ```typescript interface HookCallbackMatcher { matcher?: string; // Optional tool name matcher hooks: HookCallback[]; } type HookCallback = ( input: HookInput, toolUseID: string | undefined, options: { signal: AbortSignal } ) => Promise<HookJSONOutput>; ``` ### Hook Return Values ```typescript type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput; type AsyncHookJSONOutput = { async: true; asyncTimeout?: number }; type SyncHookJSONOutput = { continue?: boolean; suppressOutput?: boolean; stopReason?: string; decision?: 'approve' | 'block'; systemMessage?: string; reason?: string; hookSpecificOutput?: | { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record<string, unknown> } | { hookEventName: 'UserPromptSubmit'; additionalContext?: string } | { hookEventName: 'SessionStart'; additionalContext?: string } | { hookEventName: 'PostToolUse'; additionalContext?: string }; }; ``` ### Subagent Hooks (from sdk.d.ts) ```typescript type SubagentStartHookInput = BaseHookInput & { hook_event_name: 'SubagentStart'; agent_id: string; agent_type: string; }; type SubagentStopHookInput = BaseHookInput & { hook_event_name: 'SubagentStop'; stop_hook_active: boolean; agent_id: string; agent_transcript_path: string; agent_type: string; }; // BaseHookInput = { session_id, transcript_path, cwd, permission_mode? } ``` ## Query Interface Methods The `Query` object (sdk.d.ts:931). Official docs list these public methods: ```typescript interface Query extends AsyncGenerator<SDKMessage, void> { interrupt(): Promise<void>; // Stop current execution (streaming input mode only) rewindFiles(userMessageUuid: string): Promise<void>; // Restore files to state at message (needs enableFileCheckpointing) setPermissionMode(mode: PermissionMode): Promise<void>; // Change permissions (streaming input mode only) setModel(model?: string): Promise<void>; // Change model (streaming input mode only) setMaxThinkingTokens(max: number | null): Promise<void>; // Change thinking tokens (streaming input mode only) supportedCommands(): Promise<SlashCommand[]>; // Available slash commands supportedModels(): Promise<ModelInfo[]>; // Available models mcpServerStatus(): Promise<McpServerStatus[]>; // MCP server connection status accountInfo(): Promise<AccountInfo>; // Authenticated user info } ``` Found in sdk.d.ts but NOT in official docs (may be internal): - `streamInput(stream)` — stream additional user messages - `close()` — forcefully end the query - `setMcpServers(servers)` — dynamically add/remove MCP servers ## Sandbox Configuration ```typescript type SandboxSettings = { enabled?: boolean; autoAllowBashIfSandboxed?: boolean; excludedCommands?: string[]; allowUnsandboxedCommands?: boolean; network?: { allowLocalBinding?: boolean; allowUnixSockets?: string[]; allowAllUnixSockets?: boolean; httpProxyPort?: number; socksProxyPort?: number; }; ignoreViolations?: { file?: string[]; network?: string[]; }; }; ``` When `allowUnsandboxedCommands` is true, the model can set `dangerouslyDisableSandbox: true` in Bash tool input, which falls back to the `canUseTool` permission handler. ## MCP Server Helpers ### tool() Creates type-safe MCP tool definitions with Zod schemas: ```typescript function tool<Schema extends ZodRawShape>( name: string, description: string, inputSchema: Schema, handler: (args: z.infer<ZodObject<Schema>>, extra: unknown) => Promise<CallToolResult> ): SdkMcpToolDefinition<Schema> ``` ### createSdkMcpServer() Creates an in-process MCP server (we use stdio instead for subagent inheritance): ```typescript function createSdkMcpServer(options: { name: string; version?: string; tools?: Array<SdkMcpToolDefinition<any>>; }): McpSdkServerConfigWithInstance ``` ## Internals Reference ### Key minified identifiers (sdk.mjs) | Minified | Purpose | |----------|---------| | `s_` | V1 `query()` export | | `e_` | `unstable_v2_createSession` | | `Xx` | `unstable_v2_resumeSession` | | `Qx` | `unstable_v2_prompt` | | `U9` | V2 Session class (`send`/`stream`/`close`) | | `XX` | ProcessTransport (spawns cli.js) | | `$X` | Query class (JSON-line routing, async iterable) | | `QX` | AsyncQueue (input stream buffer) | ### Key minified identifiers (cli.js) | Minified | Purpose | |----------|---------| | `EZ` | Core recursive agentic loop (async generator) | | `_t4` | Stop hook handler (runs when no tool_use blocks) | | `PU1` | Streaming tool executor (parallel during API response) | | `TP6` | Standard tool executor (after API response) | | `GU1` | Individual tool executor | | `lTq` | SDK session runner (calls EZ directly) | | `bd1` | stdin reader (JSON-lines from transport) | | `mW1` | Anthropic API streaming caller | ## Key Files - `sdk.d.ts` — All type definitions (1777 lines) - `sdk-tools.d.ts` — Tool input schemas - `sdk.mjs` — SDK runtime (minified, 376KB) - `cli.js` — CLI executable (minified, runs as subprocess) ================================================ FILE: docs/SECURITY.md ================================================ # NanoClaw Security Model ## Trust Model | Entity | Trust Level | Rationale | |--------|-------------|-----------| | Main group | Trusted | Private self-chat, admin control | | Non-main groups | Untrusted | Other users may be malicious | | Container agents | Sandboxed | Isolated execution environment | | WhatsApp messages | User input | Potential prompt injection | ## Security Boundaries ### 1. Container Isolation (Primary Boundary) Agents execute in containers (lightweight Linux VMs), providing: - **Process isolation** - Container processes cannot affect the host - **Filesystem isolation** - Only explicitly mounted directories are visible - **Non-root execution** - Runs as unprivileged `node` user (uid 1000) - **Ephemeral containers** - Fresh environment per invocation (`--rm`) This is the primary security boundary. Rather than relying on application-level permission checks, the attack surface is limited by what's mounted. ### 2. Mount Security **External Allowlist** - Mount permissions stored at `~/.config/nanoclaw/mount-allowlist.json`, which is: - Outside project root - Never mounted into containers - Cannot be modified by agents **Default Blocked Patterns:** ``` .ssh, .gnupg, .aws, .azure, .gcloud, .kube, .docker, credentials, .env, .netrc, .npmrc, id_rsa, id_ed25519, private_key, .secret ``` **Protections:** - Symlink resolution before validation (prevents traversal attacks) - Container path validation (rejects `..` and absolute paths) - `nonMainReadOnly` option forces read-only for non-main groups **Read-Only Project Root:** The main group's project root is mounted read-only. Writable paths the agent needs (group folder, IPC, `.claude/`) are mounted separately. This prevents the agent from modifying host application code (`src/`, `dist/`, `package.json`, etc.) which would bypass the sandbox entirely on next restart. ### 3. Session Isolation Each group has isolated Claude sessions at `data/sessions/{group}/.claude/`: - Groups cannot see other groups' conversation history - Session data includes full message history and file contents read - Prevents cross-group information disclosure ### 4. IPC Authorization Messages and task operations are verified against group identity: | Operation | Main Group | Non-Main Group | |-----------|------------|----------------| | Send message to own chat | ✓ | ✓ | | Send message to other chats | ✓ | ✗ | | Schedule task for self | ✓ | ✓ | | Schedule task for others | ✓ | ✗ | | View all tasks | ✓ | Own only | | Manage other groups | ✓ | ✗ | ### 5. Credential Isolation (Credential Proxy) Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently. **How it works:** 1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001) 2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:<port>` and `ANTHROPIC_API_KEY=placeholder` 3. The SDK sends API requests to the proxy with the placeholder key 4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com` 5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` **NOT Mounted:** - WhatsApp session (`store/auth/`) - host only - Mount allowlist - external, never mounted - Any credentials matching blocked patterns - `.env` is shadowed with `/dev/null` in the project root mount ## Privilege Comparison | Capability | Main Group | Non-Main Group | |------------|------------|----------------| | Project root access | `/workspace/project` (ro) | None | | Group folder | `/workspace/group` (rw) | `/workspace/group` (rw) | | Global memory | Implicit via project | `/workspace/global` (ro) | | Additional mounts | Configurable | Read-only unless allowed | | Network access | Unrestricted | Unrestricted | | MCP tools | All | All | ## Security Architecture Diagram ``` ┌──────────────────────────────────────────────────────────────────┐ │ UNTRUSTED ZONE │ │ WhatsApp Messages (potentially malicious) │ └────────────────────────────────┬─────────────────────────────────┘ │ ▼ Trigger check, input escaping ┌──────────────────────────────────────────────────────────────────┐ │ HOST PROCESS (TRUSTED) │ │ • Message routing │ │ • IPC authorization │ │ • Mount validation (external allowlist) │ │ • Container lifecycle │ │ • Credential proxy (injects auth headers) │ └────────────────────────────────┬─────────────────────────────────┘ │ ▼ Explicit mounts only, no secrets ┌──────────────────────────────────────────────────────────────────┐ │ CONTAINER (ISOLATED/SANDBOXED) │ │ • Agent execution │ │ • Bash commands (sandboxed) │ │ • File operations (limited to mounts) │ │ • API calls routed through credential proxy │ │ • No real credentials in environment or filesystem │ └──────────────────────────────────────────────────────────────────┘ ``` ================================================ FILE: docs/SPEC.md ================================================ # NanoClaw Specification A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution. --- ## Table of Contents 1. [Architecture](#architecture) 2. [Architecture: Channel System](#architecture-channel-system) 3. [Folder Structure](#folder-structure) 4. [Configuration](#configuration) 5. [Memory System](#memory-system) 6. [Session Management](#session-management) 7. [Message Flow](#message-flow) 8. [Commands](#commands) 9. [Scheduled Tasks](#scheduled-tasks) 10. [MCP Servers](#mcp-servers) 11. [Deployment](#deployment) 12. [Security Considerations](#security-considerations) --- ## Architecture ``` ┌──────────────────────────────────────────────────────────────────────┐ │ HOST (macOS / Linux) │ │ (Main Node.js Process) │ ├──────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────────┐ ┌────────────────────┐ │ │ │ Channels │─────────────────▶│ SQLite Database │ │ │ │ (self-register │◀────────────────│ (messages.db) │ │ │ │ at startup) │ store/send └─────────┬──────────┘ │ │ └──────────────────┘ │ │ │ │ │ │ ┌─────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ │ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │ │ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │ │ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │ │ │ │ │ │ └───────────┬───────────┘ │ │ │ spawns container │ │ ▼ │ ├──────────────────────────────────────────────────────────────────────┤ │ CONTAINER (Linux VM) │ ├──────────────────────────────────────────────────────────────────────┤ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ AGENT RUNNER │ │ │ │ │ │ │ │ Working directory: /workspace/group (mounted from host) │ │ │ │ Volume mounts: │ │ │ │ • groups/{name}/ → /workspace/group │ │ │ │ • groups/global/ → /workspace/global/ (non-main only) │ │ │ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │ │ │ • Additional dirs → /workspace/extra/* │ │ │ │ │ │ │ │ Tools (all groups): │ │ │ │ • Bash (safe - sandboxed in container!) │ │ │ │ • Read, Write, Edit, Glob, Grep (file operations) │ │ │ │ • WebSearch, WebFetch (internet access) │ │ │ │ • agent-browser (browser automation) │ │ │ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │ │ │ │ │ │ └──────────────────────────────────────────────────────────────┘ │ │ │ └───────────────────────────────────────────────────────────────────────┘ ``` ### Technology Stack | Component | Technology | Purpose | |-----------|------------|---------| | Channel System | Channel registry (`src/channels/registry.ts`) | Channels self-register at startup | | Message Storage | SQLite (better-sqlite3) | Store messages for polling | | Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution | | Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers | | Browser Automation | agent-browser + Chromium | Web interaction and screenshots | | Runtime | Node.js 20+ | Host process for routing and scheduling | --- ## Architecture: Channel System The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a [Claude Code skill](https://code.claude.com/docs/en/skills) that adds the channel code to your fork. Channels self-register at startup; installed channels with missing credentials emit a WARN log and are skipped. ### System Diagram ```mermaid graph LR subgraph Channels["Channels"] WA[WhatsApp] TG[Telegram] SL[Slack] DC[Discord] New["Other Channel (Signal, Gmail...)"] end subgraph Orchestrator["Orchestrator — index.ts"] ML[Message Loop] GQ[Group Queue] RT[Router] TS[Task Scheduler] DB[(SQLite)] end subgraph Execution["Container Execution"] CR[Container Runner] LC["Linux Container"] IPC[IPC Watcher] end %% Flow WA & TG & SL & DC & New -->|onMessage| ML ML --> GQ GQ -->|concurrency| CR CR --> LC LC -->|filesystem IPC| IPC IPC -->|tasks & messages| RT RT -->|Channel.sendMessage| Channels TS -->|due tasks| CR %% DB Connections DB <--> ML DB <--> TS %% Styling for the dynamic channel style New stroke-dasharray: 5 5,stroke-width:2px ``` ### Channel Registry The channel system is built on a factory registry in `src/channels/registry.ts`: ```typescript export type ChannelFactory = (opts: ChannelOpts) => Channel | null; const registry = new Map<string, ChannelFactory>(); export function registerChannel(name: string, factory: ChannelFactory): void { registry.set(name, factory); } export function getChannelFactory(name: string): ChannelFactory | undefined { return registry.get(name); } export function getRegisteredChannelNames(): string[] { return [...registry.keys()]; } ``` Each factory receives `ChannelOpts` (callbacks for `onMessage`, `onChatMetadata`, and `registeredGroups`) and returns either a `Channel` instance or `null` if that channel's credentials are not configured. ### Channel Interface Every channel implements this interface (defined in `src/types.ts`): ```typescript interface Channel { name: string; connect(): Promise<void>; sendMessage(jid: string, text: string): Promise<void>; isConnected(): boolean; ownsJid(jid: string): boolean; disconnect(): Promise<void>; setTyping?(jid: string, isTyping: boolean): Promise<void>; syncGroups?(force: boolean): Promise<void>; } ``` ### Self-Registration Pattern Channels self-register using a barrel-import pattern: 1. Each channel skill adds a file to `src/channels/` (e.g. `whatsapp.ts`, `telegram.ts`) that calls `registerChannel()` at module load time: ```typescript // src/channels/whatsapp.ts import { registerChannel, ChannelOpts } from './registry.js'; export class WhatsAppChannel implements Channel { /* ... */ } registerChannel('whatsapp', (opts: ChannelOpts) => { // Return null if credentials are missing if (!existsSync(authPath)) return null; return new WhatsAppChannel(opts); }); ``` 2. The barrel file `src/channels/index.ts` imports all channel modules, triggering registration: ```typescript import './whatsapp.js'; import './telegram.js'; // ... each skill adds its import here ``` 3. At startup, the orchestrator (`src/index.ts`) loops through registered channels and connects whichever ones return a valid instance: ```typescript for (const name of getRegisteredChannelNames()) { const factory = getChannelFactory(name); const channel = factory?.(channelOpts); if (channel) { await channel.connect(); channels.push(channel); } } ``` ### Key Files | File | Purpose | |------|---------| | `src/channels/registry.ts` | Channel factory registry | | `src/channels/index.ts` | Barrel imports that trigger channel self-registration | | `src/types.ts` | `Channel` interface, `ChannelOpts`, message types | | `src/index.ts` | Orchestrator — instantiates channels, runs message loop | | `src/router.ts` | Finds the owning channel for a JID, formats messages | ### Adding a New Channel To add a new channel, contribute a skill to `.claude/skills/add-<name>/` that: 1. Adds a `src/channels/<name>.ts` file implementing the `Channel` interface 2. Calls `registerChannel(name, factory)` at module load 3. Returns `null` from the factory if credentials are missing 4. Adds an import line to `src/channels/index.ts` See existing skills (`/add-whatsapp`, `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`) for the pattern. --- ## Folder Structure ``` nanoclaw/ ├── CLAUDE.md # Project context for Claude Code ├── docs/ │ ├── SPEC.md # This specification document │ ├── REQUIREMENTS.md # Architecture decisions │ └── SECURITY.md # Security model ├── README.md # User documentation ├── package.json # Node.js dependencies ├── tsconfig.json # TypeScript configuration ├── .mcp.json # MCP server configuration (reference) ├── .gitignore │ ├── src/ │ ├── index.ts # Orchestrator: state, message loop, agent invocation │ ├── channels/ │ │ ├── registry.ts # Channel factory registry │ │ └── index.ts # Barrel imports for channel self-registration │ ├── ipc.ts # IPC watcher and task processing │ ├── router.ts # Message formatting and outbound routing │ ├── config.ts # Configuration constants │ ├── types.ts # TypeScript interfaces (includes Channel) │ ├── logger.ts # Pino logger setup │ ├── db.ts # SQLite database initialization and queries │ ├── group-queue.ts # Per-group queue with global concurrency limit │ ├── mount-security.ts # Mount allowlist validation for containers │ ├── whatsapp-auth.ts # Standalone WhatsApp authentication │ ├── task-scheduler.ts # Runs scheduled tasks when due │ └── container-runner.ts # Spawns agents in containers │ ├── container/ │ ├── Dockerfile # Container image (runs as 'node' user, includes Claude Code CLI) │ ├── build.sh # Build script for container image │ ├── agent-runner/ # Code that runs inside the container │ │ ├── package.json │ │ ├── tsconfig.json │ │ └── src/ │ │ ├── index.ts # Entry point (query loop, IPC polling, session resume) │ │ └── ipc-mcp-stdio.ts # Stdio-based MCP server for host communication │ └── skills/ │ └── agent-browser.md # Browser automation skill │ ├── dist/ # Compiled JavaScript (gitignored) │ ├── .claude/ │ └── skills/ │ ├── setup/SKILL.md # /setup - First-time installation │ ├── customize/SKILL.md # /customize - Add capabilities │ ├── debug/SKILL.md # /debug - Container debugging │ ├── add-telegram/SKILL.md # /add-telegram - Telegram channel │ ├── add-gmail/SKILL.md # /add-gmail - Gmail integration │ ├── add-voice-transcription/ # /add-voice-transcription - Whisper │ ├── x-integration/SKILL.md # /x-integration - X/Twitter │ ├── convert-to-apple-container/ # /convert-to-apple-container - Apple Container runtime │ └── add-parallel/SKILL.md # /add-parallel - Parallel agents │ ├── groups/ │ ├── CLAUDE.md # Global memory (all groups read this) │ ├── {channel}_main/ # Main control channel (e.g., whatsapp_main/) │ │ ├── CLAUDE.md # Main channel memory │ │ └── logs/ # Task execution logs │ └── {channel}_{group-name}/ # Per-group folders (created on registration) │ ├── CLAUDE.md # Group-specific memory │ ├── logs/ # Task logs for this group │ └── *.md # Files created by the agent │ ├── store/ # Local data (gitignored) │ ├── auth/ # WhatsApp authentication state │ └── messages.db # SQLite database (messages, chats, scheduled_tasks, task_run_logs, registered_groups, sessions, router_state) │ ├── data/ # Application state (gitignored) │ ├── sessions/ # Per-group session data (.claude/ dirs with JSONL transcripts) │ ├── env/env # Copy of .env for container mounting │ └── ipc/ # Container IPC (messages/, tasks/) │ ├── logs/ # Runtime logs (gitignored) │ ├── nanoclaw.log # Host stdout │ └── nanoclaw.error.log # Host stderr │ # Note: Per-container logs are in groups/{folder}/logs/container-*.log │ └── launchd/ └── com.nanoclaw.plist # macOS service configuration ``` --- ## Configuration Configuration constants are in `src/config.ts`: ```typescript import path from 'path'; export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || 'Andy'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; // Paths are absolute (required for container mounts) const PROJECT_ROOT = process.cwd(); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); // Container configuration export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); // 30min default export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min — keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5); export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i'); ``` **Note:** Paths must be absolute for container volume mounts to work correctly. ### Container Configuration Groups can have additional directories mounted via `containerConfig` in the SQLite `registered_groups` table (stored as JSON in the `container_config` column). Example registration: ```typescript setRegisteredGroup("1234567890@g.us", { name: "Dev Team", folder: "whatsapp_dev-team", trigger: "@Andy", added_at: new Date().toISOString(), containerConfig: { additionalMounts: [ { hostPath: "~/projects/webapp", containerPath: "webapp", readonly: false, }, ], timeout: 600000, }, }); ``` Folder names follow the convention `{channel}_{group-name}` (e.g., `whatsapp_family-chat`, `telegram_dev-team`). The main group has `isMain: true` set during registration. Additional mounts appear at `/workspace/extra/{containerPath}` inside the container. **Mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix may not work on all runtimes). ### Claude Authentication Configure authentication in a `.env` file in the project root. Two options: **Option 1: Claude Subscription (OAuth token)** ```bash CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... ``` The token can be extracted from `~/.claude/.credentials.json` if you're logged in to Claude Code. **Option 2: Pay-per-use API Key** ```bash ANTHROPIC_API_KEY=sk-ant-api03-... ``` Only the authentication variables (`CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY`) are extracted from `.env` and written to `data/env/env`, then mounted into the container at `/workspace/env-dir/env` and sourced by the entrypoint script. This ensures other environment variables in `.env` are not exposed to the agent. This workaround is needed because some container runtimes lose `-e` environment variables when using `-i` (interactive mode with piped stdin). ### Changing the Assistant Name Set the `ASSISTANT_NAME` environment variable: ```bash ASSISTANT_NAME=Bot npm start ``` Or edit the default in `src/config.ts`. This changes: - The trigger pattern (messages must start with `@YourName`) - The response prefix (`YourName:` added automatically) ### Placeholder Values in launchd Files with `{{PLACEHOLDER}}` values need to be configured: - `{{PROJECT_ROOT}}` - Absolute path to your nanoclaw installation - `{{NODE_PATH}}` - Path to node binary (detected via `which node`) - `{{HOME}}` - User's home directory --- ## Memory System NanoClaw uses a hierarchical memory system based on CLAUDE.md files. ### Memory Hierarchy | Level | Location | Read By | Written By | Purpose | |-------|----------|---------|------------|---------| | **Global** | `groups/CLAUDE.md` | All groups | Main only | Preferences, facts, context shared across all conversations | | **Group** | `groups/{name}/CLAUDE.md` | That group | That group | Group-specific context, conversation memory | | **Files** | `groups/{name}/*.md` | That group | That group | Notes, research, documents created during conversation | ### How Memory Works 1. **Agent Context Loading** - Agent runs with `cwd` set to `groups/{group-name}/` - Claude Agent SDK with `settingSources: ['project']` automatically loads: - `../CLAUDE.md` (parent directory = global memory) - `./CLAUDE.md` (current directory = group memory) 2. **Writing Memory** - When user says "remember this", agent writes to `./CLAUDE.md` - When user says "remember this globally" (main channel only), agent writes to `../CLAUDE.md` - Agent can create files like `notes.md`, `research.md` in the group folder 3. **Main Channel Privileges** - Only the "main" group (self-chat) can write to global memory - Main can manage registered groups and schedule tasks for any group - Main can configure additional directory mounts for any group - All groups have Bash access (safe because it runs inside container) --- ## Session Management Sessions enable conversation continuity - Claude remembers what you talked about. ### How Sessions Work 1. Each group has a session ID stored in SQLite (`sessions` table, keyed by `group_folder`) 2. Session ID is passed to Claude Agent SDK's `resume` option 3. Claude continues the conversation with full context 4. Session transcripts are stored as JSONL files in `data/sessions/{group}/.claude/` --- ## Message Flow ### Incoming Message Flow ``` 1. User sends a message via any connected channel │ ▼ 2. Channel receives message (e.g. Baileys for WhatsApp, Bot API for Telegram) │ ▼ 3. Message stored in SQLite (store/messages.db) │ ▼ 4. Message loop polls SQLite (every 2 seconds) │ ▼ 5. Router checks: ├── Is chat_jid in registered groups (SQLite)? → No: ignore └── Does message match trigger pattern? → No: store but don't process │ ▼ 6. Router catches up conversation: ├── Fetch all messages since last agent interaction ├── Format with timestamp and sender name └── Build prompt with full conversation context │ ▼ 7. Router invokes Claude Agent SDK: ├── cwd: groups/{group-name}/ ├── prompt: conversation history + current message ├── resume: session_id (for continuity) └── mcpServers: nanoclaw (scheduler) │ ▼ 8. Claude processes message: ├── Reads CLAUDE.md files for context └── Uses tools as needed (search, email, etc.) │ ▼ 9. Router prefixes response with assistant name and sends via the owning channel │ ▼ 10. Router updates last agent timestamp and saves session ID ``` ### Trigger Word Matching Messages must start with the trigger pattern (default: `@Andy`): - `@Andy what's the weather?` → ✅ Triggers Claude - `@andy help me` → ✅ Triggers (case insensitive) - `Hey @Andy` → ❌ Ignored (trigger not at start) - `What's up?` → ❌ Ignored (no trigger) ### Conversation Catch-Up When a triggered message arrives, the agent receives all messages since its last interaction in that chat. Each message is formatted with timestamp and sender name: ``` [Jan 31 2:32 PM] John: hey everyone, should we do pizza tonight? [Jan 31 2:33 PM] Sarah: sounds good to me [Jan 31 2:35 PM] John: @Andy what toppings do you recommend? ``` This allows the agent to understand the conversation context even if it wasn't mentioned in every message. --- ## Commands ### Commands Available in Any Group | Command | Example | Effect | |---------|---------|--------| | `@Assistant [message]` | `@Andy what's the weather?` | Talk to Claude | ### Commands Available in Main Channel Only | Command | Example | Effect | |---------|---------|--------| | `@Assistant add group "Name"` | `@Andy add group "Family Chat"` | Register a new group | | `@Assistant remove group "Name"` | `@Andy remove group "Work Team"` | Unregister a group | | `@Assistant list groups` | `@Andy list groups` | Show registered groups | | `@Assistant remember [fact]` | `@Andy remember I prefer dark mode` | Add to global memory | --- ## Scheduled Tasks NanoClaw has a built-in scheduler that runs tasks as full agents in their group's context. ### How Scheduling Works 1. **Group Context**: Tasks created in a group run with that group's working directory and memory 2. **Full Agent Capabilities**: Scheduled tasks have access to all tools (WebSearch, file operations, etc.) 3. **Optional Messaging**: Tasks can send messages to their group using the `send_message` tool, or complete silently 4. **Main Channel Privileges**: The main channel can schedule tasks for any group and view all tasks ### Schedule Types | Type | Value Format | Example | |------|--------------|---------| | `cron` | Cron expression | `0 9 * * 1` (Mondays at 9am) | | `interval` | Milliseconds | `3600000` (every hour) | | `once` | ISO timestamp | `2024-12-25T09:00:00Z` | ### Creating a Task ``` User: @Andy remind me every Monday at 9am to review the weekly metrics Claude: [calls mcp__nanoclaw__schedule_task] { "prompt": "Send a reminder to review weekly metrics. Be encouraging!", "schedule_type": "cron", "schedule_value": "0 9 * * 1" } Claude: Done! I'll remind you every Monday at 9am. ``` ### One-Time Tasks ``` User: @Andy at 5pm today, send me a summary of today's emails Claude: [calls mcp__nanoclaw__schedule_task] { "prompt": "Search for today's emails, summarize the important ones, and send the summary to the group.", "schedule_type": "once", "schedule_value": "2024-01-31T17:00:00Z" } ``` ### Managing Tasks From any group: - `@Andy list my scheduled tasks` - View tasks for this group - `@Andy pause task [id]` - Pause a task - `@Andy resume task [id]` - Resume a paused task - `@Andy cancel task [id]` - Delete a task From main channel: - `@Andy list all tasks` - View tasks from all groups - `@Andy schedule task for "Family Chat": [prompt]` - Schedule for another group --- ## MCP Servers ### NanoClaw MCP (built-in) The `nanoclaw` MCP server is created dynamically per agent call with the current group's context. **Available Tools:** | Tool | Purpose | |------|---------| | `schedule_task` | Schedule a recurring or one-time task | | `list_tasks` | Show tasks (group's tasks, or all if main) | | `get_task` | Get task details and run history | | `update_task` | Modify task prompt or schedule | | `pause_task` | Pause a task | | `resume_task` | Resume a paused task | | `cancel_task` | Delete a task | | `send_message` | Send a message to the group via its channel | --- ## Deployment NanoClaw runs as a single macOS launchd service. ### Startup Sequence When NanoClaw starts, it: 1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs 2. Initializes the SQLite database (migrates from JSON files if they exist) 3. Loads state from SQLite (registered groups, sessions, router state) 4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each 5. Once at least one channel is connected: - Starts the scheduler loop - Starts the IPC watcher for container messages - Sets up the per-group queue with `processGroupMessages` - Recovers any unprocessed messages from before shutdown - Starts the message polling loop ### Service: com.nanoclaw **launchd/com.nanoclaw.plist:** ```xml <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "..."> <plist version="1.0"> <dict> <key>Label</key> <string>com.nanoclaw</string> <key>ProgramArguments</key> <array> <string>{{NODE_PATH}}</string> <string>{{PROJECT_ROOT}}/dist/index.js</string> </array> <key>WorkingDirectory</key> <string>{{PROJECT_ROOT}}</string> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>EnvironmentVariables</key> <dict> <key>PATH</key> <string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string> <key>HOME</key> <string>{{HOME}}</string> <key>ASSISTANT_NAME</key> <string>Andy</string> </dict> <key>StandardOutPath</key> <string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string> <key>StandardErrorPath</key> <string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string> </dict> </plist> ``` ### Managing the Service ```bash # Install service cp launchd/com.nanoclaw.plist ~/Library/LaunchAgents/ # Start service launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist # Stop service launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist # Check status launchctl list | grep nanoclaw # View logs tail -f logs/nanoclaw.log ``` --- ## Security Considerations ### Container Isolation All agents run inside containers (lightweight Linux VMs), providing: - **Filesystem isolation**: Agents can only access mounted directories - **Safe Bash access**: Commands run inside the container, not on your Mac - **Network isolation**: Can be configured per-container if needed - **Process isolation**: Container processes can't affect the host - **Non-root user**: Container runs as unprivileged `node` user (uid 1000) ### Prompt Injection Risk WhatsApp messages could contain malicious instructions attempting to manipulate Claude's behavior. **Mitigations:** - Container isolation limits blast radius - Only registered groups are processed - Trigger word required (reduces accidental processing) - Agents can only access their group's mounted directories - Main can configure additional directories per group - Claude's built-in safety training **Recommendations:** - Only register trusted groups - Review additional directory mounts carefully - Review scheduled tasks periodically - Monitor logs for unusual activity ### Credential Storage | Credential | Storage Location | Notes | |------------|------------------|-------| | Claude CLI Auth | data/sessions/{group}/.claude/ | Per-group isolation, mounted to /home/node/.claude/ | | WhatsApp Session | store/auth/ | Auto-created, persists ~20 days | ### File Permissions The groups/ folder contains personal memory and should be protected: ```bash chmod 700 groups/ ``` --- ## Troubleshooting ### Common Issues | Issue | Cause | Solution | |-------|-------|----------| | No response to messages | Service not running | Check `launchctl list | grep nanoclaw` | | "Claude Code process exited with code 1" | Container runtime failed to start | Check logs; NanoClaw auto-starts container runtime but may fail | | "Claude Code process exited with code 1" | Session mount path wrong | Ensure mount is to `/home/node/.claude/` not `/root/.claude/` | | Session not continuing | Session ID not saved | Check SQLite: `sqlite3 store/messages.db "SELECT * FROM sessions"` | | Session not continuing | Mount path mismatch | Container user is `node` with HOME=/home/node; sessions must be at `/home/node/.claude/` | | "QR code expired" | WhatsApp session expired | Delete store/auth/ and restart | | "No groups registered" | Haven't added groups | Use `@Andy add group "Name"` in main | ### Log Location - `logs/nanoclaw.log` - stdout - `logs/nanoclaw.error.log` - stderr ### Debug Mode Run manually for verbose output: ```bash npm run dev # or node dist/index.js ``` ================================================ FILE: docs/docker-sandboxes.md ================================================ # Running NanoClaw in Docker Sandboxes (Manual Setup) This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation. ## Architecture ``` Host (macOS / Windows WSL) └── Docker Sandbox (micro VM with isolated kernel) ├── NanoClaw process (Node.js) │ ├── Channel adapters (WhatsApp, Telegram, etc.) │ └── Container spawner → nested Docker daemon └── Docker-in-Docker └── nanoclaw-agent containers └── Claude Agent SDK ``` Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary. The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically. > **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies. ## Prerequisites - **Docker Desktop v4.40+** with Sandbox support - **Anthropic API key** (the sandbox proxy manages injection) - For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID - For **WhatsApp**: a phone with WhatsApp installed Verify sandbox support: ```bash docker sandbox version ``` ## Step 1: Create the Sandbox On your host machine: ```bash # Create a workspace directory mkdir -p ~/nanoclaw-workspace # Create a shell sandbox with the workspace mounted docker sandbox create shell ~/nanoclaw-workspace ``` If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected: ```bash docker sandbox network proxy shell-nanoclaw-workspace \ --bypass-host web.whatsapp.com \ --bypass-host "*.whatsapp.com" \ --bypass-host "*.whatsapp.net" ``` Telegram does not need proxy bypass. Enter the sandbox: ```bash docker sandbox run shell-nanoclaw-workspace ``` ## Step 2: Install Prerequisites Inside the sandbox: ```bash sudo apt-get update && sudo apt-get install -y build-essential python3 npm config set strict-ssl false ``` ## Step 3: Clone and Install NanoClaw NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path. ```bash # Clone to home first (virtiofs can corrupt git pack files during clone) cd ~ git clone https://github.com/qwibitai/nanoclaw.git # Replace with YOUR workspace path (the host path you passed to `docker sandbox create`) WORKSPACE=/Users/you/nanoclaw-workspace # Move into workspace so DinD mounts work mv nanoclaw "$WORKSPACE/nanoclaw" cd "$WORKSPACE/nanoclaw" # Install dependencies npm install npm install https-proxy-agent ``` ## Step 4: Apply Proxy and Sandbox Patches NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions. ### 4a. Dockerfile — proxy args for container image build `npm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`: Add these lines after the `FROM` line: ```dockerfile # Accept proxy build args ARG http_proxy ARG https_proxy ARG no_proxy ARG NODE_EXTRA_CA_CERTS ARG npm_config_strict_ssl=true RUN npm config set strict-ssl ${npm_config_strict_ssl} ``` And after the `RUN npm install` line: ```dockerfile RUN npm config set strict-ssl true ``` ### 4b. Build script — forward proxy args Patch `container/build.sh` to pass proxy env vars to `docker build`: Add these `--build-arg` flags to the `docker build` command: ```bash --build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \ --build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \ --build-arg no_proxy="${no_proxy:-$NO_PROXY}" \ --build-arg npm_config_strict_ssl=false \ ``` ### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix Three changes to `src/container-runner.ts`: **Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file: ```typescript // Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts) const emptyEnvPath = path.join(DATA_DIR, 'empty-env'); if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, ''); // Use emptyEnvPath instead of '/dev/null' in the mount ``` **Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants. **Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers: ```typescript const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE; if (caCertSrc) { const certDir = path.join(DATA_DIR, 'ca-cert'); fs.mkdirSync(certDir, { recursive: true }); fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt')); // Mount: certDir -> /workspace/ca-cert (read-only) // Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container } ``` ### 4d. Container runtime — prevent self-termination In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname: ```typescript // In cleanupOrphans(), filter out os.hostname() from the list of containers to stop ``` ### 4e. Credential proxy — route through MITM proxy In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests: ```typescript import { HttpsProxyAgent } from 'https-proxy-agent'; const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy; const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; // Pass upstreamAgent to https.request() options ``` ### 4f. Setup script — proxy build args Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b). ## Step 5: Build ```bash npm run build bash container/build.sh ``` ## Step 6: Add a Channel ### Telegram ```bash # Apply the Telegram skill npx tsx scripts/apply-skill.ts .claude/skills/add-telegram # Rebuild after applying the skill npm run build # Configure .env cat > .env << EOF TELEGRAM_BOT_TOKEN=<your-token-from-botfather> ASSISTANT_NAME=nanoclaw ANTHROPIC_API_KEY=proxy-managed EOF mkdir -p data/env && cp .env data/env/env # Register your chat npx tsx setup/index.ts --step register \ --jid "tg:<your-chat-id>" \ --name "My Chat" \ --trigger "@nanoclaw" \ --folder "telegram_main" \ --channel telegram \ --assistant-name "nanoclaw" \ --is-main \ --no-trigger-required ``` **To find your chat ID:** Send any message to your bot, then: ```bash curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot<TOKEN>/getUpdates" | python3 -m json.tool ``` **Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot. **Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild. ### WhatsApp Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first. ```bash # Apply the WhatsApp skill npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp # Rebuild npm run build # Configure .env cat > .env << EOF ASSISTANT_NAME=nanoclaw ANTHROPIC_API_KEY=proxy-managed EOF mkdir -p data/env && cp .env data/env/env # Authenticate (choose one): # QR code — scan with WhatsApp camera: npx tsx src/whatsapp-auth.ts # OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number: npx tsx src/whatsapp-auth.ts --pairing-code --phone <phone-number-no-plus> # Register your chat (JID = your phone number + @s.whatsapp.net) npx tsx setup/index.ts --step register \ --jid "<phone>@s.whatsapp.net" \ --name "My Chat" \ --trigger "@nanoclaw" \ --folder "whatsapp_main" \ --channel whatsapp \ --assistant-name "nanoclaw" \ --is-main \ --no-trigger-required ``` **Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild. ### Both Channels Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately. ## Step 7: Run ```bash npm start ``` You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically. ## Networking Details ### How the proxy works All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`: ``` Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com ``` **"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client. ### Shared paths for DinD mounts Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared": - `/dev/null` → replace with an empty file in the project dir - `/usr/local/share/ca-certificates/` → copy cert to project dir - `/home/agent/` → clone to workspace instead ### Git clone and virtiofs The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace. ## Troubleshooting ### npm install fails with SELF_SIGNED_CERT_IN_CHAIN ```bash npm config set strict-ssl false ``` ### Container build fails with proxy errors ```bash docker build \ --build-arg http_proxy=$http_proxy \ --build-arg https_proxy=$https_proxy \ -t nanoclaw-agent:latest container/ ``` ### Agent containers fail with "path not shared" All bind-mounted paths must be under the workspace directory. Check: - Is NanoClaw cloned into the workspace? (not `/home/agent/`) - Is the CA cert copied to the project root? - Has the empty `.env` shadow file been created? ### Agent containers can't reach Anthropic API Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`. ### WhatsApp error 405 The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`. ### WhatsApp "Connection failed" immediately Proxy bypass not configured. From the **host**, run: ```bash docker sandbox network proxy <sandbox-name> \ --bypass-host web.whatsapp.com \ --bypass-host "*.whatsapp.com" \ --bypass-host "*.whatsapp.net" ``` ### Telegram bot doesn't receive messages 1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`) 2. Check Group Privacy is disabled in @BotFather if using in groups ### Git clone fails with "inflate: data stream error" Clone to a non-workspace path first, then move: ```bash cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw ``` ### WhatsApp QR code doesn't display Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`): ```bash docker sandbox run shell-nanoclaw-workspace # Then inside: npx tsx src/whatsapp-auth.ts ``` ================================================ FILE: docs/nanoclaw-architecture-final.md ================================================ # NanoClaw Skills Architecture ## Core Principle Skills are self-contained, auditable packages that apply programmatically via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict on its own. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure. ### The Three-Level Resolution Model Every operation in the system follows this escalation: 1. **Git** — deterministic, programmatic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI involved. This handles the vast majority of cases. 2. **Claude Code** — reads `SKILL.md`, `.intent.md`, migration guides, and `state.yaml` to understand context. Resolves conflicts that git can't handle programmatically. Caches the resolution via `git rerere` so it never needs to resolve the same conflict again. 3. **User** — Claude Code asks the user when it lacks context or intent. This happens when two features genuinely conflict at an application level (not just a text-level merge conflict) and a human decision is needed about desired behavior. The goal is that Level 1 handles everything on a mature, well-tested installation. Level 2 handles first-time conflicts and edge cases. Level 3 is rare and only for genuine ambiguity. **Important**: a clean merge (exit code 0) does not guarantee working code. Semantic conflicts — a renamed variable, a shifted reference, a changed function signature — can produce clean text merges that break at runtime. **Tests must run after every operation**, regardless of whether the merge was clean. A clean merge with failing tests escalates to Level 2. ### Safe Operations via Backup/Restore Many users clone the repo without forking, don't commit their changes, and don't think of themselves as git users. The system must work safely for them without requiring any git knowledge. Before any operation, the system copies all files that will be modified to `.nanoclaw/backup/`. On success, the backup is deleted. On failure, the backup is restored. This provides rollback safety regardless of whether the user commits, pushes, or understands git. --- ## 1. The Shared Base `.nanoclaw/base/` holds the clean core — the original codebase before any skills or customizations were applied. This is the stable common ancestor for all three-way merges, and it only changes on core updates. - `git merge-file` uses the base to compute two diffs: what the user changed (current vs base) and what the skill wants to change (base vs skill's modified file), then combines both - The base enables drift detection: if a file's hash differs from its base hash, something has been modified (skills, user customizations, or both) - Each skill's `modify/` files contain the full file as it should look with that skill applied (including any prerequisite skill changes), all authored against the same clean core base On a **fresh codebase**, the user's files are identical to the base. This means `git merge-file` always exits cleanly for the first skill — the merge trivially produces the skill's modified version. No special-casing needed. When multiple skills modify the same file, the three-way merge handles the overlap naturally. If Telegram and Discord both modify `src/index.ts`, and both skill files include the Telegram changes, those common changes merge cleanly against the base. The result is the base + all skill changes + user customizations. --- ## 2. Two Types of Changes: Code Merges vs. Structured Operations Not all files should be merged as text. The system distinguishes between **code files** (merged via `git merge-file`) and **structured data** (modified via deterministic operations). ### Code Files (Three-Way Merge) Source code files where skills weave in logic — route handlers, middleware, business logic. These are merged using `git merge-file` against the shared base. The skill carries a full modified version of the file. ### Structured Data (Deterministic Operations) Files like `package.json`, `docker-compose.yml`, `.env.example`, and generated configs are not code you merge — they're structured data you aggregate. Multiple skills adding npm dependencies to `package.json` shouldn't require a three-way text merge. Instead, skills declare their structured requirements in the manifest, and the system applies them programmatically. **Structured operations are implicit.** If a skill declares `npm_dependencies`, the system handles dependency installation automatically. There is no need for the skill author to add `npm install` to `post_apply`. When multiple skills are applied in sequence, the system batches structured operations: merge all dependency declarations first, write `package.json` once, run `npm install` once at the end. ```yaml # In manifest.yaml structured: npm_dependencies: whatsapp-web.js: "^2.1.0" qrcode-terminal: "^0.12.0" env_additions: - WHATSAPP_TOKEN - WHATSAPP_VERIFY_TOKEN - WHATSAPP_PHONE_ID docker_compose_services: whatsapp-redis: image: redis:alpine ports: ["6380:6379"] ``` ### Structured Operation Conflicts Structured operations eliminate text merge conflicts but can still conflict at a semantic level: - **NPM version conflicts**: two skills request incompatible semver ranges for the same package - **Port collisions**: two docker-compose services claim the same host port - **Service name collisions**: two skills define a service with the same name - **Env var duplicates**: two skills declare the same variable with different expectations The resolution policy: 1. **Automatic where possible**: widen semver ranges to find a compatible version, detect and flag port/name collisions 2. **Level 2 (Claude Code)**: if automatic resolution fails, Claude proposes options based on skill intents 3. **Level 3 (User)**: if it's a genuine product choice (which Redis instance should get port 6379?), ask the user Structured operation conflicts are included in the CI overlap graph alongside code file overlaps, so the maintainer test matrix catches these before users encounter them. ### State Records Structured Outcomes `state.yaml` records not just the declared dependencies but the resolved outcomes — actual installed versions, resolved port assignments, final env var list. This makes structured operations replayable and auditable. ### Deterministic Serialization All structured output (YAML, JSON) uses stable serialization: sorted keys, consistent quoting, normalized whitespace. This prevents noisy diffs in git history from non-functional formatting changes. --- ## 3. Skill Package Structure A skill contains only the files it adds or modifies. For modified code files, the skill carries the **full modified file** (the clean core with the skill's changes applied). ``` skills/ add-whatsapp/ SKILL.md # Context, intent, what this skill does and why manifest.yaml # Metadata, dependencies, env vars, post-apply steps tests/ # Integration tests for this skill whatsapp.test.ts add/ # New files — copied directly src/channels/whatsapp.ts src/channels/whatsapp.config.ts modify/ # Modified code files — merged via git merge-file src/ server.ts # Full file: clean core + whatsapp changes server.ts.intent.md # "Adds WhatsApp webhook route and message handler" config.ts # Full file: clean core + whatsapp config options config.ts.intent.md # "Adds WhatsApp channel configuration block" ``` ### Why Full Modified Files - `git merge-file` requires three full files — no intermediate reconstruction step - Git's three-way merge uses context matching, so it works even if the user has moved code around — unlike line-number-based diffs that break immediately - Auditable: `diff .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts` shows exactly what the skill changes - Deterministic: same three inputs always produce the same merge result - Size is negligible since NanoClaw's core files are small ### Intent Files Each modified code file has a corresponding `.intent.md` with structured headings: ```markdown # Intent: server.ts modifications ## What this skill adds Adds WhatsApp webhook route and message handler to the Express server. ## Key sections - Route registration at `/webhook/whatsapp` (POST and GET for verification) - Message handler middleware between auth and response pipeline ## Invariants - Must not interfere with other channel webhook routes - Auth middleware must run before the WhatsApp handler - Error handling must propagate to the global error handler ## Must-keep sections - The webhook verification flow (GET route) is required by WhatsApp Cloud API ``` Structured headings (What, Key sections, Invariants, Must-keep) give Claude Code specific guidance during conflict resolution instead of requiring it to infer from unstructured text. ### Manifest Format ```yaml # --- Required fields --- skill: whatsapp version: 1.2.0 description: "WhatsApp Business API integration via Cloud API" core_version: 0.1.0 # The core version this skill was authored against # Files this skill adds adds: - src/channels/whatsapp.ts - src/channels/whatsapp.config.ts # Code files this skill modifies (three-way merge) modifies: - src/server.ts - src/config.ts # File operations (renames, deletes, moves — see Section 5) file_ops: [] # Structured operations (deterministic, no merge — implicit handling) structured: npm_dependencies: whatsapp-web.js: "^2.1.0" qrcode-terminal: "^0.12.0" env_additions: - WHATSAPP_TOKEN - WHATSAPP_VERIFY_TOKEN - WHATSAPP_PHONE_ID # Skill relationships conflicts: [] # Skills that cannot coexist without agent resolution depends: [] # Skills that must be applied first # Test command — runs after apply to validate the skill works test: "npx vitest run src/channels/whatsapp.test.ts" # --- Future fields (not yet implemented in v0.1) --- # author: nanoclaw-team # license: MIT # min_skills_system_version: "0.1.0" # tested_with: [telegram@1.0.0] # post_apply: [] ``` Note: `post_apply` is only for operations that can't be expressed as structured declarations. Dependency installation is **never** in `post_apply` — it's handled implicitly by the structured operations system. --- ## 4. Skills, Customization, and Layering ### One Skill, One Happy Path A skill implements **one way of doing something — the reasonable default that covers 80% of users.** `add-telegram` gives you a clean, solid Telegram integration. It doesn't try to anticipate every use case with predefined configuration options and modes. ### Customization Is Just More Patching The entire system is built around applying transformations to a codebase. Customizing a skill after applying it is no different from any other modification: - **Apply the skill** — get the standard Telegram integration - **Modify from there** — using the customize flow (tracked patch), direct editing (detected by hash tracking), or by applying additional skills that build on top ### Layered Skills Skills can build on other skills: ``` add-telegram # Core Telegram integration (happy path) ├── telegram-reactions # Adds reaction handling (depends: [telegram]) ├── telegram-multi-bot # Multiple bot instances (depends: [telegram]) └── telegram-filters # Custom message filtering (depends: [telegram]) ``` Each layer is a separate skill with its own `SKILL.md`, manifest (with `depends: [telegram]`), tests, and modified files. The user composes exactly what they want by stacking skills. ### Custom Skill Application A user can apply a skill with their own modifications in a single step: 1. Apply the skill normally (programmatic merge) 2. Claude Code asks if the user wants to make any modifications 3. User describes what they want different 4. Claude Code makes the modifications on top of the freshly applied skill 5. The modifications are recorded as a custom patch tied to this skill Recorded in `state.yaml`: ```yaml applied_skills: - skill: telegram version: 1.0.0 custom_patch: .nanoclaw/custom/telegram-group-only.patch custom_patch_description: "Restrict bot responses to group chats only" ``` On replay, the skill applies programmatically, then the custom patch applies on top. --- ## 5. File Operations: Renames, Deletes, Moves Core updates and some skills will need to rename, delete, or move files. These are not text merges — they're structural changes handled as explicit scripted operations. ### Declaration in Manifest ```yaml file_ops: - type: rename from: src/server.ts to: src/app.ts - type: delete path: src/deprecated/old-handler.ts - type: move from: src/utils/helpers.ts to: src/lib/helpers.ts ``` ### Execution Order File operations run **before** code merges, because merges need to target the correct file paths: 1. Pre-flight checks (state validation, core version, dependencies, conflicts, drift detection) 2. Acquire operation lock 3. **Backup** all files that will be touched 4. **File operations** (renames, deletes, moves) 5. Copy new files from `add/` 6. Three-way merge modified code files 7. Conflict resolution (rerere auto-resolve, or return with `backupPending: true`) 8. Apply structured operations (npm deps, env vars, docker-compose — batched) 9. Run `npm install` (once, if any structured npm_dependencies exist) 10. Update state (record skill application, file hashes, structured outcomes) 11. Run tests (if `manifest.test` defined; rollback state + backup on failure) 12. Clean up (delete backup on success, release lock) ### Path Remapping for Skills When the core renames a file (e.g., `server.ts` → `app.ts`), skills authored against the old path still reference `server.ts` in their `modifies` and `modify/` directories. **Skill packages are never mutated on the user's machine.** Instead, core updates ship a **compatibility map**: ```yaml # In the update package path_remap: src/server.ts: src/app.ts src/old-config.ts: src/config/main.ts ``` The system resolves paths at apply time: if a skill targets `src/server.ts` and the remap says it's now `src/app.ts`, the merge runs against `src/app.ts`. The remap is recorded in `state.yaml` so future operations are consistent. ### Safety Checks Before executing file operations: - Verify the source file exists - For deletes: warn if the file has modifications beyond the base (user or skill changes would be lost) --- ## 6. The Apply Flow When a user runs the skill's slash command in Claude Code: ### Step 1: Pre-flight Checks - Core version compatibility - Dependencies satisfied - No unresolvable conflicts with applied skills - Check for untracked changes (see Section 9) ### Step 2: Backup Copy all files that will be modified to `.nanoclaw/backup/`. If the operation fails at any point, restore from backup. ### Step 3: File Operations Execute renames, deletes, or moves with safety checks. Apply path remapping if needed. ### Step 4: Apply New Files ```bash cp skills/add-whatsapp/add/src/channels/whatsapp.ts src/channels/whatsapp.ts ``` ### Step 5: Merge Modified Code Files For each file in `modifies` (with path remapping applied): ```bash git merge-file src/server.ts .nanoclaw/base/src/server.ts skills/add-whatsapp/modify/src/server.ts ``` - **Exit code 0**: clean merge, move on - **Exit code > 0**: conflict markers in file, proceed to resolution ### Step 6: Conflict Resolution (Three-Level) 1. **Check shared resolution cache** (`.nanoclaw/resolutions/`) — load into local `git rerere` if a verified resolution exists for this skill combination. **Only apply if input hashes match exactly** (base hash + current hash + skill modified hash). 2. **`git rerere`** — checks local cache. If found, applied automatically. Done. 3. **Claude Code** — reads conflict markers + `SKILL.md` + `.intent.md` (Invariants, Must-keep sections) of current and previously applied skills. Resolves. `git rerere` caches the resolution. 4. **User** — if Claude Code cannot determine intent, it asks the user for the desired behavior. ### Step 7: Apply Structured Operations Collect all structured declarations (from this skill and any previously applied skills if batching). Apply deterministically: - Merge npm dependencies into `package.json` (check for version conflicts) - Append env vars to `.env.example` - Merge docker-compose services (check for port/name collisions) - Run `npm install` **once** at the end - Record resolved outcomes in state ### Step 8: Post-Apply and Validate 1. Run any `post_apply` commands (non-structured operations only) 2. Update `.nanoclaw/state.yaml` — skill record, file hashes (base, skill, merged per file), structured outcomes 3. **Run skill tests** — mandatory, even if all merges were clean 4. If tests fail on a clean merge → escalate to Level 2 (Claude Code diagnoses the semantic conflict) ### Step 9: Clean Up If tests pass, delete `.nanoclaw/backup/`. The operation is complete. If tests fail and Level 2 can't resolve, restore from `.nanoclaw/backup/` and report the failure. --- ## 7. Shared Resolution Cache ### The Problem `git rerere` is local by default. But NanoClaw has thousands of users applying the same skill combinations. Every user hitting the same conflict and waiting for Claude Code to resolve it is wasteful. ### The Solution NanoClaw maintains a verified resolution cache in `.nanoclaw/resolutions/` that ships with the project. This is the shared artifact — **not** `.git/rr-cache/`, which stays local. ``` .nanoclaw/ resolutions/ whatsapp@1.2.0+telegram@1.0.0/ src/ server.ts.resolution server.ts.preimage config.ts.resolution config.ts.preimage meta.yaml ``` ### Hash Enforcement A cached resolution is **only applied if input hashes match exactly**: ```yaml # meta.yaml skills: - whatsapp@1.2.0 - telegram@1.0.0 apply_order: [whatsapp, telegram] core_version: 0.6.0 resolved_at: 2026-02-15T10:00:00Z tested: true test_passed: true resolution_source: maintainer input_hashes: base: "aaa..." current_after_whatsapp: "bbb..." telegram_modified: "ccc..." output_hash: "ddd..." ``` If any input hash doesn't match, the cached resolution is skipped and the system proceeds to Level 2. ### Validated: rerere + merge-file Require an Index Adapter `git rerere` does **not** natively recognize `git merge-file` output. This was validated in Phase 0 testing (`tests/phase0-merge-rerere.sh`, 33 tests). The issue is not about conflict marker format — `merge-file` uses filenames as labels (`<<<<<<< current.ts`) while `git merge` uses branch names (`<<<<<<< HEAD`), but rerere strips all labels and hashes only the conflict body. The formats are compatible. The actual issue: **rerere requires unmerged index entries** (stages 1/2/3) to detect that a merge conflict exists. A normal `git merge` creates these automatically. `git merge-file` operates on the filesystem only and does not touch the index. #### The Adapter After `git merge-file` produces a conflict, the system must create the index state that rerere expects: ```bash # 1. Run the merge (produces conflict markers in the working tree) git merge-file current.ts .nanoclaw/base/src/file.ts skills/add-whatsapp/modify/src/file.ts # 2. If exit code > 0 (conflict), set up rerere adapter: # Create blob objects for the three versions base_hash=$(git hash-object -w .nanoclaw/base/src/file.ts) ours_hash=$(git hash-object -w skills/previous-skill/modify/src/file.ts) # or the pre-merge current theirs_hash=$(git hash-object -w skills/add-whatsapp/modify/src/file.ts) # Create unmerged index entries at stages 1 (base), 2 (ours), 3 (theirs) printf '100644 %s 1\tsrc/file.ts\0' "$base_hash" | git update-index --index-info printf '100644 %s 2\tsrc/file.ts\0' "$ours_hash" | git update-index --index-info printf '100644 %s 3\tsrc/file.ts\0' "$theirs_hash" | git update-index --index-info # Set merge state (rerere checks for MERGE_HEAD) echo "$(git rev-parse HEAD)" > .git/MERGE_HEAD echo "skill merge" > .git/MERGE_MSG # 3. Now rerere can see the conflict git rerere # Records preimage, or auto-resolves from cache # 4. After resolution (manual or auto): git add src/file.ts git rerere # Records postimage (caches the resolution) # 5. Clean up merge state rm .git/MERGE_HEAD .git/MERGE_MSG git reset HEAD ``` #### Key Properties Validated - **Conflict body identity**: `merge-file` and `git merge` produce identical conflict bodies for the same inputs. Rerere hashes the body only, so resolutions learned from either source are interchangeable. - **Hash determinism**: The same conflict always produces the same rerere hash. This is critical for the shared resolution cache. - **Resolution portability**: Copying `preimage` and `postimage` files (plus the hash directory name) from one repo's `.git/rr-cache/` to another works. Rerere auto-resolves in the target repo. - **Adjacent line sensitivity**: Changes within ~3 lines of each other are treated as a single conflict hunk by `merge-file`. Skills that modify the same area of a file will conflict even if they modify different lines. This is expected and handled by the resolution cache. #### Implication: Git Repository Required The adapter requires `git hash-object`, `git update-index`, and `.git/rr-cache/`. This means the project directory must be a git repository for rerere caching to work. Users who download a zip (no `.git/`) lose resolution caching but not functionality — conflicts escalate directly to Level 2 (Claude Code resolves). The system should detect this case and skip rerere operations gracefully. ### Maintainer Workflow When releasing a core update or new skill version: 1. Fresh codebase at target core version 2. Apply each official skill individually — verify clean merge, run tests 3. Apply pairwise combinations **for skills that modify at least one common file or have overlapping structured operations** 4. Apply curated three-skill stacks based on popularity and high overlap 5. Resolve all conflicts (code and structured) 6. Record all resolutions with input hashes 7. Run full test suite for every combination 8. Ship verified resolutions with the release The bar: **a user with any common combination of official skills should never encounter an unresolved conflict.** --- ## 8. State Tracking `.nanoclaw/state.yaml` records everything about the installation: ```yaml skills_system_version: "0.1.0" # Schema version — tooling checks this before any operation core_version: 0.1.0 applied_skills: - name: telegram version: 1.0.0 applied_at: 2026-02-16T22:47:02.139Z file_hashes: src/channels/telegram.ts: "f627b9cf..." src/channels/telegram.test.ts: "400116769..." src/config.ts: "9ae28d1f..." src/index.ts: "46dbe495..." src/routing.test.ts: "5e1aede9..." structured_outcomes: npm_dependencies: grammy: "^1.39.3" env_additions: - TELEGRAM_BOT_TOKEN - TELEGRAM_ONLY test: "npx vitest run src/channels/telegram.test.ts" - name: discord version: 1.0.0 applied_at: 2026-02-17T17:29:37.821Z file_hashes: src/channels/discord.ts: "5d669123..." src/channels/discord.test.ts: "19e1c6b9..." src/config.ts: "a0a32df4..." src/index.ts: "d61e3a9d..." src/routing.test.ts: "edbacb00..." structured_outcomes: npm_dependencies: discord.js: "^14.18.0" env_additions: - DISCORD_BOT_TOKEN - DISCORD_ONLY test: "npx vitest run src/channels/discord.test.ts" custom_modifications: - description: "Added custom logging middleware" applied_at: 2026-02-15T12:00:00Z files_modified: - src/server.ts patch_file: .nanoclaw/custom/001-logging-middleware.patch ``` **v0.1 implementation notes:** - `file_hashes` stores a single SHA-256 hash per file (the final merged result). Three-part hashes (base/skill_modified/merged) are planned for a future version to improve drift diagnosis. - Applied skills use `name` as the key field (not `skill`), matching the TypeScript `AppliedSkill` interface. - `structured_outcomes` stores the raw manifest values plus the `test` command. Resolved npm versions (actual installed versions vs semver ranges) are not yet tracked. - Fields like `installed_at`, `last_updated`, `path_remap`, `rebased_at`, `core_version_at_apply`, `files_added`, and `files_modified` are planned for future versions. --- ## 9. Untracked Changes If a user edits files directly, the system detects this via hash comparison. ### When Detection Happens Before **any operation that modifies the codebase**: applying a skill, removing a skill, updating the core, replaying, or rebasing. ### What Happens ``` Detected untracked changes to src/server.ts. [1] Record these as a custom modification (recommended) [2] Continue anyway (changes preserved, but not tracked for future replay) [3] Abort ``` The system never blocks or loses work. Option 1 generates a patch and records it, making changes reproducible. Option 2 preserves the changes but they won't survive replay. ### The Recovery Guarantee No matter how much a user modifies their codebase outside the system, the three-level model can always bring them back: 1. **Git**: diff current files against base, identify what changed 2. **Claude Code**: read `state.yaml` to understand what skills were applied, compare against actual file state, identify discrepancies 3. **User**: Claude Code asks what they intended, what to keep, what to discard There is no unrecoverable state. --- ## 10. Core Updates Core updates must be as programmatic as possible. The NanoClaw team is responsible for ensuring updates apply cleanly to common skill combinations. ### Patches and Migrations Most core changes — bug fixes, performance improvements, new functionality — propagate automatically through the three-way merge. No special handling needed. **Breaking changes** — changed defaults, removed features, functionality moved to skills — require a **migration**. A migration is a skill that preserves the old behavior, authored against the new core. It's applied automatically during the update so the user's setup doesn't change. The maintainer's responsibility when making a breaking change: make the change in core, author a migration skill that reverts it, add the entry to `migrations.yaml`, test it. That's the cost of breaking changes. ### `migrations.yaml` An append-only file in the repo root. Each entry records a breaking change and the skill that preserves the old behavior: ```yaml - since: 0.6.0 skill: apple-containers@1.0.0 description: "Preserves Apple Containers (default changed to Docker in 0.6)" - since: 0.7.0 skill: add-whatsapp@2.0.0 description: "Preserves WhatsApp (moved from core to skill in 0.7)" - since: 0.8.0 skill: legacy-auth@1.0.0 description: "Preserves legacy auth module (removed from core in 0.8)" ``` Migration skills are regular skills in the `skills/` directory. They have manifests, intent files, tests — everything. They're authored against the **new** core version: the modified file is the new core with the specific breaking change reverted, everything else (bug fixes, new features) identical to the new core. ### How Migrations Work During Updates 1. Three-way merge brings in everything from the new core — patches, breaking changes, all of it 2. Conflict resolution (normal) 3. Re-apply custom patches (normal) 4. **Update base to new core** 5. Filter `migrations.yaml` for entries where `since` > user's old `core_version` 6. **Apply each migration skill using the normal apply flow against the new base** 7. Record migration skills in `state.yaml` like any other skill 8. Run tests Step 6 is just the same apply function used for any skill. The migration skill merges against the new base: - **Base**: new core (e.g., v0.8 with Docker) - **Current**: user's file after the update merge (new core + user's customizations preserved by the earlier merge) - **Other**: migration skill's file (new core with Docker reverted to Apple, everything else identical) Three-way merge correctly keeps user's customizations, reverts the breaking change, and preserves all bug fixes. If there's a conflict, normal resolution: cache → Claude → user. For big version jumps (v0.5 → v0.8), all applicable migrations are applied in sequence. Migration skills are maintained against the latest core version, so they always compose correctly with the current codebase. ### What the User Sees ``` Core updated: 0.5.0 → 0.8.0 ✓ All patches applied Preserving your current setup: + apple-containers@1.0.0 + add-whatsapp@2.0.0 + legacy-auth@1.0.0 Skill updates: ✓ add-telegram 1.0.0 → 1.2.0 To accept new defaults: /remove-skill <name> ✓ All tests passing ``` No prompts, no choices during the update. The user's setup doesn't change. If they later want to accept a new default, they remove the migration skill. ### What the Core Team Ships With an Update ``` updates/ 0.5.0-to-0.6.0/ migration.md # What changed, why, and how it affects skills files/ # The new core files file_ops: # Any renames, deletes, moves path_remap: # Compatibility map for old skill paths resolutions/ # Pre-computed resolutions for official skills ``` Plus any new migration skills added to `skills/` and entries appended to `migrations.yaml`. ### The Maintainer's Process 1. **Make the core change** 2. **If it's a breaking change**: author a migration skill against the new core, add entry to `migrations.yaml` 3. **Write `migration.md`** — what changed, why, what skills might be affected 4. **Test every official skill individually** against the new core (including migration skills) 5. **Test pairwise combinations** for skills that share modified files or structured operations 6. **Test curated three-skill stacks** based on popularity and overlap 7. **Resolve all conflicts** 8. **Record all resolutions** with enforced input hashes 9. **Run full test suites** 10. **Ship everything** — migration guide, migration skills, file ops, path remap, resolutions The bar: **patches apply silently. Breaking changes are auto-preserved via migration skills. A user should never be surprised by a change to their working setup.** ### Update Flow (Full) #### Step 1: Pre-flight - Check for untracked changes - Read `state.yaml` - Load shipped resolutions - Parse `migrations.yaml`, filter for applicable migrations #### Step 2: Preview Before modifying anything, show the user what's coming. This uses only git commands — no files are opened or changed: ```bash # Compute common base BASE=$(git merge-base HEAD upstream/$BRANCH) # Upstream commits since last sync git log --oneline $BASE..upstream/$BRANCH # Files changed upstream git diff --name-only $BASE..upstream/$BRANCH ``` Present a summary grouped by impact: ``` Update available: 0.5.0 → 0.8.0 (12 commits) Source: 4 files modified (server.ts, config.ts, ...) Skills: 2 new skills added, 1 skill updated Config: package.json, docker-compose.yml updated Migrations (auto-applied to preserve your setup): + apple-containers@1.0.0 (container default changed to Docker) + add-whatsapp@2.0.0 (WhatsApp moved from core to skill) Skill updates: add-telegram 1.0.0 → 1.2.0 [1] Proceed with update [2] Abort ``` If the user aborts, stop here. Nothing was modified. #### Step 3: Backup Copy all files that will be modified to `.nanoclaw/backup/`. #### Step 4: File Operations and Path Remap Apply renames, deletes, moves. Record path remap in state. #### Step 5: Three-Way Merge For each core file that changed: ```bash git merge-file src/server.ts .nanoclaw/base/src/server.ts updates/0.5.0-to-0.6.0/files/src/server.ts ``` #### Step 6: Conflict Resolution 1. Shipped resolutions (hash-verified) → automatic 2. `git rerere` local cache → automatic 3. Claude Code with `migration.md` + skill intents → resolves 4. User → only for genuine ambiguity #### Step 7: Re-apply Custom Patches ```bash git apply --3way .nanoclaw/custom/001-logging-middleware.patch ``` Using `--3way` allows git to fall back to three-way merge when line numbers have drifted. If `--3way` fails, escalate to Level 2. #### Step 8: Update Base `.nanoclaw/base/` replaced with new clean core. This is the **only time** the base changes. #### Step 9: Apply Migration Skills For each applicable migration (where `since` > old `core_version`), apply the migration skill using the normal apply flow against the new base. Record in `state.yaml`. #### Step 10: Re-apply Updated Skills Skills live in the repo and update alongside core files. After the update, compare the version in each skill's `manifest.yaml` on disk against the version recorded in `state.yaml`. For each skill where the on-disk version is newer than the recorded version: 1. Re-apply the skill using the normal apply flow against the new base 2. The three-way merge brings in the skill's new changes while preserving user customizations 3. Re-apply any custom patches tied to the skill (`git apply --3way`) 4. Update the version in `state.yaml` Skills whose version hasn't changed are skipped — no action needed. If the user has a custom patch on a skill that changed significantly, the patch may conflict. Normal resolution: cache → Claude → user. #### Step 11: Re-run Structured Operations Recompute structured operations against the updated codebase to ensure consistency. #### Step 12: Validate - Run all skill tests — mandatory - Compatibility report: ``` Core updated: 0.5.0 → 0.8.0 ✓ All patches applied Migrations: + apple-containers@1.0.0 (preserves container runtime) + add-whatsapp@2.0.0 (WhatsApp moved to skill) Skill updates: ✓ add-telegram 1.0.0 → 1.2.0 (new features applied) ✓ custom/telegram-group-only — re-applied cleanly ✓ All tests passing ``` #### Step 13: Clean Up Delete `.nanoclaw/backup/`. ### Progressive Core Slimming Migrations enable a clean path for slimming down the core over time. Each release can move more functionality to skills: - The breaking change removes the feature from core - The migration skill preserves it for existing users - New users start with a minimal core and add what they need - Over time, `state.yaml` reflects exactly what each user is running --- ## 11. Skill Removal (Uninstall) Removing a skill is not a reverse-patch operation. **Uninstall is a replay without the skill.** ### How It Works 1. Read `state.yaml` to get the full list of applied skills and custom modifications 2. Remove the target skill from the list 3. Backup the current codebase to `.nanoclaw/backup/` 4. **Replay from clean base** — apply each remaining skill in order, apply custom patches, using the resolution cache 5. Run all tests 6. If tests pass, delete backup and update `state.yaml` 7. If tests fail, restore from backup and report ### Custom Patches Tied to the Removed Skill If the removed skill has a `custom_patch` in `state.yaml`, the user is warned: ``` Removing telegram will also discard custom patch: "Restrict bot responses to group chats only" [1] Continue (discard custom patch) [2] Abort ``` --- ## 12. Rebase Flatten accumulated layers into a clean starting point. ### What Rebase Does 1. Takes the user's current actual files as the new reality 2. Updates `.nanoclaw/base/` to the current core version's clean files 3. For each applied skill, regenerates the modified file diffs against the new base 4. Updates `state.yaml` with `rebased_at` timestamp 5. Clears old custom patches (now baked in) 6. Clears stale resolution cache entries ### When to Rebase - After a major core update - When accumulated patches become unwieldy - Before a significant new skill application - Periodically as maintenance ### Tradeoffs **Lose**: individual skill patch history, ability to cleanly remove a single old skill, old custom patches as separate artifacts **Gain**: clean base, simpler future merges, reduced cache size, fresh starting point --- ## 13. Replay Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI intervention (assuming all resolutions are cached). ### Replay Flow ```bash # Fully programmatic — no Claude Code needed # 1. Install core at specified version nanoclaw-init --version 0.5.0 # 2. Load shared resolutions into local rerere cache load-resolutions .nanoclaw/resolutions/ # 3. For each skill in applied_skills (in order): for skill in state.applied_skills: # File operations apply_file_ops(skill) # Copy new files cp skills/${skill.name}/add/* . # Merge modified code files (with path remapping) for file in skill.files_modified: resolved_path = apply_remap(file, state.path_remap) git merge-file ${resolved_path} .nanoclaw/base/${resolved_path} skills/${skill.name}/modify/${file} # git rerere auto-resolves from shared cache if needed # Apply skill-specific custom patch if recorded if skill.custom_patch: git apply --3way ${skill.custom_patch} # 4. Apply all structured operations (batched) collect_all_structured_ops(state.applied_skills) merge_npm_dependencies → write package.json once npm install once merge_env_additions → write .env.example once merge_compose_services → write docker-compose.yml once # 5. Apply standalone custom modifications for custom in state.custom_modifications: git apply --3way ${custom.patch_file} # 6. Run tests and verify hashes run_tests && verify_hashes ``` --- ## 14. Skill Tests Each skill includes integration tests that validate the skill works correctly when applied. ### Structure ``` skills/ add-whatsapp/ tests/ whatsapp.test.ts ``` ### What Tests Validate - **Single skill on fresh core**: apply to clean codebase → tests pass → integration works - **Skill functionality**: the feature actually works - **Post-apply state**: files in expected state, `state.yaml` correctly updated ### When Tests Run (Always) - **After applying a skill** — even if all merges were clean - **After core update** — even if all merges were clean - **After uninstall replay** — confirms removal didn't break remaining skills - **In CI** — tests all official skills individually and in common combinations - **During replay** — validates replayed state Clean merge ≠ working code. Tests are the only reliable signal. ### CI Test Matrix Test coverage is **smart, not exhaustive**: - Every official skill individually against each supported core version - **Pairwise combinations for skills that modify at least one common file or have overlapping structured operations** - Curated three-skill stacks based on popularity and high overlap - Test matrix auto-generated from manifest `modifies` and `structured` fields Each passing combination generates a verified resolution entry for the shared cache. --- ## 15. Project Configuration ### `.gitattributes` Ship with NanoClaw to reduce noisy merge conflicts: ``` * text=auto *.ts text eol=lf *.json text eol=lf *.yaml text eol=lf *.md text eol=lf ``` --- ## 16. Directory Structure ``` project/ src/ # The actual codebase server.ts config.ts channels/ whatsapp.ts telegram.ts skills/ # Skill packages (Claude Code slash commands) add-whatsapp/ SKILL.md manifest.yaml tests/ whatsapp.test.ts add/ src/channels/whatsapp.ts modify/ src/ server.ts server.ts.intent.md config.ts config.ts.intent.md add-telegram/ ... telegram-reactions/ # Layered skill ... .nanoclaw/ base/ # Clean core (shared base) src/ server.ts config.ts ... state.yaml # Full installation state backup/ # Temporary backup during operations custom/ # Custom patches telegram-group-only.patch 001-logging-middleware.patch 001-logging-middleware.md resolutions/ # Shared verified resolution cache whatsapp@1.2.0+telegram@1.0.0/ src/ server.ts.resolution server.ts.preimage meta.yaml .gitattributes ``` --- ## 17. Design Principles 1. **Use git, don't reinvent it.** `git merge-file` for code merges, `git rerere` for caching resolutions, `git apply --3way` for custom patches. 2. **Three-level resolution: git → Claude → user.** Programmatic first, AI second, human third. 3. **Clean merges aren't enough.** Tests run after every operation. Semantic conflicts survive text merges. 4. **All operations are safe.** Backup before, restore on failure. No half-applied state. 5. **One shared base.** `.nanoclaw/base/` is the clean core before any skills or customizations. It's the stable common ancestor for all three-way merges. Only updated on core updates. 6. **Code merges vs. structured operations.** Source code is three-way merged. Dependencies, env vars, and configs are aggregated programmatically. Structured operations are implicit and batched. 7. **Resolutions are learned and shared.** Maintainers resolve conflicts and ship verified resolutions with hash enforcement. `.nanoclaw/resolutions/` is the shared artifact. 8. **One skill, one happy path.** No predefined configuration options. Customization is more patching. 9. **Skills layer and compose.** Core skills provide the foundation. Extension skills add capabilities. 10. **Intent is first-class and structured.** `SKILL.md`, `.intent.md` (What, Invariants, Must-keep), and `migration.md`. 11. **State is explicit and complete.** Skills, custom patches, per-file hashes, structured outcomes, path remaps. Replay is deterministic. Drift is instant to detect. 12. **Always recoverable.** The three-level model reconstructs coherent state from any starting point. 13. **Uninstall is replay.** Replay from clean base without the skill. Backup for safety. 14. **Core updates are the maintainers' responsibility.** Test, resolve, ship. Breaking changes require a migration skill that preserves the old behavior. The cost of a breaking change is authoring and testing the migration. Users should never be surprised by a change to their setup. 15. **File operations and path remapping are first-class.** Renames, deletes, moves in manifests. Skills are never mutated — paths resolve at apply time. 16. **Skills are tested.** Integration tests per skill. CI tests pairwise by overlap. Tests run always. 17. **Deterministic serialization.** Sorted keys, consistent formatting. No noisy diffs. 18. **Rebase when needed.** Flatten layers for a clean starting point. 19. **Progressive core slimming.** Breaking changes move functionality from core to migration skills. Existing users keep what they have automatically. New users start minimal and add what they need. ================================================ FILE: docs/nanorepo-architecture.md ================================================ # NanoClaw Skills Architecture ## What Skills Are For NanoClaw's core is intentionally minimal. Skills are how users extend it: adding channels, integrations, cross-platform support, or replacing internals entirely. Examples: add Telegram alongside WhatsApp, switch from Apple Container to Docker, add Gmail integration, add voice message transcription. Each skill modifies the actual codebase, adding channel handlers, updating the message router, changing container configuration, and adding dependencies, rather than working through a plugin API or runtime hooks. ## Why This Architecture The problem: users need to combine multiple modifications to a shared codebase, keep those modifications working across core updates, and do all of this without becoming git experts or losing their custom changes. A plugin system would be simpler but constrains what skills can do. Giving skills full codebase access means they can change anything, but that creates merge conflicts, update breakage, and state tracking challenges. This architecture solves that by making skill application fully programmatic using standard git mechanics, with AI as a fallback for conflicts git can't resolve, and a shared resolution cache so most users never hit those conflicts at all. The result: users compose exactly the features they want, customizations survive core updates automatically, and the system is always recoverable. ## Core Principle Skills are self-contained, auditable packages applied via standard git merge mechanics. Claude Code orchestrates the process — running git commands, reading skill manifests, and stepping in only when git can't resolve a conflict. The system uses existing git features (`merge-file`, `rerere`, `apply`) rather than custom merge infrastructure. ## Three-Level Resolution Model Every operation follows this escalation: 1. **Git** — deterministic. `git merge-file` merges, `git rerere` replays cached resolutions, structured operations apply without merging. No AI. Handles the vast majority of cases. 2. **Claude Code** — reads `SKILL.md`, `.intent.md`, and `state.yaml` to resolve conflicts git can't handle. Caches resolutions via `git rerere` so the same conflict never needs resolving twice. 3. **Claude Code + user input** — when Claude Code lacks sufficient context to determine intent (e.g., two features genuinely conflict at an application level), it asks the user for a decision, then uses that input to perform the resolution. Claude Code still does the work — the user provides direction, not code. **Important**: A clean merge doesn't guarantee working code. Semantic conflicts can produce clean text merges that break at runtime. **Tests run after every operation.** ## Backup/Restore Safety Before any operation, all affected files are copied to `.nanoclaw/backup/`. On success, backup is deleted. On failure, backup is restored. Works safely for users who don't use git. ## The Shared Base `.nanoclaw/base/` holds a clean copy of the core codebase. This is the single common ancestor for all three-way merges, only updated during core updates. ## Two Types of Changes ### Code Files (Three-Way Merge) Source code where skills weave in logic. Merged via `git merge-file` against the shared base. Skills carry full modified files. ### Structured Data (Deterministic Operations) Files like `package.json`, `docker-compose.yml`, `.env.example`. Skills declare requirements in the manifest; the system applies them programmatically. Multiple skills' declarations are batched — dependencies merged, `package.json` written once, `npm install` run once. ```yaml structured: npm_dependencies: whatsapp-web.js: "^2.1.0" env_additions: - WHATSAPP_TOKEN docker_compose_services: whatsapp-redis: image: redis:alpine ports: ["6380:6379"] ``` Structured conflicts (version incompatibilities, port collisions) follow the same three-level resolution model. ## Skill Package Structure A skill contains only the files it adds or modifies. Modified code files carry the **full file** (clean core + skill's changes), making `git merge-file` straightforward and auditable. ``` skills/add-whatsapp/ SKILL.md # What this skill does and why manifest.yaml # Metadata, dependencies, structured ops tests/whatsapp.test.ts # Integration tests add/src/channels/whatsapp.ts # New files modify/src/server.ts # Full modified file for merge modify/src/server.ts.intent.md # Structured intent for conflict resolution ``` ### Intent Files Each modified file has a `.intent.md` with structured headings: **What this skill adds**, **Key sections**, **Invariants**, and **Must-keep sections**. These give Claude Code specific guidance during conflict resolution. ### Manifest Declares: skill metadata, core version compatibility, files added/modified, file operations, structured operations, skill relationships (conflicts, depends, tested_with), post-apply commands, and test command. ## Customization and Layering **One skill, one happy path** — a skill implements the reasonable default for 80% of users. **Customization is more patching.** Apply the skill, then modify via tracked patches, direct editing, or additional layered skills. Custom modifications are recorded in `state.yaml` and replayable. **Skills layer via `depends`.** Extension skills build on base skills (e.g., `telegram-reactions` depends on `add-telegram`). ## File Operations Renames, deletes, and moves are declared in the manifest and run **before** code merges. When core renames a file, a **path remap** resolves skill references at apply time — skill packages are never mutated. ## The Apply Flow 1. Pre-flight checks (compatibility, dependencies, untracked changes) 2. Backup 3. File operations + path remapping 4. Copy new files 5. Merge modified code files (`git merge-file`) 6. Conflict resolution (shared cache → `git rerere` → Claude Code → Claude Code + user input) 7. Apply structured operations (batched) 8. Post-apply commands, update `state.yaml` 9. **Run tests** (mandatory, even if all merges were clean) 10. Clean up (delete backup on success, restore on failure) ## Shared Resolution Cache `.nanoclaw/resolutions/` ships pre-computed, verified conflict resolutions with **hash enforcement** — a cached resolution only applies if base, current, and skill input hashes match exactly. This means most users never encounter unresolved conflicts for common skill combinations. ### rerere Adapter `git rerere` requires unmerged index entries that `git merge-file` doesn't create. An adapter sets up the required index state after `merge-file` produces a conflict, enabling rerere caching. This requires the project to be a git repository; users without `.git/` lose caching but not functionality. ## State Tracking `.nanoclaw/state.yaml` records: core version, all applied skills (with per-file hashes for base/skill/merged), structured operation outcomes, custom patches, and path remaps. This makes drift detection instant and replay deterministic. ## Untracked Changes Direct edits are detected via hash comparison before any operation. Users can record them as tracked patches, continue untracked, or abort. The three-level model can always recover coherent state from any starting point. ## Core Updates Most changes propagate automatically through three-way merge. **Breaking changes** require a **migration skill** — a regular skill that preserves the old behavior, authored against the new core. Migrations are declared in `migrations.yaml` and applied automatically during updates. ### Update Flow 1. Preview changes (git-only, no files modified) 2. Backup → file operations → three-way merge → conflict resolution 3. Re-apply custom patches (`git apply --3way`) 4. **Update base** to new core 5. Apply migration skills (preserves user's setup automatically) 6. Re-apply updated skills (version-changed skills only) 7. Re-run structured operations → run all tests → clean up The user sees no prompts during updates. To accept a new default later, they remove the migration skill. ## Skill Removal Uninstall is **replay without the skill**: read `state.yaml`, remove the target skill, replay all remaining skills from clean base using the resolution cache. Backup for safety. ## Rebase Flatten accumulated layers into a clean starting point. Updates base, regenerates diffs, clears old patches and stale cache entries. Trades individual skill history for simpler future merges. ## Replay Given `state.yaml`, reproduce the exact installation on a fresh machine with no AI (assuming cached resolutions). Apply skills in order, merge, apply custom patches, batch structured operations, run tests. ## Skill Tests Each skill includes integration tests. Tests run **always** — after apply, after update, after uninstall, during replay, in CI. CI tests all official skills individually and pairwise combinations for skills sharing modified files or structured operations. ## Design Principles 1. **Use git, don't reinvent it.** 2. **Three-level resolution: git → Claude Code → Claude Code + user input.** 3. **Clean merges aren't enough.** Tests run after every operation. 4. **All operations are safe.** Backup/restore, no half-applied state. 5. **One shared base**, only updated on core updates. 6. **Code merges vs. structured operations.** Source code is merged; configs are aggregated. 7. **Resolutions are learned and shared** with hash enforcement. 8. **One skill, one happy path.** Customization is more patching. 9. **Skills layer and compose.** 10. **Intent is first-class and structured.** 11. **State is explicit and complete.** Replay is deterministic. 12. **Always recoverable.** 13. **Uninstall is replay.** 14. **Core updates are the maintainers' responsibility.** Breaking changes require migration skills. 15. **File operations and path remapping are first-class.** 16. **Skills are tested.** CI tests pairwise by overlap. 17. **Deterministic serialization.** No noisy diffs. 18. **Rebase when needed.** 19. **Progressive core slimming** via migration skills. ================================================ FILE: docs/skills-as-branches.md ================================================ # Skills as Branches ## Overview NanoClaw skills are distributed as git branches on the upstream repository. Applying a skill is a `git merge`. Updating core is a `git merge`. Everything is standard git. This replaces the previous `skills-engine/` system (three-way file merging, `.nanoclaw/` state, manifest files, replay, backup/restore) with plain git operations and Claude for conflict resolution. ## How It Works ### Repository structure The upstream repo (`qwibitai/nanoclaw`) maintains: - `main` — core NanoClaw (no skill code) - `skill/discord` — main + Discord integration - `skill/telegram` — main + Telegram integration - `skill/slack` — main + Slack integration - `skill/gmail` — main + Gmail integration - etc. Each skill branch contains all the code changes for that skill: new files, modified source files, updated `package.json` dependencies, `.env.example` additions — everything. No manifest, no structured operations, no separate `add/` and `modify/` directories. ### Skill discovery and installation Skills are split into two categories: **Operational skills** (on `main`, always available): - `/setup`, `/debug`, `/update-nanoclaw`, `/customize`, `/update-skills` - These are instruction-only SKILL.md files — no code changes, just workflows - Live in `.claude/skills/` on `main`, immediately available to every user **Feature skills** (in marketplace, installed on demand): - `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc. - Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code - Live in the marketplace repo (`qwibitai/nanoclaw-skills`) Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently: ```bash # Claude runs this behind the scenes — users don't see it claude plugin install nanoclaw-skills@nanoclaw-skills --scope project ``` Skills are hot-loaded after `claude plugin install` — no restart needed. This means `/setup` can install the marketplace plugin, then immediately run any feature skill, all in one session. ### Selective skill installation `/setup` asks users what channels they want, then only offers relevant skills: 1. "Which messaging channels do you want to use?" → Discord, Telegram, Slack, WhatsApp 2. User picks Telegram → Claude installs the plugin and runs `/add-telegram` 3. After Telegram is set up: "Want to add Agent Swarm support for Telegram?" → offers `/add-telegram-swarm` 4. "Want to enable community skills?" → installs community marketplace plugins Dependent skills (e.g., `telegram-swarm` depends on `telegram`) are only offered after their parent is installed. `/customize` follows the same pattern for post-setup additions. ### Marketplace configuration NanoClaw's `.claude/settings.json` registers the official marketplace: ```json { "extraKnownMarketplaces": { "nanoclaw-skills": { "source": { "source": "github", "repo": "qwibitai/nanoclaw-skills" } } } } ``` The marketplace repo uses Claude Code's plugin structure: ``` qwibitai/nanoclaw-skills/ .claude-plugin/ marketplace.json # Plugin catalog plugins/ nanoclaw-skills/ # Single plugin bundling all official skills .claude-plugin/ plugin.json # Plugin manifest skills/ add-discord/ SKILL.md # Setup instructions; step 1 is "merge the branch" add-telegram/ SKILL.md add-slack/ SKILL.md ... ``` Multiple skills are bundled in one plugin — installing `nanoclaw-skills` makes all feature skills available at once. Individual skills don't need separate installation. Each SKILL.md tells Claude to merge the corresponding skill branch as step 1, then walks through interactive setup (env vars, bot creation, etc.). ### Applying a skill User runs `/add-discord` (discovered via marketplace). Claude follows the SKILL.md: 1. `git fetch upstream skill/discord` 2. `git merge upstream/skill/discord` 3. Interactive setup (create bot, get token, configure env vars, etc.) Or manually: ```bash git fetch upstream skill/discord git merge upstream/skill/discord ``` ### Applying multiple skills ```bash git merge upstream/skill/discord git merge upstream/skill/telegram ``` Git handles the composition. If both skills modify the same lines, it's a real conflict and Claude resolves it. ### Updating core ```bash git fetch upstream main git merge upstream/main ``` Since skill branches are kept merged-forward with main (see CI section), the user's merged-in skill changes and upstream changes have proper common ancestors. ### Checking for skill updates Users who previously merged a skill branch can check for updates. For each `upstream/skill/*` branch, check whether the branch has commits that aren't in the user's HEAD: ```bash git fetch upstream for branch in $(git branch -r | grep 'upstream/skill/'); do # Check if user has merged this skill at some point merge_base=$(git merge-base HEAD "$branch" 2>/dev/null) || continue # Check if the skill branch has new commits beyond what the user has if ! git merge-base --is-ancestor "$branch" HEAD 2>/dev/null; then echo "$branch has updates available" fi done ``` This requires no state — it uses git history to determine which skills were previously merged and whether they have new commits. This logic is available in two ways: - Built into `/update-nanoclaw` — after merging main, optionally check for skill updates - Standalone `/update-skills` — check and merge skill updates independently ### Conflict resolution At any merge step, conflicts may arise. Claude resolves them — reading the conflicted files, understanding the intent of both sides, and producing the correct result. This is what makes the branch approach viable at scale: conflict resolution that previously required human judgment is now automated. ### Skill dependencies Some skills depend on other skills. E.g., `skill/telegram-swarm` requires `skill/telegram`. Dependent skill branches are branched from their parent skill branch, not from `main`. This means `skill/telegram-swarm` includes all of telegram's changes plus its own additions. When a user merges `skill/telegram-swarm`, they get both — no need to merge telegram separately. Dependencies are implicit in git history — `git merge-base --is-ancestor` determines whether one skill branch is an ancestor of another. No separate dependency file is needed. ### Uninstalling a skill ```bash # Find the merge commit git log --merges --oneline | grep discord # Revert it git revert -m 1 <merge-commit> ``` This creates a new commit that undoes the skill's changes. Claude can handle the whole flow. If the user has modified the skill's code since merging (custom changes on top), the revert might conflict — Claude resolves it. If the user later wants to re-apply the skill, they need to revert the revert first (git treats reverted changes as "already applied and undone"). Claude handles this too. ## CI: Keeping Skill Branches Current A GitHub Action runs on every push to `main`: 1. List all `skill/*` branches 2. For each skill branch, merge `main` into it (merge-forward, not rebase) 3. Run build and tests on the merged result 4. If tests pass, push the updated skill branch 5. If a skill fails (conflict, build error, test failure), open a GitHub issue for manual resolution **Why merge-forward instead of rebase:** - No force-push — preserves history for users who already merged the skill - Users can re-merge a skill branch to pick up skill updates (bug fixes, improvements) - Git has proper common ancestors throughout the merge graph **Why this scales:** With a few hundred skills and a few commits to main per day, the CI cost is trivial. Haiku is fast and cheap. The approach that wouldn't have been feasible a year or two ago is now practical because Claude can resolve conflicts at scale. ## Installation Flow ### New users (recommended) 1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button) 2. Clone your fork: ```bash git clone https://github.com/<you>/nanoclaw.git cd nanoclaw ``` 3. Run Claude Code: ```bash claude ``` 4. Run `/setup` — Claude handles dependencies, authentication, container setup, service configuration, and adds `upstream` remote if not present Forking is recommended because it gives users a remote to push their customizations to. Clone-only works for trying things out but provides no remote backup. ### Existing users migrating from clone Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations: 1. Fork `qwibitai/nanoclaw` on GitHub 2. Reroute remotes: ```bash git remote rename origin upstream git remote add origin https://github.com/<you>/nanoclaw.git git push --force origin main ``` The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose. 3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw ### Existing users migrating from the old skills engine Users who previously applied skills via the `skills-engine/` system have skill code in their tree but no merge commits linking to skill branches. Git doesn't know these changes came from a skill, so merging a skill branch on top would conflict or duplicate. **For new skills going forward:** just merge skill branches as normal. No issue. **For existing old-engine skills**, two migration paths: **Option A: Per-skill reapply (keep your fork)** 1. For each old-engine skill: identify and revert the old changes, then merge the skill branch fresh 2. Claude assists with identifying what to revert and resolving any conflicts 3. Custom modifications (non-skill changes) are preserved **Option B: Fresh start (cleanest)** 1. Create a new fork from upstream 2. Merge the skill branches you want 3. Manually re-apply your custom (non-skill) changes 4. Claude assists by diffing your old fork against the new one to identify custom changes In both cases: - Delete the `.nanoclaw/` directory (no longer needed) - The `skills-engine/` code will be removed from upstream once all skills are migrated - `/update-skills` only tracks skills applied via branch merge — old-engine skills won't appear in update checks ## User Workflows ### Custom changes Users make custom changes directly on their main branch. This is the standard fork workflow — their `main` IS their customized version. ```bash # Make changes vim src/config.ts git commit -am "change trigger word to @Bob" git push origin main ``` Custom changes, skills, and core updates all coexist on their main branch. Git handles the three-way merging at each merge step because it can trace common ancestors through the merge history. ### Applying a skill Run `/add-discord` in Claude Code (discovered via the marketplace plugin), or manually: ```bash git fetch upstream skill/discord git merge upstream/skill/discord # Follow setup instructions for configuration git push origin main ``` If the user is behind upstream's main when they merge a skill branch, the merge might bring in some core changes too (since skill branches are merged-forward with main). This is generally fine — they get a compatible version of everything. ### Updating core ```bash git fetch upstream main git merge upstream/main git push origin main ``` This is the same as the existing `/update-nanoclaw` skill's merge path. ### Updating skills Run `/update-skills` or let `/update-nanoclaw` check after a core update. For each previously-merged skill branch that has new commits, Claude offers to merge the updates. ### Contributing back to upstream Users who want to submit a PR to upstream: ```bash git fetch upstream main git checkout -b my-fix upstream/main # Make changes git push origin my-fix # Create PR from my-fix to qwibitai/nanoclaw:main ``` Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR. ## Contributing a Skill ### Contributor flow 1. Fork `qwibitai/nanoclaw` 2. Branch from `main` 3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.) 4. Open a PR to `main` The contributor opens a normal PR — they don't need to know about skill branches or marketplace repos. They just make code changes and submit. ### Maintainer flow When a skill PR is reviewed and approved: 1. Create a `skill/<name>` branch from the PR's commits: ```bash git fetch origin pull/<PR_NUMBER>/head:skill/<name> git push origin skill/<name> ``` 2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes) 3. Merge the slimmed PR into `main` (just the contributor addition) 4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`) This way: - The contributor gets merge credit (their PR is merged) - They're added to CONTRIBUTORS.md automatically by the maintainer - The skill branch is created from their work - `main` stays clean (no skill code) - The contributor only had to do one thing: open a PR with code changes **Note:** GitHub PRs from forks have "Allow edits from maintainers" checked by default, so the maintainer can push to the contributor's PR branch. ### Skill SKILL.md The contributor can optionally provide a SKILL.md (either in the PR or separately). This goes into the marketplace repo and contains: 1. Frontmatter (name, description, triggers) 2. Step 1: Merge the skill branch 3. Steps 2-N: Interactive setup (create bot, get token, configure env vars, verify) If the contributor doesn't provide a SKILL.md, the maintainer writes one based on the PR. ## Community Marketplaces Anyone can maintain their own fork with skill branches and their own marketplace repo. This enables a community-driven skill ecosystem without requiring write access to the upstream repo. ### How it works A community contributor: 1. Maintains a fork of NanoClaw (e.g., `alice/nanoclaw`) 2. Creates `skill/*` branches on their fork with their custom skills 3. Creates a marketplace repo (e.g., `alice/nanoclaw-skills`) with a `.claude-plugin/marketplace.json` and plugin structure ### Adding a community marketplace If the community contributor is trusted, they can open a PR to add their marketplace to NanoClaw's `.claude/settings.json`: ```json { "extraKnownMarketplaces": { "nanoclaw-skills": { "source": { "source": "github", "repo": "qwibitai/nanoclaw-skills" } }, "alice-nanoclaw-skills": { "source": { "source": "github", "repo": "alice/nanoclaw-skills" } } } } ``` Once merged, all NanoClaw users automatically discover the community marketplace alongside the official one. ### Installing community skills `/setup` and `/customize` ask users whether they want to enable community skills. If yes, Claude installs community marketplace plugins via `claude plugin install`: ```bash claude plugin install alice-skills@alice-nanoclaw-skills --scope project ``` Community skills are hot-loaded and immediately available — no restart needed. Dependent skills are only offered after their prerequisites are met (e.g., community Telegram add-ons only after Telegram is installed). Users can also browse and install community plugins manually via `/plugin`. ### Properties of this system - **No gatekeeping required.** Anyone can create skills on their fork without permission. They only need approval to be listed in the auto-discovered marketplaces. - **Multiple marketplaces coexist.** Users see skills from all trusted marketplaces in `/plugin`. - **Community skills use the same merge pattern.** The SKILL.md just points to a different remote: ```bash git remote add alice https://github.com/alice/nanoclaw.git git fetch alice skill/my-cool-feature git merge alice/skill/my-cool-feature ``` - **Users can also add marketplaces manually.** Even without being listed in settings.json, users can run `/plugin marketplace add alice/nanoclaw-skills` to discover skills from any source. - **CI is per-fork.** Each community maintainer runs their own CI to keep their skill branches merged-forward. They can use the same GitHub Action as the upstream repo. ## Flavors A flavor is a curated fork of NanoClaw — a combination of skills, custom changes, and configuration tailored for a specific use case (e.g., "NanoClaw for Sales," "NanoClaw Minimal," "NanoClaw for Developers"). ### Creating a flavor 1. Fork `qwibitai/nanoclaw` 2. Merge in the skills you want 3. Make custom changes (trigger word, prompts, integrations, etc.) 4. Your fork's `main` IS the flavor ### Installing a flavor During `/setup`, users are offered a choice of flavors before any configuration happens. The setup skill reads `flavors.yaml` from the repo (shipped with upstream, always up to date) and presents options: AskUserQuestion: "Start with a flavor or default NanoClaw?" - Default NanoClaw - NanoClaw for Sales — Gmail + Slack + CRM (maintained by alice) - NanoClaw Minimal — Telegram-only, lightweight (maintained by bob) If a flavor is chosen: ```bash git remote add <flavor-name> https://github.com/alice/nanoclaw.git git fetch <flavor-name> main git merge <flavor-name>/main ``` Then setup continues normally (dependencies, auth, container, service). **This choice is only offered on a fresh fork** — when the user's main matches or is close to upstream's main with no local commits. If `/setup` detects significant local changes (re-running setup on an existing install), it skips the flavor selection and goes straight to configuration. After installation, the user's fork has three remotes: - `origin` — their fork (push customizations here) - `upstream` — `qwibitai/nanoclaw` (core updates) - `<flavor-name>` — the flavor fork (flavor updates) ### Updating a flavor ```bash git fetch <flavor-name> main git merge <flavor-name>/main ``` The flavor maintainer keeps their fork updated (merging upstream, updating skills). Users pull flavor updates the same way they pull core updates. ### Flavors registry `flavors.yaml` lives in the upstream repo: ```yaml flavors: - name: NanoClaw for Sales repo: alice/nanoclaw description: Gmail + Slack + CRM integration, daily pipeline summaries maintainer: alice - name: NanoClaw Minimal repo: bob/nanoclaw description: Telegram-only, no container overhead maintainer: bob ``` Anyone can PR to add their flavor. The file is available locally when `/setup` runs since it's part of the cloned repo. ### Discoverability - **During setup** — flavor selection is offered as part of the initial setup flow - **`/browse-flavors` skill** — reads `flavors.yaml` and presents options at any time - **GitHub topics** — flavor forks can tag themselves with `nanoclaw-flavor` for searchability - **Discord / website** — community-curated lists ## Migration Migration from the old skills engine to branches is complete. All feature skills now live on `skill/*` branches, and the skills engine has been removed. ### Skill branches | Branch | Base | Description | |--------|------|-------------| | `skill/whatsapp` | `main` | WhatsApp channel | | `skill/telegram` | `main` | Telegram channel | | `skill/slack` | `main` | Slack channel | | `skill/discord` | `main` | Discord channel | | `skill/gmail` | `main` | Gmail channel | | `skill/voice-transcription` | `skill/whatsapp` | OpenAI Whisper voice transcription | | `skill/image-vision` | `skill/whatsapp` | Image attachment processing | | `skill/pdf-reader` | `skill/whatsapp` | PDF attachment reading | | `skill/local-whisper` | `skill/voice-transcription` | Local whisper.cpp transcription | | `skill/ollama-tool` | `main` | Ollama MCP server for local models | | `skill/apple-container` | `main` | Apple Container runtime | | `skill/reactions` | `main` | WhatsApp emoji reactions | ### What was removed - `skills-engine/` directory (entire engine) - `scripts/apply-skill.ts`, `scripts/uninstall-skill.ts`, `scripts/rebase.ts` - `scripts/fix-skill-drift.ts`, `scripts/validate-all-skills.ts` - `.github/workflows/skill-drift.yml`, `.github/workflows/skill-pr.yml` - All `add/`, `modify/`, `tests/`, and `manifest.yaml` from skill directories - `.nanoclaw/` state directory Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`. ## What Changes ### README Quick Start Before: ```bash git clone https://github.com/qwibitai/NanoClaw.git cd NanoClaw claude ``` After: ``` 1. Fork qwibitai/nanoclaw on GitHub 2. git clone https://github.com/<you>/nanoclaw.git 3. cd nanoclaw 4. claude 5. /setup ``` ### Setup skill (`/setup`) Updates to the setup flow: - Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git` - Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration. - **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart) - **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels - **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp) - **Optionally enable community marketplaces:** ask if the user wants community skills, install those marketplace plugins too ### `.claude/settings.json` Marketplace configuration so the official marketplace is auto-registered: ```json { "extraKnownMarketplaces": { "nanoclaw-skills": { "source": { "source": "github", "repo": "qwibitai/nanoclaw-skills" } } } } ``` ### Skills directory on main The `.claude/skills/` directory on `main` retains only operational skills (setup, debug, update-nanoclaw, customize, update-skills). Feature skills (add-discord, add-telegram, etc.) live in the marketplace repo, installed via `claude plugin install` during `/setup` or `/customize`. ### Skills engine removal The following can be removed: - `skills-engine/` — entire directory (apply, merge, replay, state, backup, etc.) - `scripts/apply-skill.ts` - `scripts/uninstall-skill.ts` - `scripts/fix-skill-drift.ts` - `scripts/validate-all-skills.ts` - `.nanoclaw/` — state directory - `add/` and `modify/` subdirectories from all skill directories - Feature skill SKILL.md files from `.claude/skills/` on main (they now live in the marketplace) Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-skills`) remain on main in `.claude/skills/`. ### New infrastructure - **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills - **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution - **`/update-skills` skill** — checks for and applies skill branch updates using git history - **`CONTRIBUTORS.md`** — tracks skill contributors ### Update skill (`/update-nanoclaw`) The update skill gets simpler with the branch-based approach. The old skills engine required replaying all applied skills after merging core updates — that entire step disappears. Skill changes are already in the user's git history, so `git merge upstream/main` just works. **What stays the same:** - Preflight (clean working tree, upstream remote) - Backup branch + tag - Preview (git log, git diff, file buckets) - Merge/cherry-pick/rebase options - Conflict preview (dry-run merge) - Conflict resolution - Build + test validation - Rollback instructions **What's removed:** - Skill replay step (was needed by the old skills engine to re-apply skills after core update) - Re-running structured operations (npm deps, env vars — these are part of git history now) **What's added:** - Optional step at the end: "Check for skill updates?" which runs the `/update-skills` logic - This checks whether any previously-merged skill branches have new commits (bug fixes, improvements to the skill itself — not just merge-forwards from main) **Why users don't need to re-merge skills after a core update:** When the user merged a skill branch, those changes became part of their git history. When they later merge `upstream/main`, git performs a normal three-way merge — the skill changes in their tree are untouched, and only core changes are brought in. The merge-forward CI ensures skill branches stay compatible with latest main, but that's for new users applying the skill fresh. Existing users who already merged the skill don't need to do anything. Users only need to re-merge a skill branch if the skill itself was updated (not just merged-forward with main). The `/update-skills` check detects this. ## Discord Announcement ### For existing users > **Skills are now git branches** > > We've simplified how skills work in NanoClaw. Instead of a custom skills engine, skills are now git branches that you merge in. > > **What this means for you:** > - Applying a skill: `git fetch upstream skill/discord && git merge upstream/skill/discord` > - Updating core: `git fetch upstream main && git merge upstream/main` > - Checking for skill updates: `/update-skills` > - No more `.nanoclaw/` state directory or skills engine > > **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to. > > **If you currently have a clone with local changes**, migrate to a fork: > 1. Fork `qwibitai/nanoclaw` on GitHub > 2. Run: > ``` > git remote rename origin upstream > git remote add origin https://github.com/<you>/nanoclaw.git > git push --force origin main > ``` > This works even if you're way behind — just push your current state. > > **If you previously applied skills via the old system**, your code changes are already in your working tree — nothing to redo. You can delete the `.nanoclaw/` directory. Future skills and updates use the branch-based approach. > > **Discovering skills:** Skills are now available through Claude Code's plugin marketplace. Run `/plugin` in Claude Code to browse and install available skills. ### For skill contributors > **Contributing skills** > > To contribute a skill: > 1. Fork `qwibitai/nanoclaw` > 2. Branch from `main` and make your code changes > 3. Open a regular PR > > That's it. We'll create a `skill/<name>` branch from your PR, add you to CONTRIBUTORS.md, and add the SKILL.md to the marketplace. CI automatically keeps skill branches merged-forward with `main` using Claude to resolve any conflicts. > > **Want to run your own skill marketplace?** Maintain skill branches on your fork and create a marketplace repo. Open a PR to add it to NanoClaw's auto-discovered marketplaces — or users can add it manually via `/plugin marketplace add`. ================================================ FILE: launchd/com.nanoclaw.plist ================================================ <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key> <string>com.nanoclaw</string> <key>ProgramArguments</key> <array> <string>{{NODE_PATH}}</string> <string>{{PROJECT_ROOT}}/dist/index.js</string> </array> <key>WorkingDirectory</key> <string>{{PROJECT_ROOT}}</string> <key>RunAtLoad</key> <true/> <key>KeepAlive</key> <true/> <key>EnvironmentVariables</key> <dict> <key>PATH</key> <string>{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string> <key>HOME</key> <string>{{HOME}}</string> <key>ASSISTANT_NAME</key> <string>Andy</string> </dict> <key>StandardOutPath</key> <string>{{PROJECT_ROOT}}/logs/nanoclaw.log</string> <key>StandardErrorPath</key> <string>{{PROJECT_ROOT}}/logs/nanoclaw.error.log</string> </dict> </plist> ================================================ FILE: package.json ================================================ { "name": "nanoclaw", "version": "1.2.19", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", "scripts": { "build": "tsc", "start": "node dist/index.js", "dev": "tsx src/index.ts", "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.ts\"", "format:fix": "prettier --write \"src/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\"", "prepare": "husky", "setup": "tsx setup/index.ts", "auth": "tsx src/whatsapp-auth.ts", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "better-sqlite3": "^11.8.1", "cron-parser": "^5.5.0", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "yaml": "^2.8.2", "zod": "^4.3.6" }, "devDependencies": { "@types/better-sqlite3": "^7.6.12", "@types/node": "^22.10.0", "@vitest/coverage-v8": "^4.0.18", "husky": "^9.1.7", "prettier": "^3.8.1", "tsx": "^4.19.0", "typescript": "^5.7.0", "vitest": "^4.0.18" }, "engines": { "node": ">=20" } } ================================================ FILE: repo-tokens/README.md ================================================ # Repo Tokens A GitHub Action that calculates the size of your codebase in terms of tokens and updates a badge in your README. <p> <img src="examples/green.svg" alt="tokens 12.4k">  <img src="examples/yellow-green.svg" alt="tokens 74.8k">  <img src="examples/yellow.svg" alt="tokens 120k">  <img src="examples/red.svg" alt="tokens 158k"> </p> ## Usage ```yaml - uses: qwibitai/nanoclaw/repo-tokens@v1 with: include: 'src/**/*.ts' exclude: 'src/**/*.test.ts' ``` This counts tokens using [tiktoken](https://github.com/openai/tiktoken) and writes the result between HTML comment markers in your README: The badge color reflects what percentage of an LLMs context window the codebase fills (context window size is configurable, defaults to 200k which is the size of Claude Opus). Green for under 30%, yellow-green for 30%-50%, yellow for 50%-70%, red for 70%+. ## Why Small codebases were always a good thing. With coding agents, there's now a huge advantage to having a codebase small enough that an agent can hold the full thing in context. This badge gives some indication of how easy it will be to work with an agent on the codebase, and will hopefully be a visual reminder to avoid bloat. ## Examples Repos using repo-tokens: | Repo | Badge | |------|-------| | [NanoClaw](https://github.com/qwibitai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/qwibitai/NanoClaw/main/repo-tokens/badge.svg) | ### Full workflow example ```yaml name: Update token count on: push: branches: [main] paths: ['src/**'] permissions: contents: write jobs: update-tokens: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' - uses: qwibitai/nanoclaw/repo-tokens@v1 id: tokens with: include: 'src/**/*.ts' exclude: 'src/**/*.test.ts' badge-path: '.github/badges/tokens.svg' - name: Commit if changed run: | git add README.md .github/badges/tokens.svg git diff --cached --quiet && exit 0 git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git commit -m "docs: update token count to ${{ steps.tokens.outputs.badge }}" git push ``` ### README setup Add markers where you want the token count text to appear: ```html <!-- token-count --><!-- /token-count --> ``` The action replaces everything between the markers with the token count. ## Inputs | Input | Default | Description | |-------|---------|-------------| | `include` | *required* | Glob patterns for files to count (space-separated) | | `exclude` | `''` | Glob patterns to exclude (space-separated) | | `context-window` | `200000` | Context window size for percentage calculation | | `readme` | `README.md` | Path to README file | | `encoding` | `cl100k_base` | Tiktoken encoding name | | `marker` | `token-count` | HTML comment marker name | | `badge-path` | `''` | Path to write SVG badge (empty = no SVG) | ## Outputs | Output | Description | |--------|-------------| | `tokens` | Total token count (e.g., `34940`) | | `percentage` | Percentage of context window (e.g., `17`) | | `badge` | The formatted text that was inserted (e.g., `34.9k tokens · 17% of context window`) | ## How it works Composite GitHub Action. Installs tiktoken, runs ~60 lines of inline Python. Takes about 10 seconds. The action counts tokens and updates the README but does not commit. Your workflow decides the git strategy. ================================================ FILE: repo-tokens/action.yml ================================================ name: Repo Tokens description: Count codebase tokens with tiktoken and update a README badge inputs: include: description: 'Glob patterns for files to count (space-separated)' required: true exclude: description: 'Glob patterns to exclude (space-separated)' required: false default: '' context-window: description: 'Context window size for percentage calculation' required: false default: '200000' readme: description: 'Path to README file' required: false default: 'README.md' encoding: description: 'Tiktoken encoding name' required: false default: 'cl100k_base' marker: description: 'HTML comment marker name' required: false default: 'token-count' badge-path: description: 'Path to write SVG badge (empty = no SVG)' required: false default: '' outputs: tokens: description: 'Total token count' value: ${{ steps.count.outputs.tokens }} percentage: description: 'Percentage of context window' value: ${{ steps.count.outputs.percentage }} badge: description: 'Badge text that was inserted' value: ${{ steps.count.outputs.badge }} runs: using: composite steps: - name: Install tiktoken shell: bash run: pip install tiktoken - name: Count tokens and update README id: count shell: python env: INPUT_INCLUDE: ${{ inputs.include }} INPUT_EXCLUDE: ${{ inputs.exclude }} INPUT_CONTEXT_WINDOW: ${{ inputs.context-window }} INPUT_README: ${{ inputs.readme }} INPUT_ENCODING: ${{ inputs.encoding }} INPUT_MARKER: ${{ inputs.marker }} INPUT_BADGE_PATH: ${{ inputs.badge-path }} run: | import glob, os, re, tiktoken include_patterns = os.environ["INPUT_INCLUDE"].split() exclude_patterns = os.environ["INPUT_EXCLUDE"].split() context_window = int(os.environ["INPUT_CONTEXT_WINDOW"]) readme_path = os.environ["INPUT_README"] encoding_name = os.environ["INPUT_ENCODING"] marker = os.environ["INPUT_MARKER"] badge_path = os.environ.get("INPUT_BADGE_PATH", "").strip() # Expand globs included = set() for pattern in include_patterns: included.update(glob.glob(pattern, recursive=True)) excluded = set() for pattern in exclude_patterns: excluded.update(glob.glob(pattern, recursive=True)) files = sorted(included - excluded) files = [f for f in files if os.path.isfile(f)] # Count tokens enc = tiktoken.get_encoding(encoding_name) total = 0 for path in files: try: with open(path, "r", encoding="utf-8", errors="ignore") as f: total += len(enc.encode(f.read())) except Exception as e: print(f"Skipping {path}: {e}") # Format if total >= 100000: display = f"{round(total / 1000)}k" elif total >= 1000: display = f"{total / 1000:.1f}k" else: display = str(total) pct = round(total / context_window * 100) badge = f"{display} tokens \u00b7 {pct}% of context window" print(f"Files: {len(files)}, Tokens: {total}, Badge: {badge}") # Update README (text between markers) marker_re = re.compile( rf"(<!--\s*{re.escape(marker)}\s*-->).*?(<!--\s*/{re.escape(marker)}\s*-->)", re.DOTALL, ) with open(readme_path, "r", encoding="utf-8") as f: content = f.read() repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens" linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>' new_content = marker_re.sub(rf"\1{linked_badge}\2", content) if new_content != content: with open(readme_path, "w", encoding="utf-8") as f: f.write(new_content) print("README updated") else: print("No change to README") # Generate SVG badge if badge_path: label_text = "tokens" value_text = display full_desc = f"{display} tokens, {pct}% of context window" cw = 7.0 label_w = round(len(label_text) * cw) + 10 value_w = round(len(value_text) * cw) + 10 total_w = label_w + value_w if pct < 30: color = "#4c1" elif pct < 50: color = "#97ca00" elif pct < 70: color = "#dfb317" else: color = "#e05d44" lx = label_w // 2 vx = label_w + value_w // 2 repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens" svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}"> <title>{full_desc} {label_text} {value_text} ''' os.makedirs(os.path.dirname(badge_path) or ".", exist_ok=True) with open(badge_path, "w", encoding="utf-8") as f: f.write(svg) print(f"Badge SVG written to {badge_path}") # Set outputs with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"tokens={total}\n") f.write(f"percentage={pct}\n") f.write(f"badge={badge}\n") ================================================ FILE: scripts/run-migrations.ts ================================================ #!/usr/bin/env tsx import { execFileSync, execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; function compareSemver(a: string, b: string): number { const partsA = a.split('.').map(Number); const partsB = b.split('.').map(Number); for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { const diff = (partsA[i] || 0) - (partsB[i] || 0); if (diff !== 0) return diff; } return 0; } // Resolve tsx binary once to avoid npx race conditions across migrations function resolveTsx(): string { // Check local node_modules first const local = path.resolve('node_modules/.bin/tsx'); if (fs.existsSync(local)) return local; // Fall back to whichever tsx is in PATH try { return execSync('which tsx', { encoding: 'utf-8' }).trim(); } catch { return 'npx'; // last resort } } const tsxBin = resolveTsx(); const fromVersion = process.argv[2]; const toVersion = process.argv[3]; const newCorePath = process.argv[4]; if (!fromVersion || !toVersion || !newCorePath) { console.error( 'Usage: tsx scripts/run-migrations.ts ', ); process.exit(1); } interface MigrationResult { version: string; success: boolean; error?: string; } const results: MigrationResult[] = []; // Look for migrations in the new core const migrationsDir = path.join(newCorePath, 'migrations'); if (!fs.existsSync(migrationsDir)) { console.log(JSON.stringify({ migrationsRun: 0, results: [] }, null, 2)); process.exit(0); } // Discover migration directories (version-named) const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }); const migrationVersions = entries .filter((e) => e.isDirectory() && /^\d+\.\d+\.\d+$/.test(e.name)) .map((e) => e.name) .filter( (v) => compareSemver(v, fromVersion) > 0 && compareSemver(v, toVersion) <= 0, ) .sort(compareSemver); const projectRoot = process.cwd(); for (const version of migrationVersions) { const migrationIndex = path.join(migrationsDir, version, 'index.ts'); if (!fs.existsSync(migrationIndex)) { results.push({ version, success: false, error: `Migration ${version}/index.ts not found`, }); continue; } try { const tsxArgs = tsxBin.endsWith('npx') ? ['tsx', migrationIndex, projectRoot] : [migrationIndex, projectRoot]; execFileSync(tsxBin, tsxArgs, { stdio: 'pipe', cwd: projectRoot, timeout: 120_000, }); results.push({ version, success: true }); } catch (err) { const message = err instanceof Error ? err.message : String(err); results.push({ version, success: false, error: message }); } } console.log( JSON.stringify({ migrationsRun: results.length, results }, null, 2), ); // Exit with error if any migration failed if (results.some((r) => !r.success)) { process.exit(1); } ================================================ FILE: setup/container.ts ================================================ /** * Step: container — Build container image and verify with test run. * Replaces 03-setup-container.sh */ import { execSync } from 'child_process'; import path from 'path'; import { logger } from '../src/logger.js'; import { commandExists } from './platform.js'; import { emitStatus } from './status.js'; function parseArgs(args: string[]): { runtime: string } { let runtime = ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--runtime' && args[i + 1]) { runtime = args[i + 1]; i++; } } return { runtime }; } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { runtime } = parseArgs(args); const image = 'nanoclaw-agent:latest'; const logFile = path.join(projectRoot, 'logs', 'setup.log'); if (!runtime) { emitStatus('SETUP_CONTAINER', { RUNTIME: 'unknown', IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'missing_runtime_flag', LOG: 'logs/setup.log', }); process.exit(4); } // Validate runtime availability if (runtime === 'apple-container' && !commandExists('container')) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log', }); process.exit(2); } if (runtime === 'docker') { if (!commandExists('docker')) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log', }); process.exit(2); } try { execSync('docker info', { stdio: 'ignore' }); } catch { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'runtime_not_available', LOG: 'logs/setup.log', }); process.exit(2); } } if (!['apple-container', 'docker'].includes(runtime)) { emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: false, TEST_OK: false, STATUS: 'failed', ERROR: 'unknown_runtime', LOG: 'logs/setup.log', }); process.exit(4); } const buildCmd = runtime === 'apple-container' ? 'container build' : 'docker build'; const runCmd = runtime === 'apple-container' ? 'container' : 'docker'; // Build let buildOk = false; logger.info({ runtime }, 'Building container'); try { execSync(`${buildCmd} -t ${image} .`, { cwd: path.join(projectRoot, 'container'), stdio: ['ignore', 'pipe', 'pipe'], }); buildOk = true; logger.info('Container build succeeded'); } catch (err) { logger.error({ err }, 'Container build failed'); } // Test let testOk = false; if (buildOk) { logger.info('Testing container'); try { const output = execSync( `echo '{}' | ${runCmd} run -i --rm --entrypoint /bin/echo ${image} "Container OK"`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }, ); testOk = output.includes('Container OK'); logger.info({ testOk }, 'Container test result'); } catch { logger.error('Container test failed'); } } const status = buildOk && testOk ? 'success' : 'failed'; emitStatus('SETUP_CONTAINER', { RUNTIME: runtime, IMAGE: image, BUILD_OK: buildOk, TEST_OK: testOk, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); } ================================================ FILE: setup/environment.test.ts ================================================ import { describe, it, expect, beforeEach } from 'vitest'; import fs from 'fs'; import Database from 'better-sqlite3'; /** * Tests for the environment check step. * * Verifies: config detection, Docker/AC detection, DB queries. */ describe('environment detection', () => { it('detects platform correctly', async () => { const { getPlatform } = await import('./platform.js'); const platform = getPlatform(); expect(['macos', 'linux', 'unknown']).toContain(platform); }); }); describe('registered groups DB query', () => { let db: Database.Database; beforeEach(() => { db = new Database(':memory:'); db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, name TEXT NOT NULL, folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, container_config TEXT, requires_trigger INTEGER DEFAULT 1 )`); }); it('returns 0 for empty table', () => { const row = db .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number }; expect(row.count).toBe(0); }); it('returns correct count after inserts', () => { db.prepare( `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) VALUES (?, ?, ?, ?, ?, ?)`, ).run( '123@g.us', 'Group 1', 'group-1', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); db.prepare( `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) VALUES (?, ?, ?, ?, ?, ?)`, ).run( '456@g.us', 'Group 2', 'group-2', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); const row = db .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number }; expect(row.count).toBe(2); }); }); describe('credentials detection', () => { it('detects ANTHROPIC_API_KEY in env content', () => { const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo'; const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); expect(hasCredentials).toBe(true); }); it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => { const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123'; const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); expect(hasCredentials).toBe(true); }); it('returns false when no credentials', () => { const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo'; const hasCredentials = /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); expect(hasCredentials).toBe(false); }); }); describe('Docker detection logic', () => { it('commandExists returns boolean', async () => { const { commandExists } = await import('./platform.js'); expect(typeof commandExists('docker')).toBe('boolean'); expect(typeof commandExists('nonexistent_binary_xyz')).toBe('boolean'); }); }); describe('channel auth detection', () => { it('detects non-empty auth directory', () => { const hasAuth = (authDir: string) => { try { return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; } catch { return false; } }; // Non-existent directory expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false); }); }); ================================================ FILE: setup/environment.ts ================================================ /** * Step: environment — Detect OS, Node, container runtimes, existing config. * Replaces 01-check-environment.sh */ import fs from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; import { logger } from '../src/logger.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); logger.info('Starting environment check'); const platform = getPlatform(); const wsl = isWSL(); const headless = isHeadless(); // Check Apple Container let appleContainer: 'installed' | 'not_found' = 'not_found'; if (commandExists('container')) { appleContainer = 'installed'; } // Check Docker let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found'; if (commandExists('docker')) { try { const { execSync } = await import('child_process'); execSync('docker info', { stdio: 'ignore' }); docker = 'running'; } catch { docker = 'installed_not_running'; } } // Check existing config const hasEnv = fs.existsSync(path.join(projectRoot, '.env')); const authDir = path.join(projectRoot, 'store', 'auth'); const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; let hasRegisteredGroups = false; // Check JSON file first (pre-migration) if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { hasRegisteredGroups = true; } else { // Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) const dbPath = path.join(STORE_DIR, 'messages.db'); if (fs.existsSync(dbPath)) { try { const db = new Database(dbPath, { readonly: true }); const row = db .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number }; if (row.count > 0) hasRegisteredGroups = true; db.close(); } catch { // Table might not exist yet } } } logger.info( { platform, wsl, appleContainer, docker, hasEnv, hasAuth, hasRegisteredGroups, }, 'Environment check complete', ); emitStatus('CHECK_ENVIRONMENT', { PLATFORM: platform, IS_WSL: wsl, IS_HEADLESS: headless, APPLE_CONTAINER: appleContainer, DOCKER: docker, HAS_ENV: hasEnv, HAS_AUTH: hasAuth, HAS_REGISTERED_GROUPS: hasRegisteredGroups, STATUS: 'success', LOG: 'logs/setup.log', }); } ================================================ FILE: setup/groups.ts ================================================ /** * Step: groups — Fetch group metadata from messaging platforms, write to DB. * WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating). * Other channels discover group names at runtime — this step auto-skips for them. * Replaces 05-sync-groups.sh + 05b-list-groups.sh */ import { execSync } from 'child_process'; import fs from 'fs'; import path from 'path'; import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; function parseArgs(args: string[]): { list: boolean; limit: number } { let list = false; let limit = 30; for (let i = 0; i < args.length; i++) { if (args[i] === '--list') list = true; if (args[i] === '--limit' && args[i + 1]) { limit = parseInt(args[i + 1], 10); i++; } } return { list, limit }; } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const { list, limit } = parseArgs(args); if (list) { await listGroups(limit); return; } await syncGroups(projectRoot); } async function listGroups(limit: number): Promise { const dbPath = path.join(STORE_DIR, 'messages.db'); if (!fs.existsSync(dbPath)) { console.error('ERROR: database not found'); process.exit(1); } const db = new Database(dbPath, { readonly: true }); const rows = db .prepare( `SELECT jid, name FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid ORDER BY last_message_time DESC LIMIT ?`, ) .all(limit) as Array<{ jid: string; name: string }>; db.close(); for (const row of rows) { console.log(`${row.jid}|${row.name}`); } } async function syncGroups(projectRoot: string): Promise { // Only WhatsApp needs an upfront group sync; other channels resolve names at runtime. // Detect WhatsApp by checking for auth credentials on disk. const authDir = path.join(projectRoot, 'store', 'auth'); const hasWhatsAppAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; if (!hasWhatsAppAuth) { logger.info('WhatsApp auth not found — skipping group sync'); emitStatus('SYNC_GROUPS', { BUILD: 'skipped', SYNC: 'skipped', GROUPS_IN_DB: 0, REASON: 'whatsapp_not_configured', STATUS: 'success', LOG: 'logs/setup.log', }); return; } // Build TypeScript first logger.info('Building TypeScript'); let buildOk = false; try { execSync('npm run build', { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], }); buildOk = true; logger.info('Build succeeded'); } catch { logger.error('Build failed'); emitStatus('SYNC_GROUPS', { BUILD: 'failed', SYNC: 'skipped', GROUPS_IN_DB: 0, STATUS: 'failed', ERROR: 'build_failed', LOG: 'logs/setup.log', }); process.exit(1); } // Run sync script via a temp file to avoid shell escaping issues with node -e logger.info('Fetching group metadata'); let syncOk = false; try { const syncScript = ` import makeWASocket, { useMultiFileAuthState, makeCacheableSignalKeyStore, Browsers } from '@whiskeysockets/baileys'; import pino from 'pino'; import path from 'path'; import fs from 'fs'; import Database from 'better-sqlite3'; const logger = pino({ level: 'silent' }); const authDir = path.join('store', 'auth'); const dbPath = path.join('store', 'messages.db'); if (!fs.existsSync(authDir)) { console.error('NO_AUTH'); process.exit(1); } const db = new Database(dbPath); db.pragma('journal_mode = WAL'); db.exec('CREATE TABLE IF NOT EXISTS chats (jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT)'); const upsert = db.prepare( 'INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name' ); const { state, saveCreds } = await useMultiFileAuthState(authDir); const sock = makeWASocket({ auth: { creds: state.creds, keys: makeCacheableSignalKeyStore(state.keys, logger) }, printQRInTerminal: false, logger, browser: Browsers.macOS('Chrome'), }); const timeout = setTimeout(() => { console.error('TIMEOUT'); process.exit(1); }, 30000); sock.ev.on('creds.update', saveCreds); sock.ev.on('connection.update', async (update) => { if (update.connection === 'open') { try { const groups = await sock.groupFetchAllParticipating(); const now = new Date().toISOString(); let count = 0; for (const [jid, metadata] of Object.entries(groups)) { if (metadata.subject) { upsert.run(jid, metadata.subject, now); count++; } } console.log('SYNCED:' + count); } catch (err) { console.error('FETCH_ERROR:' + err.message); } finally { clearTimeout(timeout); sock.end(undefined); db.close(); process.exit(0); } } else if (update.connection === 'close') { clearTimeout(timeout); console.error('CONNECTION_CLOSED'); process.exit(1); } }); `; const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs'); fs.writeFileSync(tmpScript, syncScript, 'utf-8'); try { const output = execSync(`node ${tmpScript}`, { cwd: projectRoot, encoding: 'utf-8', timeout: 45000, stdio: ['ignore', 'pipe', 'pipe'], }); syncOk = output.includes('SYNCED:'); logger.info({ output: output.trim() }, 'Sync output'); } finally { try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ } } } catch (err) { logger.error({ err }, 'Sync failed'); } // Count groups in DB using better-sqlite3 (no sqlite3 CLI) let groupsInDb = 0; const dbPath = path.join(STORE_DIR, 'messages.db'); if (fs.existsSync(dbPath)) { try { const db = new Database(dbPath, { readonly: true }); const row = db .prepare( "SELECT COUNT(*) as count FROM chats WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__'", ) .get() as { count: number }; groupsInDb = row.count; db.close(); } catch { // DB may not exist yet } } const status = syncOk ? 'success' : 'failed'; emitStatus('SYNC_GROUPS', { BUILD: buildOk ? 'success' : 'failed', SYNC: syncOk ? 'success' : 'failed', GROUPS_IN_DB: groupsInDb, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); } ================================================ FILE: setup/index.ts ================================================ /** * Setup CLI entry point. * Usage: npx tsx setup/index.ts --step [args...] */ import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; const STEPS: Record< string, () => Promise<{ run: (args: string[]) => Promise }> > = { environment: () => import('./environment.js'), container: () => import('./container.js'), groups: () => import('./groups.js'), register: () => import('./register.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), }; async function main(): Promise { const args = process.argv.slice(2); const stepIdx = args.indexOf('--step'); if (stepIdx === -1 || !args[stepIdx + 1]) { console.error( `Usage: npx tsx setup/index.ts --step <${Object.keys(STEPS).join('|')}> [args...]`, ); process.exit(1); } const stepName = args[stepIdx + 1]; const stepArgs = args.filter( (a, i) => i !== stepIdx && i !== stepIdx + 1 && a !== '--', ); const loader = STEPS[stepName]; if (!loader) { console.error(`Unknown step: ${stepName}`); console.error(`Available steps: ${Object.keys(STEPS).join(', ')}`); process.exit(1); } try { const mod = await loader(); await mod.run(stepArgs); } catch (err) { const message = err instanceof Error ? err.message : String(err); logger.error({ err, step: stepName }, 'Setup step failed'); emitStatus(stepName.toUpperCase(), { STATUS: 'failed', ERROR: message, }); process.exit(1); } } main(); ================================================ FILE: setup/mounts.ts ================================================ /** * Step: mounts — Write mount allowlist config file. * Replaces 07-configure-mounts.sh */ import fs from 'fs'; import path from 'path'; import os from 'os'; import { logger } from '../src/logger.js'; import { isRoot } from './platform.js'; import { emitStatus } from './status.js'; function parseArgs(args: string[]): { empty: boolean; json: string } { let empty = false; let json = ''; for (let i = 0; i < args.length; i++) { if (args[i] === '--empty') empty = true; if (args[i] === '--json' && args[i + 1]) { json = args[i + 1]; i++; } } return { empty, json }; } export async function run(args: string[]): Promise { const { empty, json } = parseArgs(args); const homeDir = os.homedir(); const configDir = path.join(homeDir, '.config', 'nanoclaw'); const configFile = path.join(configDir, 'mount-allowlist.json'); if (isRoot()) { logger.warn( 'Running as root — mount allowlist will be written to root home directory', ); } fs.mkdirSync(configDir, { recursive: true }); let allowedRoots = 0; let nonMainReadOnly = 'true'; if (empty) { logger.info('Writing empty mount allowlist'); const emptyConfig = { allowedRoots: [], blockedPatterns: [], nonMainReadOnly: true, }; fs.writeFileSync(configFile, JSON.stringify(emptyConfig, null, 2) + '\n'); } else if (json) { // Validate JSON with JSON.parse (not piped through shell) let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean }; try { parsed = JSON.parse(json); } catch { logger.error('Invalid JSON input'); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, ALLOWED_ROOTS: 0, NON_MAIN_READ_ONLY: 'unknown', STATUS: 'failed', ERROR: 'invalid_json', LOG: 'logs/setup.log', }); process.exit(4); return; // unreachable but satisfies TS } fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n'); allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0; nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; } else { // Read from stdin logger.info('Reading mount allowlist from stdin'); const input = fs.readFileSync(0, 'utf-8'); let parsed: { allowedRoots?: unknown[]; nonMainReadOnly?: boolean }; try { parsed = JSON.parse(input); } catch { logger.error('Invalid JSON from stdin'); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, ALLOWED_ROOTS: 0, NON_MAIN_READ_ONLY: 'unknown', STATUS: 'failed', ERROR: 'invalid_json', LOG: 'logs/setup.log', }); process.exit(4); return; } fs.writeFileSync(configFile, JSON.stringify(parsed, null, 2) + '\n'); allowedRoots = Array.isArray(parsed.allowedRoots) ? parsed.allowedRoots.length : 0; nonMainReadOnly = parsed.nonMainReadOnly === false ? 'false' : 'true'; } logger.info( { configFile, allowedRoots, nonMainReadOnly }, 'Allowlist configured', ); emitStatus('CONFIGURE_MOUNTS', { PATH: configFile, ALLOWED_ROOTS: allowedRoots, NON_MAIN_READ_ONLY: nonMainReadOnly, STATUS: 'success', LOG: 'logs/setup.log', }); } ================================================ FILE: setup/platform.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { getPlatform, isWSL, isRoot, isHeadless, hasSystemd, getServiceManager, commandExists, getNodeVersion, getNodeMajorVersion, } from './platform.js'; // --- getPlatform --- describe('getPlatform', () => { it('returns a valid platform string', () => { const result = getPlatform(); expect(['macos', 'linux', 'unknown']).toContain(result); }); }); // --- isWSL --- describe('isWSL', () => { it('returns a boolean', () => { expect(typeof isWSL()).toBe('boolean'); }); it('checks /proc/version for WSL markers', () => { // On non-WSL Linux, should return false // On WSL, should return true // Just verify it doesn't throw const result = isWSL(); expect(typeof result).toBe('boolean'); }); }); // --- isRoot --- describe('isRoot', () => { it('returns a boolean', () => { expect(typeof isRoot()).toBe('boolean'); }); }); // --- isHeadless --- describe('isHeadless', () => { it('returns a boolean', () => { expect(typeof isHeadless()).toBe('boolean'); }); }); // --- hasSystemd --- describe('hasSystemd', () => { it('returns a boolean', () => { expect(typeof hasSystemd()).toBe('boolean'); }); it('checks /proc/1/comm', () => { // On systemd systems, should return true // Just verify it doesn't throw const result = hasSystemd(); expect(typeof result).toBe('boolean'); }); }); // --- getServiceManager --- describe('getServiceManager', () => { it('returns a valid service manager', () => { const result = getServiceManager(); expect(['launchd', 'systemd', 'none']).toContain(result); }); it('matches the detected platform', () => { const platform = getPlatform(); const result = getServiceManager(); if (platform === 'macos') { expect(result).toBe('launchd'); } else { expect(['systemd', 'none']).toContain(result); } }); }); // --- commandExists --- describe('commandExists', () => { it('returns true for node', () => { expect(commandExists('node')).toBe(true); }); it('returns false for nonexistent command', () => { expect(commandExists('this_command_does_not_exist_xyz_123')).toBe(false); }); }); // --- getNodeVersion --- describe('getNodeVersion', () => { it('returns a version string', () => { const version = getNodeVersion(); expect(version).not.toBeNull(); expect(version).toMatch(/^\d+\.\d+\.\d+/); }); }); // --- getNodeMajorVersion --- describe('getNodeMajorVersion', () => { it('returns at least 20', () => { const major = getNodeMajorVersion(); expect(major).not.toBeNull(); expect(major!).toBeGreaterThanOrEqual(20); }); }); ================================================ FILE: setup/platform.ts ================================================ /** * Cross-platform detection utilities for NanoClaw setup. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; export type Platform = 'macos' | 'linux' | 'unknown'; export type ServiceManager = 'launchd' | 'systemd' | 'none'; export function getPlatform(): Platform { const platform = os.platform(); if (platform === 'darwin') return 'macos'; if (platform === 'linux') return 'linux'; return 'unknown'; } export function isWSL(): boolean { if (os.platform() !== 'linux') return false; try { const release = fs.readFileSync('/proc/version', 'utf-8').toLowerCase(); return release.includes('microsoft') || release.includes('wsl'); } catch { return false; } } export function isRoot(): boolean { return process.getuid?.() === 0; } export function isHeadless(): boolean { // No display server available if (getPlatform() === 'linux') { return !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY; } // macOS is never headless in practice (even SSH sessions can open URLs) return false; } export function hasSystemd(): boolean { if (getPlatform() !== 'linux') return false; try { // Check if systemd is PID 1 const init = fs.readFileSync('/proc/1/comm', 'utf-8').trim(); return init === 'systemd'; } catch { return false; } } /** * Open a URL in the default browser, cross-platform. * Returns true if the command was attempted, false if no method available. */ export function openBrowser(url: string): boolean { try { const platform = getPlatform(); if (platform === 'macos') { execSync(`open ${JSON.stringify(url)}`, { stdio: 'ignore' }); return true; } if (platform === 'linux') { // Try xdg-open first, then wslview for WSL if (commandExists('xdg-open')) { execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: 'ignore' }); return true; } if (isWSL() && commandExists('wslview')) { execSync(`wslview ${JSON.stringify(url)}`, { stdio: 'ignore' }); return true; } // WSL without wslview: try cmd.exe if (isWSL()) { try { execSync(`cmd.exe /c start "" ${JSON.stringify(url)}`, { stdio: 'ignore', }); return true; } catch { // cmd.exe not available } } } } catch { // Command failed } return false; } export function getServiceManager(): ServiceManager { const platform = getPlatform(); if (platform === 'macos') return 'launchd'; if (platform === 'linux') { if (hasSystemd()) return 'systemd'; return 'none'; } return 'none'; } export function getNodePath(): string { try { return execSync('command -v node', { encoding: 'utf-8' }).trim(); } catch { return process.execPath; } } export function commandExists(name: string): boolean { try { execSync(`command -v ${name}`, { stdio: 'ignore' }); return true; } catch { return false; } } export function getNodeVersion(): string | null { try { const version = execSync('node --version', { encoding: 'utf-8' }).trim(); return version.replace(/^v/, ''); } catch { return null; } } export function getNodeMajorVersion(): number | null { const version = getNodeVersion(); if (!version) return null; const major = parseInt(version.split('.')[0], 10); return isNaN(major) ? null : major; } ================================================ FILE: setup/register.test.ts ================================================ import { describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; /** * Tests for the register step. * * Verifies: parameterized SQL (no injection), file templating, * apostrophe in names, .env updates. */ function createTestDb(): Database.Database { const db = new Database(':memory:'); db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, name TEXT NOT NULL, folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, container_config TEXT, requires_trigger INTEGER DEFAULT 1, is_main INTEGER DEFAULT 0 )`); return db; } describe('parameterized SQL registration', () => { let db: Database.Database; beforeEach(() => { db = createTestDb(); }); it('registers a group with parameterized query', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', 'Test Group', 'test-group', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); const row = db .prepare('SELECT * FROM registered_groups WHERE jid = ?') .get('123@g.us') as { jid: string; name: string; folder: string; trigger_pattern: string; requires_trigger: number; }; expect(row.jid).toBe('123@g.us'); expect(row.name).toBe('Test Group'); expect(row.folder).toBe('test-group'); expect(row.trigger_pattern).toBe('@Andy'); expect(row.requires_trigger).toBe(1); }); it('handles apostrophes in group names safely', () => { const name = "O'Brien's Group"; db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '456@g.us', name, 'obriens-group', '@Andy', '2024-01-01T00:00:00.000Z', 0, ); const row = db .prepare('SELECT name FROM registered_groups WHERE jid = ?') .get('456@g.us') as { name: string; }; expect(row.name).toBe(name); }); it('prevents SQL injection in JID field', () => { const maliciousJid = "'; DROP TABLE registered_groups; --"; db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1); // Table should still exist and have the row const count = db .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number; }; expect(count.count).toBe(1); const row = db.prepare('SELECT jid FROM registered_groups').get() as { jid: string; }; expect(row.jid).toBe(maliciousJid); }); it('handles requiresTrigger=false', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '789@s.whatsapp.net', 'Personal', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 0, ); const row = db .prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?') .get('789@s.whatsapp.net') as { requires_trigger: number }; expect(row.requires_trigger).toBe(0); }); it('stores is_main flag', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`, ).run( '789@s.whatsapp.net', 'Personal', 'whatsapp_main', '@Andy', '2024-01-01T00:00:00.000Z', 0, 1, ); const row = db .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') .get('789@s.whatsapp.net') as { is_main: number }; expect(row.is_main).toBe(1); }); it('defaults is_main to 0', () => { db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ).run( '123@g.us', 'Some Group', 'whatsapp_some-group', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); const row = db .prepare('SELECT is_main FROM registered_groups WHERE jid = ?') .get('123@g.us') as { is_main: number }; expect(row.is_main).toBe(0); }); it('upserts on conflict', () => { const stmt = db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger) VALUES (?, ?, ?, ?, ?, NULL, ?)`, ); stmt.run( '123@g.us', 'Original', 'main', '@Andy', '2024-01-01T00:00:00.000Z', 1, ); stmt.run( '123@g.us', 'Updated', 'main', '@Bot', '2024-02-01T00:00:00.000Z', 0, ); const rows = db.prepare('SELECT * FROM registered_groups').all(); expect(rows).toHaveLength(1); const row = rows[0] as { name: string; trigger_pattern: string; requires_trigger: number; }; expect(row.name).toBe('Updated'); expect(row.trigger_pattern).toBe('@Bot'); expect(row.requires_trigger).toBe(0); }); }); describe('file templating', () => { it('replaces assistant name in CLAUDE.md content', () => { let content = '# Andy\n\nYou are Andy, a personal assistant.'; content = content.replace(/^# Andy$/m, '# Nova'); content = content.replace(/You are Andy/g, 'You are Nova'); expect(content).toBe('# Nova\n\nYou are Nova, a personal assistant.'); }); it('handles names with special regex characters', () => { let content = '# Andy\n\nYou are Andy.'; const newName = 'C.L.A.U.D.E'; content = content.replace(/^# Andy$/m, `# ${newName}`); content = content.replace(/You are Andy/g, `You are ${newName}`); expect(content).toContain('# C.L.A.U.D.E'); expect(content).toContain('You are C.L.A.U.D.E.'); }); it('updates .env ASSISTANT_NAME line', () => { let envContent = 'SOME_KEY=value\nASSISTANT_NAME="Andy"\nOTHER=test'; envContent = envContent.replace( /^ASSISTANT_NAME=.*$/m, 'ASSISTANT_NAME="Nova"', ); expect(envContent).toContain('ASSISTANT_NAME="Nova"'); expect(envContent).toContain('SOME_KEY=value'); }); it('appends ASSISTANT_NAME to .env if not present', () => { let envContent = 'SOME_KEY=value\n'; if (!envContent.includes('ASSISTANT_NAME=')) { envContent += '\nASSISTANT_NAME="Nova"'; } expect(envContent).toContain('ASSISTANT_NAME="Nova"'); }); }); ================================================ FILE: setup/register.ts ================================================ /** * Step: register — Write channel registration config, create group folders. * * Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord). * Uses parameterized SQL queries to prevent injection. */ import fs from 'fs'; import path from 'path'; import { STORE_DIR } from '../src/config.ts'; import { initDatabase, setRegisteredGroup } from '../src/db.ts'; import { isValidGroupFolder } from '../src/group-folder.ts'; import { logger } from '../src/logger.ts'; import { emitStatus } from './status.ts'; interface RegisterArgs { jid: string; name: string; trigger: string; folder: string; channel: string; requiresTrigger: boolean; isMain: boolean; assistantName: string; } function parseArgs(args: string[]): RegisterArgs { const result: RegisterArgs = { jid: '', name: '', trigger: '', folder: '', channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel requiresTrigger: true, isMain: false, assistantName: 'Andy', }; for (let i = 0; i < args.length; i++) { switch (args[i]) { case '--jid': result.jid = args[++i] || ''; break; case '--name': result.name = args[++i] || ''; break; case '--trigger': result.trigger = args[++i] || ''; break; case '--folder': result.folder = args[++i] || ''; break; case '--channel': result.channel = (args[++i] || '').toLowerCase(); break; case '--no-trigger-required': result.requiresTrigger = false; break; case '--is-main': result.isMain = true; break; case '--assistant-name': result.assistantName = args[++i] || 'Andy'; break; } } return result; } export async function run(args: string[]): Promise { const projectRoot = process.cwd(); const parsed = parseArgs(args); if (!parsed.jid || !parsed.name || !parsed.trigger || !parsed.folder) { emitStatus('REGISTER_CHANNEL', { STATUS: 'failed', ERROR: 'missing_required_args', LOG: 'logs/setup.log', }); process.exit(4); } if (!isValidGroupFolder(parsed.folder)) { emitStatus('REGISTER_CHANNEL', { STATUS: 'failed', ERROR: 'invalid_folder', LOG: 'logs/setup.log', }); process.exit(4); } logger.info(parsed, 'Registering channel'); // Ensure data and store directories exist (store/ may not exist on // fresh installs that skip WhatsApp auth, which normally creates it) fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true }); fs.mkdirSync(STORE_DIR, { recursive: true }); // Initialize database (creates schema + runs migrations) initDatabase(); setRegisteredGroup(parsed.jid, { name: parsed.name, folder: parsed.folder, trigger: parsed.trigger, added_at: new Date().toISOString(), requiresTrigger: parsed.requiresTrigger, isMain: parsed.isMain, }); logger.info('Wrote registration to SQLite'); // Create group folders fs.mkdirSync(path.join(projectRoot, 'groups', parsed.folder, 'logs'), { recursive: true, }); // Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { logger.info( { from: 'Andy', to: parsed.assistantName }, 'Updating assistant name', ); const mdFiles = [ path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), ]; for (const mdFile of mdFiles) { if (fs.existsSync(mdFile)) { let content = fs.readFileSync(mdFile, 'utf-8'); content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`); content = content.replace( /You are Andy/g, `You are ${parsed.assistantName}`, ); fs.writeFileSync(mdFile, content); logger.info({ file: mdFile }, 'Updated CLAUDE.md'); } } // Update .env const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { let envContent = fs.readFileSync(envFile, 'utf-8'); if (envContent.includes('ASSISTANT_NAME=')) { envContent = envContent.replace( /^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`, ); } else { envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`; } fs.writeFileSync(envFile, envContent); } else { fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`); } logger.info('Set ASSISTANT_NAME in .env'); nameUpdated = true; } emitStatus('REGISTER_CHANNEL', { JID: parsed.jid, NAME: parsed.name, FOLDER: parsed.folder, CHANNEL: parsed.channel, TRIGGER: parsed.trigger, REQUIRES_TRIGGER: parsed.requiresTrigger, ASSISTANT_NAME: parsed.assistantName, NAME_UPDATED: nameUpdated, STATUS: 'success', LOG: 'logs/setup.log', }); } ================================================ FILE: setup/service.test.ts ================================================ import { describe, it, expect } from 'vitest'; import path from 'path'; /** * Tests for service configuration generation. * * These tests verify the generated content of plist/systemd/nohup configs * without actually loading services. */ // Helper: generate a plist string the same way service.ts does function generatePlist( nodePath: string, projectRoot: string, homeDir: string, ): string { return ` Label com.nanoclaw ProgramArguments ${nodePath} ${projectRoot}/dist/index.js WorkingDirectory ${projectRoot} RunAtLoad KeepAlive EnvironmentVariables PATH /usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin HOME ${homeDir} StandardOutPath ${projectRoot}/logs/nanoclaw.log StandardErrorPath ${projectRoot}/logs/nanoclaw.error.log `; } function generateSystemdUnit( nodePath: string, projectRoot: string, homeDir: string, isSystem: boolean, ): string { return `[Unit] Description=NanoClaw Personal Assistant After=network.target [Service] Type=simple ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 KillMode=process Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin StandardOutput=append:${projectRoot}/logs/nanoclaw.log StandardError=append:${projectRoot}/logs/nanoclaw.error.log [Install] WantedBy=${isSystem ? 'multi-user.target' : 'default.target'}`; } describe('plist generation', () => { it('contains the correct label', () => { const plist = generatePlist( '/usr/local/bin/node', '/home/user/nanoclaw', '/home/user', ); expect(plist).toContain('com.nanoclaw'); }); it('uses the correct node path', () => { const plist = generatePlist( '/opt/node/bin/node', '/home/user/nanoclaw', '/home/user', ); expect(plist).toContain('/opt/node/bin/node'); }); it('points to dist/index.js', () => { const plist = generatePlist( '/usr/local/bin/node', '/home/user/nanoclaw', '/home/user', ); expect(plist).toContain('/home/user/nanoclaw/dist/index.js'); }); it('sets log paths', () => { const plist = generatePlist( '/usr/local/bin/node', '/home/user/nanoclaw', '/home/user', ); expect(plist).toContain('nanoclaw.log'); expect(plist).toContain('nanoclaw.error.log'); }); }); describe('systemd unit generation', () => { it('user unit uses default.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', '/home/user/nanoclaw', '/home/user', false, ); expect(unit).toContain('WantedBy=default.target'); }); it('system unit uses multi-user.target', () => { const unit = generateSystemdUnit( '/usr/bin/node', '/home/user/nanoclaw', '/home/user', true, ); expect(unit).toContain('WantedBy=multi-user.target'); }); it('contains restart policy', () => { const unit = generateSystemdUnit( '/usr/bin/node', '/home/user/nanoclaw', '/home/user', false, ); expect(unit).toContain('Restart=always'); expect(unit).toContain('RestartSec=5'); }); it('uses KillMode=process to preserve detached children', () => { const unit = generateSystemdUnit( '/usr/bin/node', '/home/user/nanoclaw', '/home/user', false, ); expect(unit).toContain('KillMode=process'); }); it('sets correct ExecStart', () => { const unit = generateSystemdUnit( '/usr/bin/node', '/srv/nanoclaw', '/home/user', false, ); expect(unit).toContain( 'ExecStart=/usr/bin/node /srv/nanoclaw/dist/index.js', ); }); }); describe('WSL nohup fallback', () => { it('generates a valid wrapper script', () => { const projectRoot = '/home/user/nanoclaw'; const nodePath = '/usr/bin/node'; const pidFile = path.join(projectRoot, 'nanoclaw.pid'); // Simulate what service.ts generates const wrapper = `#!/bin/bash set -euo pipefail cd ${JSON.stringify(projectRoot)} nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot)}/dist/index.js >> ${JSON.stringify(projectRoot)}/logs/nanoclaw.log 2>> ${JSON.stringify(projectRoot)}/logs/nanoclaw.error.log & echo $! > ${JSON.stringify(pidFile)}`; expect(wrapper).toContain('#!/bin/bash'); expect(wrapper).toContain('nohup'); expect(wrapper).toContain(nodePath); expect(wrapper).toContain('nanoclaw.pid'); }); }); ================================================ FILE: setup/service.ts ================================================ /** * Step: service — Generate and load service manager config. * Replaces 08-setup-service.sh * * Fixes: Root→system systemd, WSL nohup fallback, no `|| true` swallowing errors. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { logger } from '../src/logger.js'; import { getPlatform, getNodePath, getServiceManager, hasSystemd, isRoot, isWSL, } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); const nodePath = getNodePath(); const homeDir = os.homedir(); logger.info({ platform, nodePath, projectRoot }, 'Setting up service'); // Build first logger.info('Building TypeScript'); try { execSync('npm run build', { cwd: projectRoot, stdio: ['ignore', 'pipe', 'pipe'], }); logger.info('Build succeeded'); } catch { logger.error('Build failed'); emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, STATUS: 'failed', ERROR: 'build_failed', LOG: 'logs/setup.log', }); process.exit(1); } fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); if (platform === 'macos') { setupLaunchd(projectRoot, nodePath, homeDir); } else if (platform === 'linux') { setupLinux(projectRoot, nodePath, homeDir); } else { emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'unknown', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, STATUS: 'failed', ERROR: 'unsupported_platform', LOG: 'logs/setup.log', }); process.exit(1); } } function setupLaunchd( projectRoot: string, nodePath: string, homeDir: string, ): void { const plistPath = path.join( homeDir, 'Library', 'LaunchAgents', 'com.nanoclaw.plist', ); fs.mkdirSync(path.dirname(plistPath), { recursive: true }); const plist = ` Label com.nanoclaw ProgramArguments ${nodePath} ${projectRoot}/dist/index.js WorkingDirectory ${projectRoot} RunAtLoad KeepAlive EnvironmentVariables PATH /usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin HOME ${homeDir} StandardOutPath ${projectRoot}/logs/nanoclaw.log StandardErrorPath ${projectRoot}/logs/nanoclaw.error.log `; fs.writeFileSync(plistPath, plist); logger.info({ plistPath }, 'Wrote launchd plist'); try { execSync(`launchctl load ${JSON.stringify(plistPath)}`, { stdio: 'ignore', }); logger.info('launchctl load succeeded'); } catch { logger.warn('launchctl load failed (may already be loaded)'); } // Verify let serviceLoaded = false; try { const output = execSync('launchctl list', { encoding: 'utf-8' }); serviceLoaded = output.includes('com.nanoclaw'); } catch { // launchctl list failed } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'launchd', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, PLIST_PATH: plistPath, SERVICE_LOADED: serviceLoaded, STATUS: 'success', LOG: 'logs/setup.log', }); } function setupLinux( projectRoot: string, nodePath: string, homeDir: string, ): void { const serviceManager = getServiceManager(); if (serviceManager === 'systemd') { setupSystemd(projectRoot, nodePath, homeDir); } else { // WSL without systemd or other Linux without systemd setupNohupFallback(projectRoot, nodePath, homeDir); } } /** * Kill any orphaned nanoclaw node processes left from previous runs or debugging. * Prevents connection conflicts when two instances connect to the same channel simultaneously. */ function killOrphanedProcesses(projectRoot: string): void { try { execSync(`pkill -f '${projectRoot}/dist/index\\.js' || true`, { stdio: 'ignore', }); logger.info('Stopped any orphaned nanoclaw processes'); } catch { // pkill not available or no orphans } } /** * Detect stale docker group membership in the user systemd session. * * When a user is added to the docker group mid-session, the user systemd * daemon (user@UID.service) keeps the old group list from login time. * Docker works in the terminal but not in the service context. * * Only relevant on Linux with user-level systemd (not root, not macOS, not WSL nohup). */ function checkDockerGroupStale(): boolean { try { execSync('systemd-run --user --pipe --wait docker info', { stdio: 'pipe', timeout: 10000, }); return false; // Docker works from systemd session } catch { // Check if docker works from the current shell (to distinguish stale group vs broken docker) try { execSync('docker info', { stdio: 'pipe', timeout: 5000 }); return true; // Works in shell but not systemd session → stale group } catch { return false; // Docker itself is not working, different issue } } } function setupSystemd( projectRoot: string, nodePath: string, homeDir: string, ): void { const runningAsRoot = isRoot(); // Root uses system-level service, non-root uses user-level let unitPath: string; let systemctlPrefix: string; if (runningAsRoot) { unitPath = '/etc/systemd/system/nanoclaw.service'; systemctlPrefix = 'systemctl'; logger.info('Running as root — installing system-level systemd unit'); } else { // Check if user-level systemd session is available try { execSync('systemctl --user daemon-reload', { stdio: 'pipe' }); } catch { logger.warn( 'systemd user session not available — falling back to nohup wrapper', ); setupNohupFallback(projectRoot, nodePath, homeDir); return; } const unitDir = path.join(homeDir, '.config', 'systemd', 'user'); fs.mkdirSync(unitDir, { recursive: true }); unitPath = path.join(unitDir, 'nanoclaw.service'); systemctlPrefix = 'systemctl --user'; } const unit = `[Unit] Description=NanoClaw Personal Assistant After=network.target [Service] Type=simple ExecStart=${nodePath} ${projectRoot}/dist/index.js WorkingDirectory=${projectRoot} Restart=always RestartSec=5 KillMode=process Environment=HOME=${homeDir} Environment=PATH=/usr/local/bin:/usr/bin:/bin:${homeDir}/.local/bin StandardOutput=append:${projectRoot}/logs/nanoclaw.log StandardError=append:${projectRoot}/logs/nanoclaw.error.log [Install] WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; fs.writeFileSync(unitPath, unit); logger.info({ unitPath }, 'Wrote systemd unit'); // Detect stale docker group before starting (user systemd only) const dockerGroupStale = !runningAsRoot && checkDockerGroupStale(); if (dockerGroupStale) { logger.warn( 'Docker group not active in systemd session — user was likely added to docker group mid-session', ); } // Kill orphaned nanoclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); // Enable and start try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); } catch (err) { logger.error({ err }, 'systemctl daemon-reload failed'); } try { execSync(`${systemctlPrefix} enable nanoclaw`, { stdio: 'ignore' }); } catch (err) { logger.error({ err }, 'systemctl enable failed'); } try { execSync(`${systemctlPrefix} start nanoclaw`, { stdio: 'ignore' }); } catch (err) { logger.error({ err }, 'systemctl start failed'); } // Verify let serviceLoaded = false; try { execSync(`${systemctlPrefix} is-active nanoclaw`, { stdio: 'ignore' }); serviceLoaded = true; } catch { // Not active } emitStatus('SETUP_SERVICE', { SERVICE_TYPE: runningAsRoot ? 'systemd-system' : 'systemd-user', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, UNIT_PATH: unitPath, SERVICE_LOADED: serviceLoaded, ...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}), STATUS: 'success', LOG: 'logs/setup.log', }); } function setupNohupFallback( projectRoot: string, nodePath: string, homeDir: string, ): void { logger.warn('No systemd detected — generating nohup wrapper script'); const wrapperPath = path.join(projectRoot, 'start-nanoclaw.sh'); const pidFile = path.join(projectRoot, 'nanoclaw.pid'); const lines = [ '#!/bin/bash', '# start-nanoclaw.sh — Start NanoClaw without systemd', `# To stop: kill \\$(cat ${pidFile})`, '', 'set -euo pipefail', '', `cd ${JSON.stringify(projectRoot)}`, '', '# Stop existing instance if running', `if [ -f ${JSON.stringify(pidFile)} ]; then`, ` OLD_PID=$(cat ${JSON.stringify(pidFile)} 2>/dev/null || echo "")`, ' if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then', ' echo "Stopping existing NanoClaw (PID $OLD_PID)..."', ' kill "$OLD_PID" 2>/dev/null || true', ' sleep 2', ' fi', 'fi', '', 'echo "Starting NanoClaw..."', `nohup ${JSON.stringify(nodePath)} ${JSON.stringify(projectRoot + '/dist/index.js')} \\`, ` >> ${JSON.stringify(projectRoot + '/logs/nanoclaw.log')} \\`, ` 2>> ${JSON.stringify(projectRoot + '/logs/nanoclaw.error.log')} &`, '', `echo $! > ${JSON.stringify(pidFile)}`, 'echo "NanoClaw started (PID $!)"', `echo "Logs: tail -f ${projectRoot}/logs/nanoclaw.log"`, ]; const wrapper = lines.join('\n') + '\n'; fs.writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); logger.info({ wrapperPath }, 'Wrote nohup wrapper script'); emitStatus('SETUP_SERVICE', { SERVICE_TYPE: 'nohup', NODE_PATH: nodePath, PROJECT_PATH: projectRoot, WRAPPER_PATH: wrapperPath, SERVICE_LOADED: false, FALLBACK: 'wsl_no_systemd', STATUS: 'success', LOG: 'logs/setup.log', }); } ================================================ FILE: setup/status.ts ================================================ /** * Structured status block output for setup steps. * Each step emits a block that the SKILL.md LLM can parse. */ export function emitStatus( step: string, fields: Record, ): void { const lines = [`=== NANOCLAW SETUP: ${step} ===`]; for (const [key, value] of Object.entries(fields)) { lines.push(`${key}: ${value}`); } lines.push('=== END ==='); console.log(lines.join('\n')); } ================================================ FILE: setup/verify.ts ================================================ /** * Step: verify — End-to-end health check of the full installation. * Replaces 09-verify.sh * * Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import path from 'path'; import Database from 'better-sqlite3'; import { STORE_DIR } from '../src/config.js'; import { readEnvFile } from '../src/env.js'; import { logger } from '../src/logger.js'; import { getPlatform, getServiceManager, hasSystemd, isRoot, } from './platform.js'; import { emitStatus } from './status.js'; export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); const platform = getPlatform(); const homeDir = os.homedir(); logger.info('Starting verification'); // 1. Check service status let service = 'not_found'; const mgr = getServiceManager(); if (mgr === 'launchd') { try { const output = execSync('launchctl list', { encoding: 'utf-8' }); if (output.includes('com.nanoclaw')) { // Check if it has a PID (actually running) const line = output.split('\n').find((l) => l.includes('com.nanoclaw')); if (line) { const pidField = line.trim().split(/\s+/)[0]; service = pidField !== '-' && pidField ? 'running' : 'stopped'; } } } catch { // launchctl not available } } else if (mgr === 'systemd') { const prefix = isRoot() ? 'systemctl' : 'systemctl --user'; try { execSync(`${prefix} is-active nanoclaw`, { stdio: 'ignore' }); service = 'running'; } catch { try { const output = execSync(`${prefix} list-unit-files`, { encoding: 'utf-8', }); if (output.includes('nanoclaw')) { service = 'stopped'; } } catch { // systemctl not available } } } else { // Check for nohup PID file const pidFile = path.join(projectRoot, 'nanoclaw.pid'); if (fs.existsSync(pidFile)) { try { const raw = fs.readFileSync(pidFile, 'utf-8').trim(); const pid = Number(raw); if (raw && Number.isInteger(pid) && pid > 0) { process.kill(pid, 0); service = 'running'; } } catch { service = 'stopped'; } } } logger.info({ service }, 'Service status'); // 2. Check container runtime let containerRuntime = 'none'; try { execSync('command -v container', { stdio: 'ignore' }); containerRuntime = 'apple-container'; } catch { try { execSync('docker info', { stdio: 'ignore' }); containerRuntime = 'docker'; } catch { // No runtime } } // 3. Check credentials let credentials = 'missing'; const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(envContent)) { credentials = 'configured'; } } // 4. Check channel auth (detect configured channels by credentials) const envVars = readEnvFile([ 'TELEGRAM_BOT_TOKEN', 'SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'DISCORD_BOT_TOKEN', ]); const channelAuth: Record = {}; // WhatsApp: check for auth credentials on disk const authDir = path.join(projectRoot, 'store', 'auth'); if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) { channelAuth.whatsapp = 'authenticated'; } // Token-based channels: check .env if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) { channelAuth.telegram = 'configured'; } if ( (process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) && (process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN) ) { channelAuth.slack = 'configured'; } if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) { channelAuth.discord = 'configured'; } const configuredChannels = Object.keys(channelAuth); const anyChannelConfigured = configuredChannels.length > 0; // 5. Check registered groups (using better-sqlite3, not sqlite3 CLI) let registeredGroups = 0; const dbPath = path.join(STORE_DIR, 'messages.db'); if (fs.existsSync(dbPath)) { try { const db = new Database(dbPath, { readonly: true }); const row = db .prepare('SELECT COUNT(*) as count FROM registered_groups') .get() as { count: number }; registeredGroups = row.count; db.close(); } catch { // Table might not exist } } // 6. Check mount allowlist let mountAllowlist = 'missing'; if ( fs.existsSync( path.join(homeDir, '.config', 'nanoclaw', 'mount-allowlist.json'), ) ) { mountAllowlist = 'configured'; } // Determine overall status const status = service === 'running' && credentials !== 'missing' && anyChannelConfigured && registeredGroups > 0 ? 'success' : 'failed'; logger.info({ status, channelAuth }, 'Verification complete'); emitStatus('VERIFY', { SERVICE: service, CONTAINER_RUNTIME: containerRuntime, CREDENTIALS: credentials, CONFIGURED_CHANNELS: configuredChannels.join(','), CHANNEL_AUTH: JSON.stringify(channelAuth), REGISTERED_GROUPS: registeredGroups, MOUNT_ALLOWLIST: mountAllowlist, STATUS: status, LOG: 'logs/setup.log', }); if (status === 'failed') process.exit(1); } ================================================ FILE: setup.sh ================================================ #!/bin/bash set -euo pipefail # setup.sh — Bootstrap script for NanoClaw # Handles Node.js/npm setup, then hands off to the Node.js setup modules. # This is the only bash script in the setup flow. PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_FILE="$PROJECT_ROOT/logs/setup.log" mkdir -p "$PROJECT_ROOT/logs" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; } # --- Platform detection --- detect_platform() { local uname_s uname_s=$(uname -s) case "$uname_s" in Darwin*) PLATFORM="macos" ;; Linux*) PLATFORM="linux" ;; *) PLATFORM="unknown" ;; esac IS_WSL="false" if [ "$PLATFORM" = "linux" ] && [ -f /proc/version ]; then if grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null; then IS_WSL="true" fi fi IS_ROOT="false" if [ "$(id -u)" -eq 0 ]; then IS_ROOT="true" fi log "Platform: $PLATFORM, WSL: $IS_WSL, Root: $IS_ROOT" } # --- Node.js check --- check_node() { NODE_OK="false" NODE_VERSION="not_found" NODE_PATH_FOUND="" if command -v node >/dev/null 2>&1; then NODE_VERSION=$(node --version 2>/dev/null | sed 's/^v//') NODE_PATH_FOUND=$(command -v node) local major major=$(echo "$NODE_VERSION" | cut -d. -f1) if [ "$major" -ge 20 ] 2>/dev/null; then NODE_OK="true" fi log "Node $NODE_VERSION at $NODE_PATH_FOUND (major=$major, ok=$NODE_OK)" else log "Node not found" fi } # --- npm install --- install_deps() { DEPS_OK="false" NATIVE_OK="false" if [ "$NODE_OK" = "false" ]; then log "Skipping npm install — Node not available" return fi cd "$PROJECT_ROOT" # npm install with --unsafe-perm if root (needed for native modules) local npm_flags="" if [ "$IS_ROOT" = "true" ]; then npm_flags="--unsafe-perm" log "Running as root, using --unsafe-perm" fi log "Running npm ci $npm_flags" if npm ci $npm_flags >> "$LOG_FILE" 2>&1; then DEPS_OK="true" log "npm install succeeded" else log "npm install failed" return fi # Verify native module (better-sqlite3) log "Verifying native modules" if node -e "require('better-sqlite3')" >> "$LOG_FILE" 2>&1; then NATIVE_OK="true" log "better-sqlite3 loads OK" else log "better-sqlite3 failed to load" fi } # --- Build tools check --- check_build_tools() { HAS_BUILD_TOOLS="false" if [ "$PLATFORM" = "macos" ]; then if xcode-select -p >/dev/null 2>&1; then HAS_BUILD_TOOLS="true" fi elif [ "$PLATFORM" = "linux" ]; then if command -v gcc >/dev/null 2>&1 && command -v make >/dev/null 2>&1; then HAS_BUILD_TOOLS="true" fi fi log "Build tools: $HAS_BUILD_TOOLS" } # --- Main --- log "=== Bootstrap started ===" detect_platform check_node install_deps check_build_tools # Emit status block STATUS="success" if [ "$NODE_OK" = "false" ]; then STATUS="node_missing" elif [ "$DEPS_OK" = "false" ]; then STATUS="deps_failed" elif [ "$NATIVE_OK" = "false" ]; then STATUS="native_failed" fi cat < { // Note: registry is shared module state across tests in this file. // Tests are ordered to account for cumulative registrations. it('getChannelFactory returns undefined for unknown channel', () => { expect(getChannelFactory('nonexistent')).toBeUndefined(); }); it('registerChannel and getChannelFactory round-trip', () => { const factory = () => null; registerChannel('test-channel', factory); expect(getChannelFactory('test-channel')).toBe(factory); }); it('getRegisteredChannelNames includes registered channels', () => { registerChannel('another-channel', () => null); const names = getRegisteredChannelNames(); expect(names).toContain('test-channel'); expect(names).toContain('another-channel'); }); it('later registration overwrites earlier one', () => { const factory1 = () => null; const factory2 = () => null; registerChannel('overwrite-test', factory1); registerChannel('overwrite-test', factory2); expect(getChannelFactory('overwrite-test')).toBe(factory2); }); }); ================================================ FILE: src/channels/registry.ts ================================================ import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup, } from '../types.js'; export interface ChannelOpts { onMessage: OnInboundMessage; onChatMetadata: OnChatMetadata; registeredGroups: () => Record; } export type ChannelFactory = (opts: ChannelOpts) => Channel | null; const registry = new Map(); export function registerChannel(name: string, factory: ChannelFactory): void { registry.set(name, factory); } export function getChannelFactory(name: string): ChannelFactory | undefined { return registry.get(name); } export function getRegisteredChannelNames(): string[] { return [...registry.keys()]; } ================================================ FILE: src/config.ts ================================================ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; // Read config values from .env (falls back to process.env). // Secrets (API keys, tokens) are NOT read here — they are loaded only // by the credential proxy (credential-proxy.ts), never exposed to containers. const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; export const ASSISTANT_HAS_OWN_NUMBER = (process.env.ASSISTANT_HAS_OWN_NUMBER || envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true'; export const POLL_INTERVAL = 2000; export const SCHEDULER_POLL_INTERVAL = 60000; // Absolute paths needed for container mounts const PROJECT_ROOT = process.cwd(); const HOME_DIR = process.env.HOME || os.homedir(); // Mount security: allowlist stored OUTSIDE project root, never mounted into containers export const MOUNT_ALLOWLIST_PATH = path.join( HOME_DIR, '.config', 'nanoclaw', 'mount-allowlist.json', ); export const SENDER_ALLOWLIST_PATH = path.join( HOME_DIR, '.config', 'nanoclaw', 'sender-allowlist.json', ); export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store'); export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups'); export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest'; export const CONTAINER_TIMEOUT = parseInt( process.env.CONTAINER_TIMEOUT || '1800000', 10, ); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt( process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10, ); // 10MB default export const CREDENTIAL_PROXY_PORT = parseInt( process.env.CREDENTIAL_PROXY_PORT || '3001', 10, ); export const IPC_POLL_INTERVAL = 1000; export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result export const MAX_CONCURRENT_CONTAINERS = Math.max( 1, parseInt(process.env.MAX_CONCURRENT_CONTAINERS || '5', 10) || 5, ); function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export const TRIGGER_PATTERN = new RegExp( `^@${escapeRegex(ASSISTANT_NAME)}\\b`, 'i', ); // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default export const TIMEZONE = process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone; ================================================ FILE: src/container-runner.test.ts ================================================ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { EventEmitter } from 'events'; import { PassThrough } from 'stream'; // Sentinel markers must match container-runner.ts const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; // Mock config vi.mock('./config.js', () => ({ CONTAINER_IMAGE: 'nanoclaw-agent:latest', CONTAINER_MAX_OUTPUT_SIZE: 10485760, CONTAINER_TIMEOUT: 1800000, // 30min CREDENTIAL_PROXY_PORT: 3001, DATA_DIR: '/tmp/nanoclaw-test-data', GROUPS_DIR: '/tmp/nanoclaw-test-groups', IDLE_TIMEOUT: 1800000, // 30min TIMEZONE: 'America/Los_Angeles', })); // Mock logger vi.mock('./logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); // Mock fs vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, default: { ...actual, existsSync: vi.fn(() => false), mkdirSync: vi.fn(), writeFileSync: vi.fn(), readFileSync: vi.fn(() => ''), readdirSync: vi.fn(() => []), statSync: vi.fn(() => ({ isDirectory: () => false })), copyFileSync: vi.fn(), }, }; }); // Mock mount-security vi.mock('./mount-security.js', () => ({ validateAdditionalMounts: vi.fn(() => []), })); // Create a controllable fake ChildProcess function createFakeProcess() { const proc = new EventEmitter() as EventEmitter & { stdin: PassThrough; stdout: PassThrough; stderr: PassThrough; kill: ReturnType; pid: number; }; proc.stdin = new PassThrough(); proc.stdout = new PassThrough(); proc.stderr = new PassThrough(); proc.kill = vi.fn(); proc.pid = 12345; return proc; } let fakeProc: ReturnType; // Mock child_process.spawn vi.mock('child_process', async () => { const actual = await vi.importActual('child_process'); return { ...actual, spawn: vi.fn(() => fakeProc), exec: vi.fn( (_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => { if (cb) cb(null); return new EventEmitter(); }, ), }; }); import { runContainerAgent, ContainerOutput } from './container-runner.js'; import type { RegisteredGroup } from './types.js'; const testGroup: RegisteredGroup = { name: 'Test Group', folder: 'test-group', trigger: '@Andy', added_at: new Date().toISOString(), }; const testInput = { prompt: 'Hello', groupFolder: 'test-group', chatJid: 'test@g.us', isMain: false, }; function emitOutputMarker( proc: ReturnType, output: ContainerOutput, ) { const json = JSON.stringify(output); proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`); } describe('container-runner timeout behavior', () => { beforeEach(() => { vi.useFakeTimers(); fakeProc = createFakeProcess(); }); afterEach(() => { vi.useRealTimers(); }); it('timeout after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runContainerAgent( testGroup, testInput, () => {}, onOutput, ); // Emit output with a result emitOutputMarker(fakeProc, { status: 'success', result: 'Here is my response', newSessionId: 'session-123', }); // Let output processing settle await vi.advanceTimersByTimeAsync(10); // Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms) await vi.advanceTimersByTimeAsync(1830000); // Emit close event (as if container was stopped by the timeout) fakeProc.emit('close', 137); // Let the promise resolve await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-123'); expect(onOutput).toHaveBeenCalledWith( expect.objectContaining({ result: 'Here is my response' }), ); }); it('timeout with no output resolves as error', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runContainerAgent( testGroup, testInput, () => {}, onOutput, ); // No output emitted — fire the hard timeout await vi.advanceTimersByTimeAsync(1830000); // Emit close event fakeProc.emit('close', 137); await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('error'); expect(result.error).toContain('timed out'); expect(onOutput).not.toHaveBeenCalled(); }); it('normal exit after output resolves as success', async () => { const onOutput = vi.fn(async () => {}); const resultPromise = runContainerAgent( testGroup, testInput, () => {}, onOutput, ); // Emit output emitOutputMarker(fakeProc, { status: 'success', result: 'Done', newSessionId: 'session-456', }); await vi.advanceTimersByTimeAsync(10); // Normal exit (no timeout) fakeProc.emit('close', 0); await vi.advanceTimersByTimeAsync(10); const result = await resultPromise; expect(result.status).toBe('success'); expect(result.newSessionId).toBe('session-456'); }); }); ================================================ FILE: src/container-runner.ts ================================================ /** * Container Runner for NanoClaw * Spawns agent execution in containers and handles IPC */ import { ChildProcess, exec, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, CREDENTIAL_PROXY_PORT, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, TIMEZONE, } from './config.js'; import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; import { logger } from './logger.js'; import { CONTAINER_HOST_GATEWAY, CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer, } from './container-runtime.js'; import { detectAuthMode } from './credential-proxy.js'; import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; // Sentinel markers for robust output parsing (must match agent-runner) const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---'; export interface ContainerInput { prompt: string; sessionId?: string; groupFolder: string; chatJid: string; isMain: boolean; isScheduledTask?: boolean; assistantName?: string; } export interface ContainerOutput { status: 'success' | 'error'; result: string | null; newSessionId?: string; error?: string; } interface VolumeMount { hostPath: string; containerPath: string; readonly: boolean; } function buildVolumeMounts( group: RegisteredGroup, isMain: boolean, ): VolumeMount[] { const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); const groupDir = resolveGroupFolderPath(group.folder); if (isMain) { // Main gets the project root read-only. Writable paths the agent needs // (group folder, IPC, .claude/) are mounted separately below. // Read-only prevents the agent from modifying host application code // (src/, dist/, package.json, etc.) which would bypass the sandbox // entirely on next restart. mounts.push({ hostPath: projectRoot, containerPath: '/workspace/project', readonly: true, }); // Shadow .env so the agent cannot read secrets from the mounted project root. // Credentials are injected by the credential proxy, never exposed to containers. const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { mounts.push({ hostPath: '/dev/null', containerPath: '/workspace/project/.env', readonly: true, }); } // Main also gets its group folder as the working directory mounts.push({ hostPath: groupDir, containerPath: '/workspace/group', readonly: false, }); } else { // Other groups only get their own folder mounts.push({ hostPath: groupDir, containerPath: '/workspace/group', readonly: false, }); // Global memory directory (read-only for non-main) // Only directory mounts are supported, not file mounts const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: true, }); } } // Per-group Claude sessions directory (isolated from other groups) // Each group gets their own .claude/ to prevent cross-group session access const groupSessionsDir = path.join( DATA_DIR, 'sessions', group.folder, '.claude', ); fs.mkdirSync(groupSessionsDir, { recursive: true }); const settingsFile = path.join(groupSessionsDir, 'settings.json'); if (!fs.existsSync(settingsFile)) { fs.writeFileSync( settingsFile, JSON.stringify( { env: { // Enable agent swarms (subagent orchestration) // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', // Load CLAUDE.md from additional mounted directories // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', // Enable Claude's memory feature (persists user preferences between sessions) // https://code.claude.com/docs/en/memory#manage-auto-memory CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, }, null, 2, ) + '\n', ); } // Sync skills from container/skills/ into each group's .claude/skills/ const skillsSrc = path.join(process.cwd(), 'container', 'skills'); const skillsDst = path.join(groupSessionsDir, 'skills'); if (fs.existsSync(skillsSrc)) { for (const skillDir of fs.readdirSync(skillsSrc)) { const srcDir = path.join(skillsSrc, skillDir); if (!fs.statSync(srcDir).isDirectory()) continue; const dstDir = path.join(skillsDst, skillDir); fs.cpSync(srcDir, dstDir, { recursive: true }); } } mounts.push({ hostPath: groupSessionsDir, containerPath: '/home/node/.claude', readonly: false, }); // Per-group IPC namespace: each group gets its own IPC directory // This prevents cross-group privilege escalation via IPC const groupIpcDir = resolveGroupIpcPath(group.folder); fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); mounts.push({ hostPath: groupIpcDir, containerPath: '/workspace/ipc', readonly: false, }); // Copy agent-runner source into a per-group writable location so agents // can customize it (add tools, change behavior) without affecting other // groups. Recompiled on container startup via entrypoint.sh. const agentRunnerSrc = path.join( projectRoot, 'container', 'agent-runner', 'src', ); const groupAgentRunnerDir = path.join( DATA_DIR, 'sessions', group.folder, 'agent-runner-src', ); if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); } mounts.push({ hostPath: groupAgentRunnerDir, containerPath: '/app/src', readonly: false, }); // Additional mounts validated against external allowlist (tamper-proof from containers) if (group.containerConfig?.additionalMounts) { const validatedMounts = validateAdditionalMounts( group.containerConfig.additionalMounts, group.name, isMain, ); mounts.push(...validatedMounts); } return mounts; } function buildContainerArgs( mounts: VolumeMount[], containerName: string, ): string[] { const args: string[] = ['run', '-i', '--rm', '--name', containerName]; // Pass host timezone so container's local time matches the user's args.push('-e', `TZ=${TIMEZONE}`); // Route API traffic through the credential proxy (containers never see real secrets) args.push( '-e', `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, ); // Mirror the host's auth method with a placeholder value. // API key mode: SDK sends x-api-key, proxy replaces with real key. // OAuth mode: SDK exchanges placeholder token for temp API key, // proxy injects real OAuth token on that exchange request. const authMode = detectAuthMode(); if (authMode === 'api-key') { args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); } else { args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); } // Runtime-specific args for host gateway resolution args.push(...hostGatewayArgs()); // Run as host user so bind-mounted files are accessible. // Skip when running as root (uid 0), as the container's node user (uid 1000), // or when getuid is unavailable (native Windows without WSL). const hostUid = process.getuid?.(); const hostGid = process.getgid?.(); if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { args.push('--user', `${hostUid}:${hostGid}`); args.push('-e', 'HOME=/home/node'); } for (const mount of mounts) { if (mount.readonly) { args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); } else { args.push('-v', `${mount.hostPath}:${mount.containerPath}`); } } args.push(CONTAINER_IMAGE); return args; } export async function runContainerAgent( group: RegisteredGroup, input: ContainerInput, onProcess: (proc: ChildProcess, containerName: string) => void, onOutput?: (output: ContainerOutput) => Promise, ): Promise { const startTime = Date.now(); const groupDir = resolveGroupFolderPath(group.folder); fs.mkdirSync(groupDir, { recursive: true }); const mounts = buildVolumeMounts(group, input.isMain); const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-'); const containerName = `nanoclaw-${safeName}-${Date.now()}`; const containerArgs = buildContainerArgs(mounts, containerName); logger.debug( { group: group.name, containerName, mounts: mounts.map( (m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, ), containerArgs: containerArgs.join(' '), }, 'Container mount configuration', ); logger.info( { group: group.name, containerName, mountCount: mounts.length, isMain: input.isMain, }, 'Spawning container agent', ); const logsDir = path.join(groupDir, 'logs'); fs.mkdirSync(logsDir, { recursive: true }); return new Promise((resolve) => { const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, { stdio: ['pipe', 'pipe', 'pipe'], }); onProcess(container, containerName); let stdout = ''; let stderr = ''; let stdoutTruncated = false; let stderrTruncated = false; container.stdin.write(JSON.stringify(input)); container.stdin.end(); // Streaming output: parse OUTPUT_START/END marker pairs as they arrive let parseBuffer = ''; let newSessionId: string | undefined; let outputChain = Promise.resolve(); container.stdout.on('data', (data) => { const chunk = data.toString(); // Always accumulate for logging if (!stdoutTruncated) { const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length; if (chunk.length > remaining) { stdout += chunk.slice(0, remaining); stdoutTruncated = true; logger.warn( { group: group.name, size: stdout.length }, 'Container stdout truncated due to size limit', ); } else { stdout += chunk; } } // Stream-parse for output markers if (onOutput) { parseBuffer += chunk; let startIdx: number; while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) { const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx); if (endIdx === -1) break; // Incomplete pair, wait for more data const jsonStr = parseBuffer .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) .trim(); parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length); try { const parsed: ContainerOutput = JSON.parse(jsonStr); if (parsed.newSessionId) { newSessionId = parsed.newSessionId; } hadStreamingOutput = true; // Activity detected — reset the hard timeout resetTimeout(); // Call onOutput for all markers (including null results) // so idle timers start even for "silent" query completions. outputChain = outputChain.then(() => onOutput(parsed)); } catch (err) { logger.warn( { group: group.name, error: err }, 'Failed to parse streamed output chunk', ); } } } }); container.stderr.on('data', (data) => { const chunk = data.toString(); const lines = chunk.trim().split('\n'); for (const line of lines) { if (line) logger.debug({ container: group.folder }, line); } // Don't reset timeout on stderr — SDK writes debug logs continuously. // Timeout only resets on actual output (OUTPUT_MARKER in stdout). if (stderrTruncated) return; const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length; if (chunk.length > remaining) { stderr += chunk.slice(0, remaining); stderrTruncated = true; logger.warn( { group: group.name, size: stderr.length }, 'Container stderr truncated due to size limit', ); } else { stderr += chunk; } }); let timedOut = false; let hadStreamingOutput = false; const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT; // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the // graceful _close sentinel has time to trigger before the hard kill fires. const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000); const killOnTimeout = () => { timedOut = true; logger.error( { group: group.name, containerName }, 'Container timeout, stopping gracefully', ); exec(stopContainer(containerName), { timeout: 15000 }, (err) => { if (err) { logger.warn( { group: group.name, containerName, err }, 'Graceful stop failed, force killing', ); container.kill('SIGKILL'); } }); }; let timeout = setTimeout(killOnTimeout, timeoutMs); // Reset the timeout whenever there's activity (streaming output) const resetTimeout = () => { clearTimeout(timeout); timeout = setTimeout(killOnTimeout, timeoutMs); }; container.on('close', (code) => { clearTimeout(timeout); const duration = Date.now() - startTime; if (timedOut) { const ts = new Date().toISOString().replace(/[:.]/g, '-'); const timeoutLog = path.join(logsDir, `container-${ts}.log`); fs.writeFileSync( timeoutLog, [ `=== Container Run Log (TIMEOUT) ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, `Container: ${containerName}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, `Had Streaming Output: ${hadStreamingOutput}`, ].join('\n'), ); // Timeout after output = idle cleanup, not failure. // The agent already sent its response; this is just the // container being reaped after the idle period expired. if (hadStreamingOutput) { logger.info( { group: group.name, containerName, duration, code }, 'Container timed out after output (idle cleanup)', ); outputChain.then(() => { resolve({ status: 'success', result: null, newSessionId, }); }); return; } logger.error( { group: group.name, containerName, duration, code }, 'Container timed out with no output', ); resolve({ status: 'error', result: null, error: `Container timed out after ${configTimeout}ms`, }); return; } const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const logFile = path.join(logsDir, `container-${timestamp}.log`); const isVerbose = process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace'; const logLines = [ `=== Container Run Log ===`, `Timestamp: ${new Date().toISOString()}`, `Group: ${group.name}`, `IsMain: ${input.isMain}`, `Duration: ${duration}ms`, `Exit Code: ${code}`, `Stdout Truncated: ${stdoutTruncated}`, `Stderr Truncated: ${stderrTruncated}`, ``, ]; const isError = code !== 0; if (isVerbose || isError) { // On error, log input metadata only — not the full prompt. // Full input is only included at verbose level to avoid // persisting user conversation content on every non-zero exit. if (isVerbose) { logLines.push( `=== Input ===`, JSON.stringify(input, null, 2), ``, ); } else { logLines.push( `=== Input Summary ===`, `Prompt length: ${input.prompt.length} chars`, `Session ID: ${input.sessionId || 'new'}`, ``, ); } logLines.push( `=== Container Args ===`, containerArgs.join(' '), ``, `=== Mounts ===`, mounts .map( (m) => `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`, ) .join('\n'), ``, `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`, stderr, ``, `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`, stdout, ); } else { logLines.push( `=== Input Summary ===`, `Prompt length: ${input.prompt.length} chars`, `Session ID: ${input.sessionId || 'new'}`, ``, `=== Mounts ===`, mounts .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`) .join('\n'), ``, ); } fs.writeFileSync(logFile, logLines.join('\n')); logger.debug({ logFile, verbose: isVerbose }, 'Container log written'); if (code !== 0) { logger.error( { group: group.name, code, duration, stderr, stdout, logFile, }, 'Container exited with error', ); resolve({ status: 'error', result: null, error: `Container exited with code ${code}: ${stderr.slice(-200)}`, }); return; } // Streaming mode: wait for output chain to settle, return completion marker if (onOutput) { outputChain.then(() => { logger.info( { group: group.name, duration, newSessionId }, 'Container completed (streaming mode)', ); resolve({ status: 'success', result: null, newSessionId, }); }); return; } // Legacy mode: parse the last output marker pair from accumulated stdout try { // Extract JSON between sentinel markers for robust parsing const startIdx = stdout.indexOf(OUTPUT_START_MARKER); const endIdx = stdout.indexOf(OUTPUT_END_MARKER); let jsonLine: string; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { jsonLine = stdout .slice(startIdx + OUTPUT_START_MARKER.length, endIdx) .trim(); } else { // Fallback: last non-empty line (backwards compatibility) const lines = stdout.trim().split('\n'); jsonLine = lines[lines.length - 1]; } const output: ContainerOutput = JSON.parse(jsonLine); logger.info( { group: group.name, duration, status: output.status, hasResult: !!output.result, }, 'Container completed', ); resolve(output); } catch (err) { logger.error( { group: group.name, stdout, stderr, error: err, }, 'Failed to parse container output', ); resolve({ status: 'error', result: null, error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`, }); } }); container.on('error', (err) => { clearTimeout(timeout); logger.error( { group: group.name, containerName, error: err }, 'Container spawn error', ); resolve({ status: 'error', result: null, error: `Container spawn error: ${err.message}`, }); }); }); } export function writeTasksSnapshot( groupFolder: string, isMain: boolean, tasks: Array<{ id: string; groupFolder: string; prompt: string; schedule_type: string; schedule_value: string; status: string; next_run: string | null; }>, ): void { // Write filtered tasks to the group's IPC directory const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); // Main sees all tasks, others only see their own const filteredTasks = isMain ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); } export interface AvailableGroup { jid: string; name: string; lastActivity: string; isRegistered: boolean; } /** * Write available groups snapshot for the container to read. * Only main group can see all available groups (for activation). * Non-main groups only see their own registration status. */ export function writeGroupsSnapshot( groupFolder: string, isMain: boolean, groups: AvailableGroup[], registeredJids: Set, ): void { const groupIpcDir = resolveGroupIpcPath(groupFolder); fs.mkdirSync(groupIpcDir, { recursive: true }); // Main sees all groups; others see nothing (they can't activate groups) const visibleGroups = isMain ? groups : []; const groupsFile = path.join(groupIpcDir, 'available_groups.json'); fs.writeFileSync( groupsFile, JSON.stringify( { groups: visibleGroups, lastSync: new Date().toISOString(), }, null, 2, ), ); } ================================================ FILE: src/container-runtime.test.ts ================================================ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock logger vi.mock('./logger.js', () => ({ logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, })); // Mock child_process — store the mock fn so tests can configure it const mockExecSync = vi.fn(); vi.mock('child_process', () => ({ execSync: (...args: unknown[]) => mockExecSync(...args), })); import { CONTAINER_RUNTIME_BIN, readonlyMountArgs, stopContainer, ensureContainerRuntimeRunning, cleanupOrphans, } from './container-runtime.js'; import { logger } from './logger.js'; beforeEach(() => { vi.clearAllMocks(); }); // --- Pure functions --- describe('readonlyMountArgs', () => { it('returns -v flag with :ro suffix', () => { const args = readonlyMountArgs('/host/path', '/container/path'); expect(args).toEqual(['-v', '/host/path:/container/path:ro']); }); }); describe('stopContainer', () => { it('returns stop command using CONTAINER_RUNTIME_BIN', () => { expect(stopContainer('nanoclaw-test-123')).toBe( `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`, ); }); }); // --- ensureContainerRuntimeRunning --- describe('ensureContainerRuntimeRunning', () => { it('does nothing when runtime is already running', () => { mockExecSync.mockReturnValueOnce(''); ensureContainerRuntimeRunning(); expect(mockExecSync).toHaveBeenCalledTimes(1); expect(mockExecSync).toHaveBeenCalledWith(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000, }); expect(logger.debug).toHaveBeenCalledWith( 'Container runtime already running', ); }); it('throws when docker info fails', () => { mockExecSync.mockImplementationOnce(() => { throw new Error('Cannot connect to the Docker daemon'); }); expect(() => ensureContainerRuntimeRunning()).toThrow( 'Container runtime is required but failed to start', ); expect(logger.error).toHaveBeenCalled(); }); }); // --- cleanupOrphans --- describe('cleanupOrphans', () => { it('stops orphaned nanoclaw containers', () => { // docker ps returns container names, one per line mockExecSync.mockReturnValueOnce( 'nanoclaw-group1-111\nnanoclaw-group2-222\n', ); // stop calls succeed mockExecSync.mockReturnValue(''); cleanupOrphans(); // ps + 2 stop calls expect(mockExecSync).toHaveBeenCalledTimes(3); expect(mockExecSync).toHaveBeenNthCalledWith( 2, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group1-111`, { stdio: 'pipe' }, ); expect(mockExecSync).toHaveBeenNthCalledWith( 3, `${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-group2-222`, { stdio: 'pipe' }, ); expect(logger.info).toHaveBeenCalledWith( { count: 2, names: ['nanoclaw-group1-111', 'nanoclaw-group2-222'] }, 'Stopped orphaned containers', ); }); it('does nothing when no orphans exist', () => { mockExecSync.mockReturnValueOnce(''); cleanupOrphans(); expect(mockExecSync).toHaveBeenCalledTimes(1); expect(logger.info).not.toHaveBeenCalled(); }); it('warns and continues when ps fails', () => { mockExecSync.mockImplementationOnce(() => { throw new Error('docker not available'); }); cleanupOrphans(); // should not throw expect(logger.warn).toHaveBeenCalledWith( expect.objectContaining({ err: expect.any(Error) }), 'Failed to clean up orphaned containers', ); }); it('continues stopping remaining containers when one stop fails', () => { mockExecSync.mockReturnValueOnce('nanoclaw-a-1\nnanoclaw-b-2\n'); // First stop fails mockExecSync.mockImplementationOnce(() => { throw new Error('already stopped'); }); // Second stop succeeds mockExecSync.mockReturnValueOnce(''); cleanupOrphans(); // should not throw expect(mockExecSync).toHaveBeenCalledTimes(3); expect(logger.info).toHaveBeenCalledWith( { count: 2, names: ['nanoclaw-a-1', 'nanoclaw-b-2'] }, 'Stopped orphaned containers', ); }); }); ================================================ FILE: src/container-runtime.ts ================================================ /** * Container runtime abstraction for NanoClaw. * All runtime-specific logic lives here so swapping runtimes means changing one file. */ import { execSync } from 'child_process'; import fs from 'fs'; import os from 'os'; import { logger } from './logger.js'; /** The container runtime binary name. */ export const CONTAINER_RUNTIME_BIN = 'docker'; /** Hostname containers use to reach the host machine. */ export const CONTAINER_HOST_GATEWAY = 'host.docker.internal'; /** * Address the credential proxy binds to. * Docker Desktop (macOS): 127.0.0.1 — the VM routes host.docker.internal to loopback. * Docker (Linux): bind to the docker0 bridge IP so only containers can reach it, * falling back to 0.0.0.0 if the interface isn't found. */ export const PROXY_BIND_HOST = process.env.CREDENTIAL_PROXY_HOST || detectProxyBindHost(); function detectProxyBindHost(): string { if (os.platform() === 'darwin') return '127.0.0.1'; // WSL uses Docker Desktop (same VM routing as macOS) — loopback is correct. // Check /proc filesystem, not env vars — WSL_DISTRO_NAME isn't set under systemd. if (fs.existsSync('/proc/sys/fs/binfmt_misc/WSLInterop')) return '127.0.0.1'; // Bare-metal Linux: bind to the docker0 bridge IP instead of 0.0.0.0 const ifaces = os.networkInterfaces(); const docker0 = ifaces['docker0']; if (docker0) { const ipv4 = docker0.find((a) => a.family === 'IPv4'); if (ipv4) return ipv4.address; } return '0.0.0.0'; } /** CLI args needed for the container to resolve the host gateway. */ export function hostGatewayArgs(): string[] { // On Linux, host.docker.internal isn't built-in — add it explicitly if (os.platform() === 'linux') { return ['--add-host=host.docker.internal:host-gateway']; } return []; } /** Returns CLI args for a readonly bind mount. */ export function readonlyMountArgs( hostPath: string, containerPath: string, ): string[] { return ['-v', `${hostPath}:${containerPath}:ro`]; } /** Returns the shell command to stop a container by name. */ export function stopContainer(name: string): string { return `${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`; } /** Ensure the container runtime is running, starting it if needed. */ export function ensureContainerRuntimeRunning(): void { try { execSync(`${CONTAINER_RUNTIME_BIN} info`, { stdio: 'pipe', timeout: 10000, }); logger.debug('Container runtime already running'); } catch (err) { logger.error({ err }, 'Failed to reach container runtime'); console.error( '\n╔════════════════════════════════════════════════════════════════╗', ); console.error( '║ FATAL: Container runtime failed to start ║', ); console.error( '║ ║', ); console.error( '║ Agents cannot run without a container runtime. To fix: ║', ); console.error( '║ 1. Ensure Docker is installed and running ║', ); console.error( '║ 2. Run: docker info ║', ); console.error( '║ 3. Restart NanoClaw ║', ); console.error( '╚════════════════════════════════════════════════════════════════╝\n', ); throw new Error('Container runtime is required but failed to start'); } } /** Kill orphaned NanoClaw containers from previous runs. */ export function cleanupOrphans(): void { try { const output = execSync( `${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }, ); const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { execSync(stopContainer(name), { stdio: 'pipe' }); } catch { /* already stopped */ } } if (orphans.length > 0) { logger.info( { count: orphans.length, names: orphans }, 'Stopped orphaned containers', ); } } catch (err) { logger.warn({ err }, 'Failed to clean up orphaned containers'); } } ================================================ FILE: src/credential-proxy.test.ts ================================================ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import http from 'http'; import type { AddressInfo } from 'net'; const mockEnv: Record = {}; vi.mock('./env.js', () => ({ readEnvFile: vi.fn(() => ({ ...mockEnv })), })); vi.mock('./logger.js', () => ({ logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }, })); import { startCredentialProxy } from './credential-proxy.js'; function makeRequest( port: number, options: http.RequestOptions, body = '', ): Promise<{ statusCode: number; body: string; headers: http.IncomingHttpHeaders; }> { return new Promise((resolve, reject) => { const req = http.request( { ...options, hostname: '127.0.0.1', port }, (res) => { const chunks: Buffer[] = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { resolve({ statusCode: res.statusCode!, body: Buffer.concat(chunks).toString(), headers: res.headers, }); }); }, ); req.on('error', reject); req.write(body); req.end(); }); } describe('credential-proxy', () => { let proxyServer: http.Server; let upstreamServer: http.Server; let proxyPort: number; let upstreamPort: number; let lastUpstreamHeaders: http.IncomingHttpHeaders; beforeEach(async () => { lastUpstreamHeaders = {}; upstreamServer = http.createServer((req, res) => { lastUpstreamHeaders = { ...req.headers }; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ ok: true })); }); await new Promise((resolve) => upstreamServer.listen(0, '127.0.0.1', resolve), ); upstreamPort = (upstreamServer.address() as AddressInfo).port; }); afterEach(async () => { await new Promise((r) => proxyServer?.close(() => r())); await new Promise((r) => upstreamServer?.close(() => r())); for (const key of Object.keys(mockEnv)) delete mockEnv[key]; }); async function startProxy(env: Record): Promise { Object.assign(mockEnv, env, { ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, }); proxyServer = await startCredentialProxy(0); return (proxyServer.address() as AddressInfo).port; } it('API-key mode injects x-api-key and strips placeholder', async () => { proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' }); await makeRequest( proxyPort, { method: 'POST', path: '/v1/messages', headers: { 'content-type': 'application/json', 'x-api-key': 'placeholder', }, }, '{}', ); expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key'); }); it('OAuth mode replaces Authorization when container sends one', async () => { proxyPort = await startProxy({ CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token', }); await makeRequest( proxyPort, { method: 'POST', path: '/api/oauth/claude_cli/create_api_key', headers: { 'content-type': 'application/json', authorization: 'Bearer placeholder', }, }, '{}', ); expect(lastUpstreamHeaders['authorization']).toBe( 'Bearer real-oauth-token', ); }); it('OAuth mode does not inject Authorization when container omits it', async () => { proxyPort = await startProxy({ CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token', }); // Post-exchange: container uses x-api-key only, no Authorization header await makeRequest( proxyPort, { method: 'POST', path: '/v1/messages', headers: { 'content-type': 'application/json', 'x-api-key': 'temp-key-from-exchange', }, }, '{}', ); expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange'); expect(lastUpstreamHeaders['authorization']).toBeUndefined(); }); it('strips hop-by-hop headers', async () => { proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' }); await makeRequest( proxyPort, { method: 'POST', path: '/v1/messages', headers: { 'content-type': 'application/json', connection: 'keep-alive', 'keep-alive': 'timeout=5', 'transfer-encoding': 'chunked', }, }, '{}', ); // Proxy strips client hop-by-hop headers. Node's HTTP client may re-add // its own Connection header (standard HTTP/1.1 behavior), but the client's // custom keep-alive and transfer-encoding must not be forwarded. expect(lastUpstreamHeaders['keep-alive']).toBeUndefined(); expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined(); }); it('returns 502 when upstream is unreachable', async () => { Object.assign(mockEnv, { ANTHROPIC_API_KEY: 'sk-ant-real-key', ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999', }); proxyServer = await startCredentialProxy(0); proxyPort = (proxyServer.address() as AddressInfo).port; const res = await makeRequest( proxyPort, { method: 'POST', path: '/v1/messages', headers: { 'content-type': 'application/json' }, }, '{}', ); expect(res.statusCode).toBe(502); expect(res.body).toBe('Bad Gateway'); }); }); ================================================ FILE: src/credential-proxy.ts ================================================ /** * Credential proxy for container isolation. * Containers connect here instead of directly to the Anthropic API. * The proxy injects real credentials so containers never see them. * * Two auth modes: * API key: Proxy injects x-api-key on every request. * OAuth: Container CLI exchanges its placeholder token for a temp * API key via /api/oauth/claude_cli/create_api_key. * Proxy injects real OAuth token on that exchange request; * subsequent requests carry the temp key which is valid as-is. */ import { createServer, Server } from 'http'; import { request as httpsRequest } from 'https'; import { request as httpRequest, RequestOptions } from 'http'; import { readEnvFile } from './env.js'; import { logger } from './logger.js'; export type AuthMode = 'api-key' | 'oauth'; export interface ProxyConfig { authMode: AuthMode; } export function startCredentialProxy( port: number, host = '127.0.0.1', ): Promise { const secrets = readEnvFile([ 'ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', ]); const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth'; const oauthToken = secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN; const upstreamUrl = new URL( secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com', ); const isHttps = upstreamUrl.protocol === 'https:'; const makeRequest = isHttps ? httpsRequest : httpRequest; return new Promise((resolve, reject) => { const server = createServer((req, res) => { const chunks: Buffer[] = []; req.on('data', (c) => chunks.push(c)); req.on('end', () => { const body = Buffer.concat(chunks); const headers: Record = { ...(req.headers as Record), host: upstreamUrl.host, 'content-length': body.length, }; // Strip hop-by-hop headers that must not be forwarded by proxies delete headers['connection']; delete headers['keep-alive']; delete headers['transfer-encoding']; if (authMode === 'api-key') { // API key mode: inject x-api-key on every request delete headers['x-api-key']; headers['x-api-key'] = secrets.ANTHROPIC_API_KEY; } else { // OAuth mode: replace placeholder Bearer token with the real one // only when the container actually sends an Authorization header // (exchange request + auth probes). Post-exchange requests use // x-api-key only, so they pass through without token injection. if (headers['authorization']) { delete headers['authorization']; if (oauthToken) { headers['authorization'] = `Bearer ${oauthToken}`; } } } const upstream = makeRequest( { hostname: upstreamUrl.hostname, port: upstreamUrl.port || (isHttps ? 443 : 80), path: req.url, method: req.method, headers, } as RequestOptions, (upRes) => { res.writeHead(upRes.statusCode!, upRes.headers); upRes.pipe(res); }, ); upstream.on('error', (err) => { logger.error( { err, url: req.url }, 'Credential proxy upstream error', ); if (!res.headersSent) { res.writeHead(502); res.end('Bad Gateway'); } }); upstream.write(body); upstream.end(); }); }); server.listen(port, host, () => { logger.info({ port, host, authMode }, 'Credential proxy started'); resolve(server); }); server.on('error', reject); }); } /** Detect which auth mode the host is configured for. */ export function detectAuthMode(): AuthMode { const secrets = readEnvFile(['ANTHROPIC_API_KEY']); return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth'; } ================================================ FILE: src/db.test.ts ================================================ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, deleteTask, getAllChats, getAllRegisteredGroups, getMessagesSince, getNewMessages, getTaskById, setRegisteredGroup, storeChatMetadata, storeMessage, updateTask, } from './db.js'; beforeEach(() => { _initTestDatabase(); }); // Helper to store a message using the normalized NewMessage interface function store(overrides: { id: string; chat_jid: string; sender: string; sender_name: string; content: string; timestamp: string; is_from_me?: boolean; }) { storeMessage({ id: overrides.id, chat_jid: overrides.chat_jid, sender: overrides.sender, sender_name: overrides.sender_name, content: overrides.content, timestamp: overrides.timestamp, is_from_me: overrides.is_from_me ?? false, }); } // --- storeMessage (NewMessage format) --- describe('storeMessage', () => { it('stores a message and retrieves it', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-1', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'hello world', timestamp: '2024-01-01T00:00:01.000Z', }); const messages = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', ); expect(messages).toHaveLength(1); expect(messages[0].id).toBe('msg-1'); expect(messages[0].sender).toBe('123@s.whatsapp.net'); expect(messages[0].sender_name).toBe('Alice'); expect(messages[0].content).toBe('hello world'); }); it('filters out empty content', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-2', chat_jid: 'group@g.us', sender: '111@s.whatsapp.net', sender_name: 'Dave', content: '', timestamp: '2024-01-01T00:00:04.000Z', }); const messages = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', ); expect(messages).toHaveLength(0); }); it('stores is_from_me flag', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-3', chat_jid: 'group@g.us', sender: 'me@s.whatsapp.net', sender_name: 'Me', content: 'my message', timestamp: '2024-01-01T00:00:05.000Z', is_from_me: true, }); // Message is stored (we can retrieve it — is_from_me doesn't affect retrieval) const messages = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', ); expect(messages).toHaveLength(1); }); it('upserts on duplicate id+chat_jid', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'msg-dup', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'original', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'msg-dup', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'updated', timestamp: '2024-01-01T00:00:01.000Z', }); const messages = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', ); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('updated'); }); }); // --- getMessagesSince --- describe('getMessagesSince', () => { beforeEach(() => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'm1', chat_jid: 'group@g.us', sender: 'Alice@s.whatsapp.net', sender_name: 'Alice', content: 'first', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'm2', chat_jid: 'group@g.us', sender: 'Bob@s.whatsapp.net', sender_name: 'Bob', content: 'second', timestamp: '2024-01-01T00:00:02.000Z', }); storeMessage({ id: 'm3', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', sender_name: 'Bot', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', is_bot_message: true, }); store({ id: 'm4', chat_jid: 'group@g.us', sender: 'Carol@s.whatsapp.net', sender_name: 'Carol', content: 'third', timestamp: '2024-01-01T00:00:04.000Z', }); }); it('returns messages after the given timestamp', () => { const msgs = getMessagesSince( 'group@g.us', '2024-01-01T00:00:02.000Z', 'Andy', ); // Should exclude m1, m2 (before/at timestamp), m3 (bot message) expect(msgs).toHaveLength(1); expect(msgs[0].content).toBe('third'); }); it('excludes bot messages via is_bot_message flag', () => { const msgs = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', ); const botMsgs = msgs.filter((m) => m.content === 'bot reply'); expect(botMsgs).toHaveLength(0); }); it('returns all non-bot messages when sinceTimestamp is empty', () => { const msgs = getMessagesSince('group@g.us', '', 'Andy'); // 3 user messages (bot message excluded) expect(msgs).toHaveLength(3); }); it('filters pre-migration bot messages via content prefix backstop', () => { // Simulate a message written before migration: has prefix but is_bot_message = 0 store({ id: 'm5', chat_jid: 'group@g.us', sender: 'Bot@s.whatsapp.net', sender_name: 'Bot', content: 'Andy: old bot reply', timestamp: '2024-01-01T00:00:05.000Z', }); const msgs = getMessagesSince( 'group@g.us', '2024-01-01T00:00:04.000Z', 'Andy', ); expect(msgs).toHaveLength(0); }); }); // --- getNewMessages --- describe('getNewMessages', () => { beforeEach(() => { storeChatMetadata('group1@g.us', '2024-01-01T00:00:00.000Z'); storeChatMetadata('group2@g.us', '2024-01-01T00:00:00.000Z'); store({ id: 'a1', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g1 msg1', timestamp: '2024-01-01T00:00:01.000Z', }); store({ id: 'a2', chat_jid: 'group2@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g2 msg1', timestamp: '2024-01-01T00:00:02.000Z', }); storeMessage({ id: 'a3', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'bot reply', timestamp: '2024-01-01T00:00:03.000Z', is_bot_message: true, }); store({ id: 'a4', chat_jid: 'group1@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: 'g1 msg2', timestamp: '2024-01-01T00:00:04.000Z', }); }); it('returns new messages across multiple groups', () => { const { messages, newTimestamp } = getNewMessages( ['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', ); // Excludes bot message, returns 3 user messages expect(messages).toHaveLength(3); expect(newTimestamp).toBe('2024-01-01T00:00:04.000Z'); }); it('filters by timestamp', () => { const { messages } = getNewMessages( ['group1@g.us', 'group2@g.us'], '2024-01-01T00:00:02.000Z', 'Andy', ); // Only g1 msg2 (after ts, not bot) expect(messages).toHaveLength(1); expect(messages[0].content).toBe('g1 msg2'); }); it('returns empty for no registered groups', () => { const { messages, newTimestamp } = getNewMessages([], '', 'Andy'); expect(messages).toHaveLength(0); expect(newTimestamp).toBe(''); }); }); // --- storeChatMetadata --- describe('storeChatMetadata', () => { it('stores chat with JID as default name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); const chats = getAllChats(); expect(chats).toHaveLength(1); expect(chats[0].jid).toBe('group@g.us'); expect(chats[0].name).toBe('group@g.us'); }); it('stores chat with explicit name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z', 'My Group'); const chats = getAllChats(); expect(chats[0].name).toBe('My Group'); }); it('updates name on subsequent call with name', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z', 'Updated Name'); const chats = getAllChats(); expect(chats).toHaveLength(1); expect(chats[0].name).toBe('Updated Name'); }); it('preserves newer timestamp on conflict', () => { storeChatMetadata('group@g.us', '2024-01-01T00:00:05.000Z'); storeChatMetadata('group@g.us', '2024-01-01T00:00:01.000Z'); const chats = getAllChats(); expect(chats[0].last_message_time).toBe('2024-01-01T00:00:05.000Z'); }); }); // --- Task CRUD --- describe('task CRUD', () => { it('creates and retrieves a task', () => { createTask({ id: 'task-1', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'do something', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: '2024-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); const task = getTaskById('task-1'); expect(task).toBeDefined(); expect(task!.prompt).toBe('do something'); expect(task!.status).toBe('active'); }); it('updates task status', () => { createTask({ id: 'task-2', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'test', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); updateTask('task-2', { status: 'paused' }); expect(getTaskById('task-2')!.status).toBe('paused'); }); it('deletes a task and its run logs', () => { createTask({ id: 'task-3', group_folder: 'main', chat_jid: 'group@g.us', prompt: 'delete me', schedule_type: 'once', schedule_value: '2024-06-01T00:00:00.000Z', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); deleteTask('task-3'); expect(getTaskById('task-3')).toBeUndefined(); }); }); // --- LIMIT behavior --- describe('message query LIMIT', () => { beforeEach(() => { storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z'); for (let i = 1; i <= 10; i++) { store({ id: `lim-${i}`, chat_jid: 'group@g.us', sender: 'user@s.whatsapp.net', sender_name: 'User', content: `message ${i}`, timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`, }); } }); it('getNewMessages caps to limit and returns most recent in chronological order', () => { const { messages, newTimestamp } = getNewMessages( ['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 3, ); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); // Chronological order preserved expect(messages[1].timestamp > messages[0].timestamp).toBe(true); // newTimestamp reflects latest returned row expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z'); }); it('getMessagesSince caps to limit and returns most recent in chronological order', () => { const messages = getMessagesSince( 'group@g.us', '2024-01-01T00:00:00.000Z', 'Andy', 3, ); expect(messages).toHaveLength(3); expect(messages[0].content).toBe('message 8'); expect(messages[2].content).toBe('message 10'); expect(messages[1].timestamp > messages[0].timestamp).toBe(true); }); it('returns all messages when count is under the limit', () => { const { messages } = getNewMessages( ['group@g.us'], '2024-01-01T00:00:00.000Z', 'Andy', 50, ); expect(messages).toHaveLength(10); }); }); // --- RegisteredGroup isMain round-trip --- describe('registered group isMain', () => { it('persists isMain=true through set/get round-trip', () => { setRegisteredGroup('main@s.whatsapp.net', { name: 'Main Chat', folder: 'whatsapp_main', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', isMain: true, }); const groups = getAllRegisteredGroups(); const group = groups['main@s.whatsapp.net']; expect(group).toBeDefined(); expect(group.isMain).toBe(true); expect(group.folder).toBe('whatsapp_main'); }); it('omits isMain for non-main groups', () => { setRegisteredGroup('group@g.us', { name: 'Family Chat', folder: 'whatsapp_family-chat', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }); const groups = getAllRegisteredGroups(); const group = groups['group@g.us']; expect(group).toBeDefined(); expect(group.isMain).toBeUndefined(); }); }); ================================================ FILE: src/db.ts ================================================ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { NewMessage, RegisteredGroup, ScheduledTask, TaskRunLog, } from './types.js'; let db: Database.Database; function createSchema(database: Database.Database): void { database.exec(` CREATE TABLE IF NOT EXISTS chats ( jid TEXT PRIMARY KEY, name TEXT, last_message_time TEXT, channel TEXT, is_group INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id TEXT, chat_jid TEXT, sender TEXT, sender_name TEXT, content TEXT, timestamp TEXT, is_from_me INTEGER, is_bot_message INTEGER DEFAULT 0, PRIMARY KEY (id, chat_jid), FOREIGN KEY (chat_jid) REFERENCES chats(jid) ); CREATE INDEX IF NOT EXISTS idx_timestamp ON messages(timestamp); CREATE TABLE IF NOT EXISTS scheduled_tasks ( id TEXT PRIMARY KEY, group_folder TEXT NOT NULL, chat_jid TEXT NOT NULL, prompt TEXT NOT NULL, schedule_type TEXT NOT NULL, schedule_value TEXT NOT NULL, next_run TEXT, last_run TEXT, last_result TEXT, status TEXT DEFAULT 'active', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_next_run ON scheduled_tasks(next_run); CREATE INDEX IF NOT EXISTS idx_status ON scheduled_tasks(status); CREATE TABLE IF NOT EXISTS task_run_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL, run_at TEXT NOT NULL, duration_ms INTEGER NOT NULL, status TEXT NOT NULL, result TEXT, error TEXT, FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ); CREATE INDEX IF NOT EXISTS idx_task_run_logs ON task_run_logs(task_id, run_at); CREATE TABLE IF NOT EXISTS router_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( group_folder TEXT PRIMARY KEY, session_id TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS registered_groups ( jid TEXT PRIMARY KEY, name TEXT NOT NULL, folder TEXT NOT NULL UNIQUE, trigger_pattern TEXT NOT NULL, added_at TEXT NOT NULL, container_config TEXT, requires_trigger INTEGER DEFAULT 1 ); `); // Add context_mode column if it doesn't exist (migration for existing DBs) try { database.exec( `ALTER TABLE scheduled_tasks ADD COLUMN context_mode TEXT DEFAULT 'isolated'`, ); } catch { /* column already exists */ } // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( `ALTER TABLE messages ADD COLUMN is_bot_message INTEGER DEFAULT 0`, ); // Backfill: mark existing bot messages that used the content prefix pattern database .prepare(`UPDATE messages SET is_bot_message = 1 WHERE content LIKE ?`) .run(`${ASSISTANT_NAME}:%`); } catch { /* column already exists */ } // Add is_main column if it doesn't exist (migration for existing DBs) try { database.exec( `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`, ); // Backfill: existing rows with folder = 'main' are the main group database.exec( `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`, ); } catch { /* column already exists */ } // Add channel and is_group columns if they don't exist (migration for existing DBs) try { database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`); database.exec(`ALTER TABLE chats ADD COLUMN is_group INTEGER DEFAULT 0`); // Backfill from JID patterns database.exec( `UPDATE chats SET channel = 'whatsapp', is_group = 1 WHERE jid LIKE '%@g.us'`, ); database.exec( `UPDATE chats SET channel = 'whatsapp', is_group = 0 WHERE jid LIKE '%@s.whatsapp.net'`, ); database.exec( `UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`, ); database.exec( `UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`, ); } catch { /* columns already exist */ } } export function initDatabase(): void { const dbPath = path.join(STORE_DIR, 'messages.db'); fs.mkdirSync(path.dirname(dbPath), { recursive: true }); db = new Database(dbPath); createSchema(db); // Migrate from JSON files if they exist migrateJsonState(); } /** @internal - for tests only. Creates a fresh in-memory database. */ export function _initTestDatabase(): void { db = new Database(':memory:'); createSchema(db); } /** * Store chat metadata only (no message content). * Used for all chats to enable group discovery without storing sensitive content. */ export function storeChatMetadata( chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean, ): void { const ch = channel ?? null; const group = isGroup === undefined ? null : isGroup ? 1 : 0; if (name) { // Update with name, preserving existing timestamp if newer db.prepare( ` INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name, last_message_time = MAX(last_message_time, excluded.last_message_time), channel = COALESCE(excluded.channel, channel), is_group = COALESCE(excluded.is_group, is_group) `, ).run(chatJid, name, timestamp, ch, group); } else { // Update timestamp only, preserve existing name if any db.prepare( ` INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) ON CONFLICT(jid) DO UPDATE SET last_message_time = MAX(last_message_time, excluded.last_message_time), channel = COALESCE(excluded.channel, channel), is_group = COALESCE(excluded.is_group, is_group) `, ).run(chatJid, chatJid, timestamp, ch, group); } } /** * Update chat name without changing timestamp for existing chats. * New chats get the current time as their initial timestamp. * Used during group metadata sync. */ export function updateChatName(chatJid: string, name: string): void { db.prepare( ` INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) ON CONFLICT(jid) DO UPDATE SET name = excluded.name `, ).run(chatJid, name, new Date().toISOString()); } export interface ChatInfo { jid: string; name: string; last_message_time: string; channel: string; is_group: number; } /** * Get all known chats, ordered by most recent activity. */ export function getAllChats(): ChatInfo[] { return db .prepare( ` SELECT jid, name, last_message_time, channel, is_group FROM chats ORDER BY last_message_time DESC `, ) .all() as ChatInfo[]; } /** * Get timestamp of last group metadata sync. */ export function getLastGroupSync(): string | null { // Store sync time in a special chat entry const row = db .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) .get() as { last_message_time: string } | undefined; return row?.last_message_time || null; } /** * Record that group metadata was synced. */ export function setLastGroupSync(): void { const now = new Date().toISOString(); db.prepare( `INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`, ).run(now); } /** * Store a message with full content. * Only call this for registered groups where message history is needed. */ export function storeMessage(msg: NewMessage): void { db.prepare( `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, msg.sender, msg.sender_name, msg.content, msg.timestamp, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0, ); } /** * Store a message directly. */ export function storeMessageDirect(msg: { id: string; chat_jid: string; sender: string; sender_name: string; content: string; timestamp: string; is_from_me: boolean; is_bot_message?: boolean; }): void { db.prepare( `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( msg.id, msg.chat_jid, msg.sender, msg.sender_name, msg.content, msg.timestamp, msg.is_from_me ? 1 : 0, msg.is_bot_message ? 1 : 0, ); } export function getNewMessages( jids: string[], lastTimestamp: string, botPrefix: string, limit: number = 200, ): { messages: NewMessage[]; newTimestamp: string } { if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; const placeholders = jids.map(() => '?').join(','); // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE timestamp > ? AND chat_jid IN (${placeholders}) AND is_bot_message = 0 AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? ) ORDER BY timestamp `; const rows = db .prepare(sql) .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; let newTimestamp = lastTimestamp; for (const row of rows) { if (row.timestamp > newTimestamp) newTimestamp = row.timestamp; } return { messages: rows, newTimestamp }; } export function getMessagesSince( chatJid: string, sinceTimestamp: string, botPrefix: string, limit: number = 200, ): NewMessage[] { // Filter bot messages using both the is_bot_message flag AND the content // prefix as a backstop for messages written before the migration ran. // Subquery takes the N most recent, outer query re-sorts chronologically. const sql = ` SELECT * FROM ( SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me FROM messages WHERE chat_jid = ? AND timestamp > ? AND is_bot_message = 0 AND content NOT LIKE ? AND content != '' AND content IS NOT NULL ORDER BY timestamp DESC LIMIT ? ) ORDER BY timestamp `; return db .prepare(sql) .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; } export function createTask( task: Omit, ): void { db.prepare( ` INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, task.prompt, task.schedule_type, task.schedule_value, task.context_mode || 'isolated', task.next_run, task.status, task.created_at, ); } export function getTaskById(id: string): ScheduledTask | undefined { return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as | ScheduledTask | undefined; } export function getTasksForGroup(groupFolder: string): ScheduledTask[] { return db .prepare( 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', ) .all(groupFolder) as ScheduledTask[]; } export function getAllTasks(): ScheduledTask[] { return db .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') .all() as ScheduledTask[]; } export function updateTask( id: string, updates: Partial< Pick< ScheduledTask, 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' > >, ): void { const fields: string[] = []; const values: unknown[] = []; if (updates.prompt !== undefined) { fields.push('prompt = ?'); values.push(updates.prompt); } if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); } if (updates.schedule_value !== undefined) { fields.push('schedule_value = ?'); values.push(updates.schedule_value); } if (updates.next_run !== undefined) { fields.push('next_run = ?'); values.push(updates.next_run); } if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); } if (fields.length === 0) return; values.push(id); db.prepare( `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, ).run(...values); } export function deleteTask(id: string): void { // Delete child records first (FK constraint) db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); } export function getDueTasks(): ScheduledTask[] { const now = new Date().toISOString(); return db .prepare( ` SELECT * FROM scheduled_tasks WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? ORDER BY next_run `, ) .all(now) as ScheduledTask[]; } export function updateTaskAfterRun( id: string, nextRun: string | null, lastResult: string, ): void { const now = new Date().toISOString(); db.prepare( ` UPDATE scheduled_tasks SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END WHERE id = ? `, ).run(nextRun, now, lastResult, nextRun, id); } export function logTaskRun(log: TaskRunLog): void { db.prepare( ` INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) VALUES (?, ?, ?, ?, ?, ?) `, ).run( log.task_id, log.run_at, log.duration_ms, log.status, log.result, log.error, ); } // --- Router state accessors --- export function getRouterState(key: string): string | undefined { const row = db .prepare('SELECT value FROM router_state WHERE key = ?') .get(key) as { value: string } | undefined; return row?.value; } export function setRouterState(key: string, value: string): void { db.prepare( 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', ).run(key, value); } // --- Session accessors --- export function getSession(groupFolder: string): string | undefined { const row = db .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') .get(groupFolder) as { session_id: string } | undefined; return row?.session_id; } export function setSession(groupFolder: string, sessionId: string): void { db.prepare( 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', ).run(groupFolder, sessionId); } export function getAllSessions(): Record { const rows = db .prepare('SELECT group_folder, session_id FROM sessions') .all() as Array<{ group_folder: string; session_id: string }>; const result: Record = {}; for (const row of rows) { result[row.group_folder] = row.session_id; } return result; } // --- Registered group accessors --- export function getRegisteredGroup( jid: string, ): (RegisteredGroup & { jid: string }) | undefined { const row = db .prepare('SELECT * FROM registered_groups WHERE jid = ?') .get(jid) as | { jid: string; name: string; folder: string; trigger_pattern: string; added_at: string; container_config: string | null; requires_trigger: number | null; is_main: number | null; } | undefined; if (!row) return undefined; if (!isValidGroupFolder(row.folder)) { logger.warn( { jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder', ); return undefined; } return { jid: row.jid, name: row.name, folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { if (!isValidGroupFolder(group.folder)) { throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); } db.prepare( `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, ).run( jid, group.name, group.folder, group.trigger, group.added_at, group.containerConfig ? JSON.stringify(group.containerConfig) : null, group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, group.isMain ? 1 : 0, ); } export function getAllRegisteredGroups(): Record { const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ jid: string; name: string; folder: string; trigger_pattern: string; added_at: string; container_config: string | null; requires_trigger: number | null; is_main: number | null; }>; const result: Record = {}; for (const row of rows) { if (!isValidGroupFolder(row.folder)) { logger.warn( { jid: row.jid, folder: row.folder }, 'Skipping registered group with invalid folder', ); continue; } result[row.jid] = { name: row.name, folder: row.folder, trigger: row.trigger_pattern, added_at: row.added_at, containerConfig: row.container_config ? JSON.parse(row.container_config) : undefined, requiresTrigger: row.requires_trigger === null ? undefined : row.requires_trigger === 1, isMain: row.is_main === 1 ? true : undefined, }; } return result; } // --- JSON migration --- function migrateJsonState(): void { const migrateFile = (filename: string) => { const filePath = path.join(DATA_DIR, filename); if (!fs.existsSync(filePath)) return null; try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); fs.renameSync(filePath, `${filePath}.migrated`); return data; } catch { return null; } }; // Migrate router_state.json const routerState = migrateFile('router_state.json') as { last_timestamp?: string; last_agent_timestamp?: Record; } | null; if (routerState) { if (routerState.last_timestamp) { setRouterState('last_timestamp', routerState.last_timestamp); } if (routerState.last_agent_timestamp) { setRouterState( 'last_agent_timestamp', JSON.stringify(routerState.last_agent_timestamp), ); } } // Migrate sessions.json const sessions = migrateFile('sessions.json') as Record< string, string > | null; if (sessions) { for (const [folder, sessionId] of Object.entries(sessions)) { setSession(folder, sessionId); } } // Migrate registered_groups.json const groups = migrateFile('registered_groups.json') as Record< string, RegisteredGroup > | null; if (groups) { for (const [jid, group] of Object.entries(groups)) { try { setRegisteredGroup(jid, group); } catch (err) { logger.warn( { jid, folder: group.folder, err }, 'Skipping migrated registered group with invalid folder', ); } } } } ================================================ FILE: src/env.ts ================================================ import fs from 'fs'; import path from 'path'; import { logger } from './logger.js'; /** * Parse the .env file and return values for the requested keys. * Does NOT load anything into process.env — callers decide what to * do with the values. This keeps secrets out of the process environment * so they don't leak to child processes. */ export function readEnvFile(keys: string[]): Record { const envFile = path.join(process.cwd(), '.env'); let content: string; try { content = fs.readFileSync(envFile, 'utf-8'); } catch (err) { logger.debug({ err }, '.env file not found, using defaults'); return {}; } const result: Record = {}; const wanted = new Set(keys); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIdx = trimmed.indexOf('='); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); if (!wanted.has(key)) continue; let value = trimmed.slice(eqIdx + 1).trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } if (value) result[key] = value; } return result; } ================================================ FILE: src/formatting.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; import { escapeXml, formatMessages, formatOutbound, stripInternalTags, } from './router.js'; import { NewMessage } from './types.js'; function makeMsg(overrides: Partial = {}): NewMessage { return { id: '1', chat_jid: 'group@g.us', sender: '123@s.whatsapp.net', sender_name: 'Alice', content: 'hello', timestamp: '2024-01-01T00:00:00.000Z', ...overrides, }; } // --- escapeXml --- describe('escapeXml', () => { it('escapes ampersands', () => { expect(escapeXml('a & b')).toBe('a & b'); }); it('escapes less-than', () => { expect(escapeXml('a < b')).toBe('a < b'); }); it('escapes greater-than', () => { expect(escapeXml('a > b')).toBe('a > b'); }); it('escapes double quotes', () => { expect(escapeXml('"hello"')).toBe('"hello"'); }); it('handles multiple special characters together', () => { expect(escapeXml('a & b < c > d "e"')).toBe( 'a & b < c > d "e"', ); }); it('passes through strings with no special chars', () => { expect(escapeXml('hello world')).toBe('hello world'); }); it('handles empty string', () => { expect(escapeXml('')).toBe(''); }); }); // --- formatMessages --- describe('formatMessages', () => { const TZ = 'UTC'; it('formats a single message as XML with context header', () => { const result = formatMessages([makeMsg()], TZ); expect(result).toContain(''); expect(result).toContain('hello'); expect(result).toContain('Jan 1, 2024'); }); it('formats multiple messages', () => { const msgs = [ makeMsg({ id: '1', sender_name: 'Alice', content: 'hi', timestamp: '2024-01-01T00:00:00.000Z', }), makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: '2024-01-01T01:00:00.000Z', }), ]; const result = formatMessages(msgs, TZ); expect(result).toContain('sender="Alice"'); expect(result).toContain('sender="Bob"'); expect(result).toContain('>hi'); expect(result).toContain('>hey'); }); it('escapes special characters in sender names', () => { const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ); expect(result).toContain('sender="A & B <Co>"'); }); it('escapes special characters in content', () => { const result = formatMessages( [makeMsg({ content: '' })], TZ, ); expect(result).toContain( '<script>alert("xss")</script>', ); }); it('handles empty array', () => { const result = formatMessages([], TZ); expect(result).toContain(''); expect(result).toContain('\n\n'); }); it('converts timestamps to local time for given timezone', () => { // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM const result = formatMessages( [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })], 'America/New_York', ); expect(result).toContain('1:30'); expect(result).toContain('PM'); expect(result).toContain(''); }); }); // --- TRIGGER_PATTERN --- describe('TRIGGER_PATTERN', () => { const name = ASSISTANT_NAME; const lower = name.toLowerCase(); const upper = name.toUpperCase(); it('matches @name at start of message', () => { expect(TRIGGER_PATTERN.test(`@${name} hello`)).toBe(true); }); it('matches case-insensitively', () => { expect(TRIGGER_PATTERN.test(`@${lower} hello`)).toBe(true); expect(TRIGGER_PATTERN.test(`@${upper} hello`)).toBe(true); }); it('does not match when not at start of message', () => { expect(TRIGGER_PATTERN.test(`hello @${name}`)).toBe(false); }); it('does not match partial name like @NameExtra (word boundary)', () => { expect(TRIGGER_PATTERN.test(`@${name}extra hello`)).toBe(false); }); it('matches with word boundary before apostrophe', () => { expect(TRIGGER_PATTERN.test(`@${name}'s thing`)).toBe(true); }); it('matches @name alone (end of string is a word boundary)', () => { expect(TRIGGER_PATTERN.test(`@${name}`)).toBe(true); }); it('matches with leading whitespace after trim', () => { // The actual usage trims before testing: TRIGGER_PATTERN.test(m.content.trim()) expect(TRIGGER_PATTERN.test(`@${name} hey`.trim())).toBe(true); }); }); // --- Outbound formatting (internal tag stripping + prefix) --- describe('stripInternalTags', () => { it('strips single-line internal tags', () => { expect(stripInternalTags('hello secret world')).toBe( 'hello world', ); }); it('strips multi-line internal tags', () => { expect( stripInternalTags('hello \nsecret\nstuff\n world'), ).toBe('hello world'); }); it('strips multiple internal tag blocks', () => { expect( stripInternalTags('ahellob'), ).toBe('hello'); }); it('returns empty string when text is only internal tags', () => { expect(stripInternalTags('only this')).toBe(''); }); }); describe('formatOutbound', () => { it('returns text with internal tags stripped', () => { expect(formatOutbound('hello world')).toBe('hello world'); }); it('returns empty string when all text is internal', () => { expect(formatOutbound('hidden')).toBe(''); }); it('strips internal tags from remaining text', () => { expect( formatOutbound('thinkingThe answer is 42'), ).toBe('The answer is 42'); }); }); // --- Trigger gating with requiresTrigger flag --- describe('trigger gating (requiresTrigger interaction)', () => { // Replicates the exact logic from processGroupMessages and startMessageLoop: // if (!isMainGroup && group.requiresTrigger !== false) { check trigger } function shouldRequireTrigger( isMainGroup: boolean, requiresTrigger: boolean | undefined, ): boolean { return !isMainGroup && requiresTrigger !== false; } function shouldProcess( isMainGroup: boolean, requiresTrigger: boolean | undefined, messages: NewMessage[], ): boolean { if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true; return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim())); } it('main group always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; expect(shouldProcess(true, undefined, msgs)).toBe(true); }); it('main group processes even with requiresTrigger=true', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; expect(shouldProcess(true, true, msgs)).toBe(true); }); it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; expect(shouldProcess(false, undefined, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true requires trigger', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; expect(shouldProcess(false, true, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true processes when trigger present', () => { const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; expect(shouldProcess(false, true, msgs)).toBe(true); }); it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; expect(shouldProcess(false, false, msgs)).toBe(true); }); }); ================================================ FILE: src/group-folder.test.ts ================================================ import path from 'path'; import { describe, expect, it } from 'vitest'; import { isValidGroupFolder, resolveGroupFolderPath, resolveGroupIpcPath, } from './group-folder.js'; describe('group folder validation', () => { it('accepts normal group folder names', () => { expect(isValidGroupFolder('main')).toBe(true); expect(isValidGroupFolder('family-chat')).toBe(true); expect(isValidGroupFolder('Team_42')).toBe(true); }); it('rejects traversal and reserved names', () => { expect(isValidGroupFolder('../../etc')).toBe(false); expect(isValidGroupFolder('/tmp')).toBe(false); expect(isValidGroupFolder('global')).toBe(false); expect(isValidGroupFolder('')).toBe(false); }); it('resolves safe paths under groups directory', () => { const resolved = resolveGroupFolderPath('family-chat'); expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe( true, ); }); it('resolves safe paths under data ipc directory', () => { const resolved = resolveGroupIpcPath('family-chat'); expect( resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`), ).toBe(true); }); it('throws for unsafe folder names', () => { expect(() => resolveGroupFolderPath('../../etc')).toThrow(); expect(() => resolveGroupIpcPath('/tmp')).toThrow(); }); }); ================================================ FILE: src/group-folder.ts ================================================ import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; const GROUP_FOLDER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/; const RESERVED_FOLDERS = new Set(['global']); export function isValidGroupFolder(folder: string): boolean { if (!folder) return false; if (folder !== folder.trim()) return false; if (!GROUP_FOLDER_PATTERN.test(folder)) return false; if (folder.includes('/') || folder.includes('\\')) return false; if (folder.includes('..')) return false; if (RESERVED_FOLDERS.has(folder.toLowerCase())) return false; return true; } export function assertValidGroupFolder(folder: string): void { if (!isValidGroupFolder(folder)) { throw new Error(`Invalid group folder "${folder}"`); } } function ensureWithinBase(baseDir: string, resolvedPath: string): void { const rel = path.relative(baseDir, resolvedPath); if (rel.startsWith('..') || path.isAbsolute(rel)) { throw new Error(`Path escapes base directory: ${resolvedPath}`); } } export function resolveGroupFolderPath(folder: string): string { assertValidGroupFolder(folder); const groupPath = path.resolve(GROUPS_DIR, folder); ensureWithinBase(GROUPS_DIR, groupPath); return groupPath; } export function resolveGroupIpcPath(folder: string): string { assertValidGroupFolder(folder); const ipcBaseDir = path.resolve(DATA_DIR, 'ipc'); const ipcPath = path.resolve(ipcBaseDir, folder); ensureWithinBase(ipcBaseDir, ipcPath); return ipcPath; } ================================================ FILE: src/group-queue.test.ts ================================================ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { GroupQueue } from './group-queue.js'; // Mock config to control concurrency limit vi.mock('./config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-test-data', MAX_CONCURRENT_CONTAINERS: 2, })); // Mock fs operations used by sendMessage/closeStdin vi.mock('fs', async () => { const actual = await vi.importActual('fs'); return { ...actual, default: { ...actual, mkdirSync: vi.fn(), writeFileSync: vi.fn(), renameSync: vi.fn(), }, }; }); describe('GroupQueue', () => { let queue: GroupQueue; beforeEach(() => { vi.useFakeTimers(); queue = new GroupQueue(); }); afterEach(() => { vi.useRealTimers(); }); // --- Single group at a time --- it('only runs one container per group at a time', async () => { let concurrentCount = 0; let maxConcurrent = 0; const processMessages = vi.fn(async (groupJid: string) => { concurrentCount++; maxConcurrent = Math.max(maxConcurrent, concurrentCount); // Simulate async work await new Promise((resolve) => setTimeout(resolve, 100)); concurrentCount--; return true; }); queue.setProcessMessagesFn(processMessages); // Enqueue two messages for the same group queue.enqueueMessageCheck('group1@g.us'); queue.enqueueMessageCheck('group1@g.us'); // Advance timers to let the first process complete await vi.advanceTimersByTimeAsync(200); // Second enqueue should have been queued, not concurrent expect(maxConcurrent).toBe(1); }); // --- Global concurrency limit --- it('respects global concurrency limit', async () => { let activeCount = 0; let maxActive = 0; const completionCallbacks: Array<() => void> = []; const processMessages = vi.fn(async (groupJid: string) => { activeCount++; maxActive = Math.max(maxActive, activeCount); await new Promise((resolve) => completionCallbacks.push(resolve)); activeCount--; return true; }); queue.setProcessMessagesFn(processMessages); // Enqueue 3 groups (limit is 2) queue.enqueueMessageCheck('group1@g.us'); queue.enqueueMessageCheck('group2@g.us'); queue.enqueueMessageCheck('group3@g.us'); // Let promises settle await vi.advanceTimersByTimeAsync(10); // Only 2 should be active (MAX_CONCURRENT_CONTAINERS = 2) expect(maxActive).toBe(2); expect(activeCount).toBe(2); // Complete one — third should start completionCallbacks[0](); await vi.advanceTimersByTimeAsync(10); expect(processMessages).toHaveBeenCalledTimes(3); }); // --- Tasks prioritized over messages --- it('drains tasks before messages for same group', async () => { const executionOrder: string[] = []; let resolveFirst: () => void; const processMessages = vi.fn(async (groupJid: string) => { if (executionOrder.length === 0) { // First call: block until we release it await new Promise((resolve) => { resolveFirst = resolve; }); } executionOrder.push('messages'); return true; }); queue.setProcessMessagesFn(processMessages); // Start processing messages (takes the active slot) queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); // While active, enqueue both a task and pending messages const taskFn = vi.fn(async () => { executionOrder.push('task'); }); queue.enqueueTask('group1@g.us', 'task-1', taskFn); queue.enqueueMessageCheck('group1@g.us'); // Release the first processing resolveFirst!(); await vi.advanceTimersByTimeAsync(10); // Task should have run before the second message check expect(executionOrder[0]).toBe('messages'); // first call expect(executionOrder[1]).toBe('task'); // task runs first in drain // Messages would run after task completes }); // --- Retry with backoff on failure --- it('retries with exponential backoff on failure', async () => { let callCount = 0; const processMessages = vi.fn(async () => { callCount++; return false; // failure }); queue.setProcessMessagesFn(processMessages); queue.enqueueMessageCheck('group1@g.us'); // First call happens immediately await vi.advanceTimersByTimeAsync(10); expect(callCount).toBe(1); // First retry after 5000ms (BASE_RETRY_MS * 2^0) await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(10); expect(callCount).toBe(2); // Second retry after 10000ms (BASE_RETRY_MS * 2^1) await vi.advanceTimersByTimeAsync(10000); await vi.advanceTimersByTimeAsync(10); expect(callCount).toBe(3); }); // --- Shutdown prevents new enqueues --- it('prevents new enqueues after shutdown', async () => { const processMessages = vi.fn(async () => true); queue.setProcessMessagesFn(processMessages); await queue.shutdown(1000); queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(100); expect(processMessages).not.toHaveBeenCalled(); }); // --- Max retries exceeded --- it('stops retrying after MAX_RETRIES and resets', async () => { let callCount = 0; const processMessages = vi.fn(async () => { callCount++; return false; // always fail }); queue.setProcessMessagesFn(processMessages); queue.enqueueMessageCheck('group1@g.us'); // Run through all 5 retries (MAX_RETRIES = 5) // Initial call await vi.advanceTimersByTimeAsync(10); expect(callCount).toBe(1); // Retry 1: 5000ms, Retry 2: 10000ms, Retry 3: 20000ms, Retry 4: 40000ms, Retry 5: 80000ms const retryDelays = [5000, 10000, 20000, 40000, 80000]; for (let i = 0; i < retryDelays.length; i++) { await vi.advanceTimersByTimeAsync(retryDelays[i] + 10); expect(callCount).toBe(i + 2); } // After 5 retries (6 total calls), should stop — no more retries const countAfterMaxRetries = callCount; await vi.advanceTimersByTimeAsync(200000); // Wait a long time expect(callCount).toBe(countAfterMaxRetries); }); // --- Waiting groups get drained when slots free up --- it('drains waiting groups when active slots free up', async () => { const processed: string[] = []; const completionCallbacks: Array<() => void> = []; const processMessages = vi.fn(async (groupJid: string) => { processed.push(groupJid); await new Promise((resolve) => completionCallbacks.push(resolve)); return true; }); queue.setProcessMessagesFn(processMessages); // Fill both slots queue.enqueueMessageCheck('group1@g.us'); queue.enqueueMessageCheck('group2@g.us'); await vi.advanceTimersByTimeAsync(10); // Queue a third queue.enqueueMessageCheck('group3@g.us'); await vi.advanceTimersByTimeAsync(10); expect(processed).toEqual(['group1@g.us', 'group2@g.us']); // Free up a slot completionCallbacks[0](); await vi.advanceTimersByTimeAsync(10); expect(processed).toContain('group3@g.us'); }); // --- Running task dedup (Issue #138) --- it('rejects duplicate enqueue of a currently-running task', async () => { let resolveTask: () => void; let taskCallCount = 0; const taskFn = vi.fn(async () => { taskCallCount++; await new Promise((resolve) => { resolveTask = resolve; }); }); // Start the task (runs immediately — slot available) queue.enqueueTask('group1@g.us', 'task-1', taskFn); await vi.advanceTimersByTimeAsync(10); expect(taskCallCount).toBe(1); // Scheduler poll re-discovers the same task while it's running — // this must be silently dropped const dupFn = vi.fn(async () => {}); queue.enqueueTask('group1@g.us', 'task-1', dupFn); await vi.advanceTimersByTimeAsync(10); // Duplicate was NOT queued expect(dupFn).not.toHaveBeenCalled(); // Complete the original task resolveTask!(); await vi.advanceTimersByTimeAsync(10); // Only one execution total expect(taskCallCount).toBe(1); }); // --- Idle preemption --- it('does NOT preempt active container when not idle', async () => { const fs = await import('fs'); let resolveProcess: () => void; const processMessages = vi.fn(async () => { await new Promise((resolve) => { resolveProcess = resolve; }); return true; }); queue.setProcessMessagesFn(processMessages); // Start processing (takes the active slot) queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); // Register a process so closeStdin has a groupFolder queue.registerProcess( 'group1@g.us', {} as any, 'container-1', 'test-group', ); // Enqueue a task while container is active but NOT idle const taskFn = vi.fn(async () => {}); queue.enqueueTask('group1@g.us', 'task-1', taskFn); // _close should NOT have been written (container is working, not idle) const writeFileSync = vi.mocked(fs.default.writeFileSync); const closeWrites = writeFileSync.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), ); expect(closeWrites).toHaveLength(0); resolveProcess!(); await vi.advanceTimersByTimeAsync(10); }); it('preempts idle container when task is enqueued', async () => { const fs = await import('fs'); let resolveProcess: () => void; const processMessages = vi.fn(async () => { await new Promise((resolve) => { resolveProcess = resolve; }); return true; }); queue.setProcessMessagesFn(processMessages); // Start processing queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); // Register process and mark idle queue.registerProcess( 'group1@g.us', {} as any, 'container-1', 'test-group', ); queue.notifyIdle('group1@g.us'); // Clear previous writes, then enqueue a task const writeFileSync = vi.mocked(fs.default.writeFileSync); writeFileSync.mockClear(); const taskFn = vi.fn(async () => {}); queue.enqueueTask('group1@g.us', 'task-1', taskFn); // _close SHOULD have been written (container is idle) const closeWrites = writeFileSync.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), ); expect(closeWrites).toHaveLength(1); resolveProcess!(); await vi.advanceTimersByTimeAsync(10); }); it('sendMessage resets idleWaiting so a subsequent task enqueue does not preempt', async () => { const fs = await import('fs'); let resolveProcess: () => void; const processMessages = vi.fn(async () => { await new Promise((resolve) => { resolveProcess = resolve; }); return true; }); queue.setProcessMessagesFn(processMessages); queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); queue.registerProcess( 'group1@g.us', {} as any, 'container-1', 'test-group', ); // Container becomes idle queue.notifyIdle('group1@g.us'); // A new user message arrives — resets idleWaiting queue.sendMessage('group1@g.us', 'hello'); // Task enqueued after message reset — should NOT preempt (agent is working) const writeFileSync = vi.mocked(fs.default.writeFileSync); writeFileSync.mockClear(); const taskFn = vi.fn(async () => {}); queue.enqueueTask('group1@g.us', 'task-1', taskFn); const closeWrites = writeFileSync.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), ); expect(closeWrites).toHaveLength(0); resolveProcess!(); await vi.advanceTimersByTimeAsync(10); }); it('sendMessage returns false for task containers so user messages queue up', async () => { let resolveTask: () => void; const taskFn = vi.fn(async () => { await new Promise((resolve) => { resolveTask = resolve; }); }); // Start a task (sets isTaskContainer = true) queue.enqueueTask('group1@g.us', 'task-1', taskFn); await vi.advanceTimersByTimeAsync(10); queue.registerProcess( 'group1@g.us', {} as any, 'container-1', 'test-group', ); // sendMessage should return false — user messages must not go to task containers const result = queue.sendMessage('group1@g.us', 'hello'); expect(result).toBe(false); resolveTask!(); await vi.advanceTimersByTimeAsync(10); }); it('preempts when idle arrives with pending tasks', async () => { const fs = await import('fs'); let resolveProcess: () => void; const processMessages = vi.fn(async () => { await new Promise((resolve) => { resolveProcess = resolve; }); return true; }); queue.setProcessMessagesFn(processMessages); // Start processing queue.enqueueMessageCheck('group1@g.us'); await vi.advanceTimersByTimeAsync(10); // Register process and enqueue a task (no idle yet — no preemption) queue.registerProcess( 'group1@g.us', {} as any, 'container-1', 'test-group', ); const writeFileSync = vi.mocked(fs.default.writeFileSync); writeFileSync.mockClear(); const taskFn = vi.fn(async () => {}); queue.enqueueTask('group1@g.us', 'task-1', taskFn); let closeWrites = writeFileSync.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), ); expect(closeWrites).toHaveLength(0); // Now container becomes idle — should preempt because task is pending writeFileSync.mockClear(); queue.notifyIdle('group1@g.us'); closeWrites = writeFileSync.mock.calls.filter( (call) => typeof call[0] === 'string' && call[0].endsWith('_close'), ); expect(closeWrites).toHaveLength(1); resolveProcess!(); await vi.advanceTimersByTimeAsync(10); }); }); ================================================ FILE: src/group-queue.ts ================================================ import { ChildProcess } from 'child_process'; import fs from 'fs'; import path from 'path'; import { DATA_DIR, MAX_CONCURRENT_CONTAINERS } from './config.js'; import { logger } from './logger.js'; interface QueuedTask { id: string; groupJid: string; fn: () => Promise; } const MAX_RETRIES = 5; const BASE_RETRY_MS = 5000; interface GroupState { active: boolean; idleWaiting: boolean; isTaskContainer: boolean; runningTaskId: string | null; pendingMessages: boolean; pendingTasks: QueuedTask[]; process: ChildProcess | null; containerName: string | null; groupFolder: string | null; retryCount: number; } export class GroupQueue { private groups = new Map(); private activeCount = 0; private waitingGroups: string[] = []; private processMessagesFn: ((groupJid: string) => Promise) | null = null; private shuttingDown = false; private getGroup(groupJid: string): GroupState { let state = this.groups.get(groupJid); if (!state) { state = { active: false, idleWaiting: false, isTaskContainer: false, runningTaskId: null, pendingMessages: false, pendingTasks: [], process: null, containerName: null, groupFolder: null, retryCount: 0, }; this.groups.set(groupJid, state); } return state; } setProcessMessagesFn(fn: (groupJid: string) => Promise): void { this.processMessagesFn = fn; } enqueueMessageCheck(groupJid: string): void { if (this.shuttingDown) return; const state = this.getGroup(groupJid); if (state.active) { state.pendingMessages = true; logger.debug({ groupJid }, 'Container active, message queued'); return; } if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) { state.pendingMessages = true; if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } logger.debug( { groupJid, activeCount: this.activeCount }, 'At concurrency limit, message queued', ); return; } this.runForGroup(groupJid, 'messages').catch((err) => logger.error({ groupJid, err }, 'Unhandled error in runForGroup'), ); } enqueueTask(groupJid: string, taskId: string, fn: () => Promise): void { if (this.shuttingDown) return; const state = this.getGroup(groupJid); // Prevent double-queuing: check both pending and currently-running task if (state.runningTaskId === taskId) { logger.debug({ groupJid, taskId }, 'Task already running, skipping'); return; } if (state.pendingTasks.some((t) => t.id === taskId)) { logger.debug({ groupJid, taskId }, 'Task already queued, skipping'); return; } if (state.active) { state.pendingTasks.push({ id: taskId, groupJid, fn }); if (state.idleWaiting) { this.closeStdin(groupJid); } logger.debug({ groupJid, taskId }, 'Container active, task queued'); return; } if (this.activeCount >= MAX_CONCURRENT_CONTAINERS) { state.pendingTasks.push({ id: taskId, groupJid, fn }); if (!this.waitingGroups.includes(groupJid)) { this.waitingGroups.push(groupJid); } logger.debug( { groupJid, taskId, activeCount: this.activeCount }, 'At concurrency limit, task queued', ); return; } // Run immediately this.runTask(groupJid, { id: taskId, groupJid, fn }).catch((err) => logger.error({ groupJid, taskId, err }, 'Unhandled error in runTask'), ); } registerProcess( groupJid: string, proc: ChildProcess, containerName: string, groupFolder?: string, ): void { const state = this.getGroup(groupJid); state.process = proc; state.containerName = containerName; if (groupFolder) state.groupFolder = groupFolder; } /** * Mark the container as idle-waiting (finished work, waiting for IPC input). * If tasks are pending, preempt the idle container immediately. */ notifyIdle(groupJid: string): void { const state = this.getGroup(groupJid); state.idleWaiting = true; if (state.pendingTasks.length > 0) { this.closeStdin(groupJid); } } /** * Send a follow-up message to the active container via IPC file. * Returns true if the message was written, false if no active container. */ sendMessage(groupJid: string, text: string): boolean { const state = this.getGroup(groupJid); if (!state.active || !state.groupFolder || state.isTaskContainer) return false; state.idleWaiting = false; // Agent is about to receive work, no longer idle const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); try { fs.mkdirSync(inputDir, { recursive: true }); const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`; const filepath = path.join(inputDir, filename); const tempPath = `${filepath}.tmp`; fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text })); fs.renameSync(tempPath, filepath); return true; } catch { return false; } } /** * Signal the active container to wind down by writing a close sentinel. */ closeStdin(groupJid: string): void { const state = this.getGroup(groupJid); if (!state.active || !state.groupFolder) return; const inputDir = path.join(DATA_DIR, 'ipc', state.groupFolder, 'input'); try { fs.mkdirSync(inputDir, { recursive: true }); fs.writeFileSync(path.join(inputDir, '_close'), ''); } catch { // ignore } } private async runForGroup( groupJid: string, reason: 'messages' | 'drain', ): Promise { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; state.isTaskContainer = false; state.pendingMessages = false; this.activeCount++; logger.debug( { groupJid, reason, activeCount: this.activeCount }, 'Starting container for group', ); try { if (this.processMessagesFn) { const success = await this.processMessagesFn(groupJid); if (success) { state.retryCount = 0; } else { this.scheduleRetry(groupJid, state); } } } catch (err) { logger.error({ groupJid, err }, 'Error processing messages for group'); this.scheduleRetry(groupJid, state); } finally { state.active = false; state.process = null; state.containerName = null; state.groupFolder = null; this.activeCount--; this.drainGroup(groupJid); } } private async runTask(groupJid: string, task: QueuedTask): Promise { const state = this.getGroup(groupJid); state.active = true; state.idleWaiting = false; state.isTaskContainer = true; state.runningTaskId = task.id; this.activeCount++; logger.debug( { groupJid, taskId: task.id, activeCount: this.activeCount }, 'Running queued task', ); try { await task.fn(); } catch (err) { logger.error({ groupJid, taskId: task.id, err }, 'Error running task'); } finally { state.active = false; state.isTaskContainer = false; state.runningTaskId = null; state.process = null; state.containerName = null; state.groupFolder = null; this.activeCount--; this.drainGroup(groupJid); } } private scheduleRetry(groupJid: string, state: GroupState): void { state.retryCount++; if (state.retryCount > MAX_RETRIES) { logger.error( { groupJid, retryCount: state.retryCount }, 'Max retries exceeded, dropping messages (will retry on next incoming message)', ); state.retryCount = 0; return; } const delayMs = BASE_RETRY_MS * Math.pow(2, state.retryCount - 1); logger.info( { groupJid, retryCount: state.retryCount, delayMs }, 'Scheduling retry with backoff', ); setTimeout(() => { if (!this.shuttingDown) { this.enqueueMessageCheck(groupJid); } }, delayMs); } private drainGroup(groupJid: string): void { if (this.shuttingDown) return; const state = this.getGroup(groupJid); // Tasks first (they won't be re-discovered from SQLite like messages) if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(groupJid, task).catch((err) => logger.error( { groupJid, taskId: task.id, err }, 'Unhandled error in runTask (drain)', ), ); return; } // Then pending messages if (state.pendingMessages) { this.runForGroup(groupJid, 'drain').catch((err) => logger.error( { groupJid, err }, 'Unhandled error in runForGroup (drain)', ), ); return; } // Nothing pending for this group; check if other groups are waiting for a slot this.drainWaiting(); } private drainWaiting(): void { while ( this.waitingGroups.length > 0 && this.activeCount < MAX_CONCURRENT_CONTAINERS ) { const nextJid = this.waitingGroups.shift()!; const state = this.getGroup(nextJid); // Prioritize tasks over messages if (state.pendingTasks.length > 0) { const task = state.pendingTasks.shift()!; this.runTask(nextJid, task).catch((err) => logger.error( { groupJid: nextJid, taskId: task.id, err }, 'Unhandled error in runTask (waiting)', ), ); } else if (state.pendingMessages) { this.runForGroup(nextJid, 'drain').catch((err) => logger.error( { groupJid: nextJid, err }, 'Unhandled error in runForGroup (waiting)', ), ); } // If neither pending, skip this group } } async shutdown(_gracePeriodMs: number): Promise { this.shuttingDown = true; // Count active containers but don't kill them — they'll finish on their own // via idle timeout or container timeout. The --rm flag cleans them up on exit. // This prevents WhatsApp reconnection restarts from killing working agents. const activeContainers: string[] = []; for (const [jid, state] of this.groups) { if (state.process && !state.process.killed && state.containerName) { activeContainers.push(state.containerName); } } logger.info( { activeCount: this.activeCount, detachedContainers: activeContainers }, 'GroupQueue shutting down (containers detached, not killed)', ); } } ================================================ FILE: src/index.ts ================================================ import fs from 'fs'; import path from 'path'; import { ASSISTANT_NAME, CREDENTIAL_PROXY_PORT, IDLE_TIMEOUT, POLL_INTERVAL, TIMEZONE, TRIGGER_PATTERN, } from './config.js'; import { startCredentialProxy } from './credential-proxy.js'; import './channels/index.js'; import { getChannelFactory, getRegisteredChannelNames, } from './channels/registry.js'; import { ContainerOutput, runContainerAgent, writeGroupsSnapshot, writeTasksSnapshot, } from './container-runner.js'; import { cleanupOrphans, ensureContainerRuntimeRunning, PROXY_BIND_HOST, } from './container-runtime.js'; import { getAllChats, getAllRegisteredGroups, getAllSessions, getAllTasks, getMessagesSince, getNewMessages, getRegisteredGroup, getRouterState, initDatabase, setRegisteredGroup, setRouterState, setSession, storeChatMetadata, storeMessage, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { startIpcWatcher } from './ipc.js'; import { findChannel, formatMessages, formatOutbound } from './router.js'; import { restoreRemoteControl, startRemoteControl, stopRemoteControl, } from './remote-control.js'; import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, shouldDropMessage, } from './sender-allowlist.js'; import { startSchedulerLoop } from './task-scheduler.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; // Re-export for backwards compatibility during refactor export { escapeXml, formatMessages } from './router.js'; let lastTimestamp = ''; let sessions: Record = {}; let registeredGroups: Record = {}; let lastAgentTimestamp: Record = {}; let messageLoopRunning = false; const channels: Channel[] = []; const queue = new GroupQueue(); function loadState(): void { lastTimestamp = getRouterState('last_timestamp') || ''; const agentTs = getRouterState('last_agent_timestamp'); try { lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {}; } catch { logger.warn('Corrupted last_agent_timestamp in DB, resetting'); lastAgentTimestamp = {}; } sessions = getAllSessions(); registeredGroups = getAllRegisteredGroups(); logger.info( { groupCount: Object.keys(registeredGroups).length }, 'State loaded', ); } function saveState(): void { setRouterState('last_timestamp', lastTimestamp); setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp)); } function registerGroup(jid: string, group: RegisteredGroup): void { let groupDir: string; try { groupDir = resolveGroupFolderPath(group.folder); } catch (err) { logger.warn( { jid, folder: group.folder, err }, 'Rejecting group registration with invalid folder', ); return; } registeredGroups[jid] = group; setRegisteredGroup(jid, group); // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); logger.info( { jid, name: group.name, folder: group.folder }, 'Group registered', ); } /** * Get available groups list for the agent. * Returns groups ordered by most recent activity. */ export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] { const chats = getAllChats(); const registeredJids = new Set(Object.keys(registeredGroups)); return chats .filter((c) => c.jid !== '__group_sync__' && c.is_group) .map((c) => ({ jid: c.jid, name: c.name, lastActivity: c.last_message_time, isRegistered: registeredJids.has(c.jid), })); } /** @internal - exported for testing */ export function _setRegisteredGroups( groups: Record, ): void { registeredGroups = groups; } /** * Process all pending messages for a group. * Called by the GroupQueue when it's this group's turn. */ async function processGroupMessages(chatJid: string): Promise { const group = registeredGroups[chatJid]; if (!group) return true; const channel = findChannel(channels, chatJid); if (!channel) { logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); return true; } const isMainGroup = group.isMain === true; const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const missedMessages = getMessagesSince( chatJid, sinceTimestamp, ASSISTANT_NAME, ); if (missedMessages.length === 0) return true; // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => TRIGGER_PATTERN.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; } const prompt = formatMessages(missedMessages, TIMEZONE); // Advance cursor so the piping path in startMessageLoop won't re-fetch // these messages. Save the old cursor so we can roll back on error. const previousCursor = lastAgentTimestamp[chatJid] || ''; lastAgentTimestamp[chatJid] = missedMessages[missedMessages.length - 1].timestamp; saveState(); logger.info( { group: group.name, messageCount: missedMessages.length }, 'Processing messages', ); // Track idle timer for closing stdin when agent is idle let idleTimer: ReturnType | null = null; const resetIdleTimer = () => { if (idleTimer) clearTimeout(idleTimer); idleTimer = setTimeout(() => { logger.debug( { group: group.name }, 'Idle timeout, closing container stdin', ); queue.closeStdin(chatJid); }, IDLE_TIMEOUT); }; await channel.setTyping?.(chatJid, true); let hadError = false; let outputSentToUser = false; const output = await runAgent(group, prompt, chatJid, async (result) => { // Streaming output callback — called for each agent result if (result.result) { const raw = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); // Strip ... blocks — agent uses these for internal reasoning const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim(); logger.info({ group: group.name }, `Agent output: ${raw.length} chars`); if (text) { await channel.sendMessage(chatJid, text); outputSentToUser = true; } // Only reset idle timer on actual results, not session-update markers (result: null) resetIdleTimer(); } if (result.status === 'success') { queue.notifyIdle(chatJid); } if (result.status === 'error') { hadError = true; } }); await channel.setTyping?.(chatJid, false); if (idleTimer) clearTimeout(idleTimer); if (output === 'error' || hadError) { // If we already sent output to the user, don't roll back the cursor — // the user got their response and re-processing would send duplicates. if (outputSentToUser) { logger.warn( { group: group.name }, 'Agent error after output was sent, skipping cursor rollback to prevent duplicates', ); return true; } // Roll back cursor so retries can re-process these messages lastAgentTimestamp[chatJid] = previousCursor; saveState(); logger.warn( { group: group.name }, 'Agent error, rolled back message cursor for retry', ); return false; } return true; } async function runAgent( group: RegisteredGroup, prompt: string, chatJid: string, onOutput?: (output: ContainerOutput) => Promise, ): Promise<'success' | 'error'> { const isMain = group.isMain === true; const sessionId = sessions[group.folder]; // Update tasks snapshot for container to read (filtered by group) const tasks = getAllTasks(); writeTasksSnapshot( group.folder, isMain, tasks.map((t) => ({ id: t.id, groupFolder: t.group_folder, prompt: t.prompt, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, next_run: t.next_run, })), ); // Update available groups snapshot (main group only can see all groups) const availableGroups = getAvailableGroups(); writeGroupsSnapshot( group.folder, isMain, availableGroups, new Set(Object.keys(registeredGroups)), ); // Wrap onOutput to track session ID from streamed results const wrappedOnOutput = onOutput ? async (output: ContainerOutput) => { if (output.newSessionId) { sessions[group.folder] = output.newSessionId; setSession(group.folder, output.newSessionId); } await onOutput(output); } : undefined; try { const output = await runContainerAgent( group, { prompt, sessionId, groupFolder: group.folder, chatJid, isMain, assistantName: ASSISTANT_NAME, }, (proc, containerName) => queue.registerProcess(chatJid, proc, containerName, group.folder), wrappedOnOutput, ); if (output.newSessionId) { sessions[group.folder] = output.newSessionId; setSession(group.folder, output.newSessionId); } if (output.status === 'error') { logger.error( { group: group.name, error: output.error }, 'Container agent error', ); return 'error'; } return 'success'; } catch (err) { logger.error({ group: group.name, err }, 'Agent error'); return 'error'; } } async function startMessageLoop(): Promise { if (messageLoopRunning) { logger.debug('Message loop already running, skipping duplicate start'); return; } messageLoopRunning = true; logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); while (true) { try { const jids = Object.keys(registeredGroups); const { messages, newTimestamp } = getNewMessages( jids, lastTimestamp, ASSISTANT_NAME, ); if (messages.length > 0) { logger.info({ count: messages.length }, 'New messages'); // Advance the "seen" cursor for all messages immediately lastTimestamp = newTimestamp; saveState(); // Deduplicate by group const messagesByGroup = new Map(); for (const msg of messages) { const existing = messagesByGroup.get(msg.chat_jid); if (existing) { existing.push(msg); } else { messagesByGroup.set(msg.chat_jid, [msg]); } } for (const [chatJid, groupMessages] of messagesByGroup) { const group = registeredGroups[chatJid]; if (!group) continue; const channel = findChannel(channels, chatJid); if (!channel) { logger.warn({ chatJid }, 'No channel owns JID, skipping messages'); continue; } const isMainGroup = group.isMain === true; const needsTrigger = !isMainGroup && group.requiresTrigger !== false; // For non-main groups, only act on trigger messages. // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { const allowlistCfg = loadSenderAllowlist(); const hasTrigger = groupMessages.some( (m) => TRIGGER_PATTERN.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) continue; } // Pull all messages since lastAgentTimestamp so non-trigger // context that accumulated between triggers is included. const allPending = getMessagesSince( chatJid, lastAgentTimestamp[chatJid] || '', ASSISTANT_NAME, ); const messagesToSend = allPending.length > 0 ? allPending : groupMessages; const formatted = formatMessages(messagesToSend, TIMEZONE); if (queue.sendMessage(chatJid, formatted)) { logger.debug( { chatJid, count: messagesToSend.length }, 'Piped messages to active container', ); lastAgentTimestamp[chatJid] = messagesToSend[messagesToSend.length - 1].timestamp; saveState(); // Show typing indicator while the container processes the piped message channel .setTyping?.(chatJid, true) ?.catch((err) => logger.warn({ chatJid, err }, 'Failed to set typing indicator'), ); } else { // No active container — enqueue for a new one queue.enqueueMessageCheck(chatJid); } } } } catch (err) { logger.error({ err }, 'Error in message loop'); } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); } } /** * Startup recovery: check for unprocessed messages in registered groups. * Handles crash between advancing lastTimestamp and processing messages. */ function recoverPendingMessages(): void { for (const [chatJid, group] of Object.entries(registeredGroups)) { const sinceTimestamp = lastAgentTimestamp[chatJid] || ''; const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME); if (pending.length > 0) { logger.info( { group: group.name, pendingCount: pending.length }, 'Recovery: found unprocessed messages', ); queue.enqueueMessageCheck(chatJid); } } } function ensureContainerSystemRunning(): void { ensureContainerRuntimeRunning(); cleanupOrphans(); } async function main(): Promise { ensureContainerSystemRunning(); initDatabase(); logger.info('Database initialized'); loadState(); restoreRemoteControl(); // Start credential proxy (containers route API calls through this) const proxyServer = await startCredentialProxy( CREDENTIAL_PROXY_PORT, PROXY_BIND_HOST, ); // Graceful shutdown handlers const shutdown = async (signal: string) => { logger.info({ signal }, 'Shutdown signal received'); proxyServer.close(); await queue.shutdown(10000); for (const ch of channels) await ch.disconnect(); process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); // Handle /remote-control and /remote-control-end commands async function handleRemoteControl( command: string, chatJid: string, msg: NewMessage, ): Promise { const group = registeredGroups[chatJid]; if (!group?.isMain) { logger.warn( { chatJid, sender: msg.sender }, 'Remote control rejected: not main group', ); return; } const channel = findChannel(channels, chatJid); if (!channel) return; if (command === '/remote-control') { const result = await startRemoteControl( msg.sender, chatJid, process.cwd(), ); if (result.ok) { await channel.sendMessage(chatJid, result.url); } else { await channel.sendMessage( chatJid, `Remote Control failed: ${result.error}`, ); } } else { const result = stopRemoteControl(); if (result.ok) { await channel.sendMessage(chatJid, 'Remote Control session ended.'); } else { await channel.sendMessage(chatJid, result.error); } } } // Channel callbacks (shared by all channels) const channelOpts = { onMessage: (chatJid: string, msg: NewMessage) => { // Remote control commands — intercept before storage const trimmed = msg.content.trim(); if (trimmed === '/remote-control' || trimmed === '/remote-control-end') { handleRemoteControl(trimmed, chatJid, msg).catch((err) => logger.error({ err, chatJid }, 'Remote control command error'), ); return; } // Sender allowlist drop mode: discard messages from denied senders before storing if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) { const cfg = loadSenderAllowlist(); if ( shouldDropMessage(chatJid, cfg) && !isSenderAllowed(chatJid, msg.sender, cfg) ) { if (cfg.logDenied) { logger.debug( { chatJid, sender: msg.sender }, 'sender-allowlist: dropping message (drop mode)', ); } return; } } storeMessage(msg); }, onChatMetadata: ( chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean, ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup), registeredGroups: () => registeredGroups, }; // Create and connect all registered channels. // Each channel self-registers via the barrel import above. // Factories return null when credentials are missing, so unconfigured channels are skipped. for (const channelName of getRegisteredChannelNames()) { const factory = getChannelFactory(channelName)!; const channel = factory(channelOpts); if (!channel) { logger.warn( { channel: channelName }, 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.', ); continue; } channels.push(channel); await channel.connect(); } if (channels.length === 0) { logger.fatal('No channels connected'); process.exit(1); } // Start subsystems (independently of connection handler) startSchedulerLoop({ registeredGroups: () => registeredGroups, getSessions: () => sessions, queue, onProcess: (groupJid, proc, containerName, groupFolder) => queue.registerProcess(groupJid, proc, containerName, groupFolder), sendMessage: async (jid, rawText) => { const channel = findChannel(channels, jid); if (!channel) { logger.warn({ jid }, 'No channel owns JID, cannot send message'); return; } const text = formatOutbound(rawText); if (text) await channel.sendMessage(jid, text); }, }); startIpcWatcher({ sendMessage: (jid, text) => { const channel = findChannel(channels, jid); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); }, registeredGroups: () => registeredGroups, registerGroup, syncGroups: async (force: boolean) => { await Promise.all( channels .filter((ch) => ch.syncGroups) .map((ch) => ch.syncGroups!(force)), ); }, getAvailableGroups, writeGroupsSnapshot: (gf, im, ag, rj) => writeGroupsSnapshot(gf, im, ag, rj), onTasksChanged: () => { const tasks = getAllTasks(); const taskRows = tasks.map((t) => ({ id: t.id, groupFolder: t.group_folder, prompt: t.prompt, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, next_run: t.next_run, })); for (const group of Object.values(registeredGroups)) { writeTasksSnapshot(group.folder, group.isMain === true, taskRows); } }, }); queue.setProcessMessagesFn(processGroupMessages); recoverPendingMessages(); startMessageLoop().catch((err) => { logger.fatal({ err }, 'Message loop crashed unexpectedly'); process.exit(1); }); } // Guard: only run when executed directly, not when imported by tests const isDirectRun = process.argv[1] && new URL(import.meta.url).pathname === new URL(`file://${process.argv[1]}`).pathname; if (isDirectRun) { main().catch((err) => { logger.error({ err }, 'Failed to start NanoClaw'); process.exit(1); }); } ================================================ FILE: src/ipc-auth.test.ts ================================================ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, createTask, getAllTasks, getRegisteredGroup, getTaskById, setRegisteredGroup, } from './db.js'; import { processTaskIpc, IpcDeps } from './ipc.js'; import { RegisteredGroup } from './types.js'; // Set up registered groups used across tests const MAIN_GROUP: RegisteredGroup = { name: 'Main', folder: 'whatsapp_main', trigger: 'always', added_at: '2024-01-01T00:00:00.000Z', isMain: true, }; const OTHER_GROUP: RegisteredGroup = { name: 'Other', folder: 'other-group', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }; const THIRD_GROUP: RegisteredGroup = { name: 'Third', folder: 'third-group', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }; let groups: Record; let deps: IpcDeps; beforeEach(() => { _initTestDatabase(); groups = { 'main@g.us': MAIN_GROUP, 'other@g.us': OTHER_GROUP, 'third@g.us': THIRD_GROUP, }; // Populate DB as well setRegisteredGroup('main@g.us', MAIN_GROUP); setRegisteredGroup('other@g.us', OTHER_GROUP); setRegisteredGroup('third@g.us', THIRD_GROUP); deps = { sendMessage: async () => {}, registeredGroups: () => groups, registerGroup: (jid, group) => { groups[jid] = group; setRegisteredGroup(jid, group); // Mock the fs.mkdirSync that registerGroup does }, syncGroups: async () => {}, getAvailableGroups: () => [], writeGroupsSnapshot: () => {}, onTasksChanged: () => {}, }; }); // --- schedule_task authorization --- describe('schedule_task authorization', () => { it('main group can schedule for another group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'do something', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); // Verify task was created in DB for the other group const allTasks = getAllTasks(); expect(allTasks.length).toBe(1); expect(allTasks[0].group_folder).toBe('other-group'); }); it('non-main group can schedule for itself', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'self task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'other-group', false, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(1); expect(allTasks[0].group_folder).toBe('other-group'); }); it('non-main group cannot schedule for another group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'unauthorized', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'main@g.us', }, 'other-group', false, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(0); }); it('rejects schedule_task for unregistered target JID', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'no target', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'unknown@g.us', }, 'whatsapp_main', true, deps, ); const allTasks = getAllTasks(); expect(allTasks.length).toBe(0); }); }); // --- pause_task authorization --- describe('pause_task authorization', () => { beforeEach(() => { createTask({ id: 'task-main', group_folder: 'whatsapp_main', chat_jid: 'main@g.us', prompt: 'main task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); createTask({ id: 'task-other', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'other task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); }); it('main group can pause any task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-other' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group can pause its own task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-other' }, 'other-group', false, deps, ); expect(getTaskById('task-other')!.status).toBe('paused'); }); it('non-main group cannot pause another groups task', async () => { await processTaskIpc( { type: 'pause_task', taskId: 'task-main' }, 'other-group', false, deps, ); expect(getTaskById('task-main')!.status).toBe('active'); }); }); // --- resume_task authorization --- describe('resume_task authorization', () => { beforeEach(() => { createTask({ id: 'task-paused', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'paused task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: '2025-06-01T00:00:00.000Z', status: 'paused', created_at: '2024-01-01T00:00:00.000Z', }); }); it('main group can resume any task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group can resume its own task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'other-group', false, deps, ); expect(getTaskById('task-paused')!.status).toBe('active'); }); it('non-main group cannot resume another groups task', async () => { await processTaskIpc( { type: 'resume_task', taskId: 'task-paused' }, 'third-group', false, deps, ); expect(getTaskById('task-paused')!.status).toBe('paused'); }); }); // --- cancel_task authorization --- describe('cancel_task authorization', () => { it('main group can cancel any task', async () => { createTask({ id: 'task-to-cancel', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'cancel me', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-to-cancel' }, 'whatsapp_main', true, deps, ); expect(getTaskById('task-to-cancel')).toBeUndefined(); }); it('non-main group can cancel its own task', async () => { createTask({ id: 'task-own', group_folder: 'other-group', chat_jid: 'other@g.us', prompt: 'my task', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-own' }, 'other-group', false, deps, ); expect(getTaskById('task-own')).toBeUndefined(); }); it('non-main group cannot cancel another groups task', async () => { createTask({ id: 'task-foreign', group_folder: 'whatsapp_main', chat_jid: 'main@g.us', prompt: 'not yours', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', next_run: null, status: 'active', created_at: '2024-01-01T00:00:00.000Z', }); await processTaskIpc( { type: 'cancel_task', taskId: 'task-foreign' }, 'other-group', false, deps, ); expect(getTaskById('task-foreign')).toBeDefined(); }); }); // --- register_group authorization --- describe('register_group authorization', () => { it('non-main group cannot register a group', async () => { await processTaskIpc( { type: 'register_group', jid: 'new@g.us', name: 'New Group', folder: 'new-group', trigger: '@Andy', }, 'other-group', false, deps, ); // registeredGroups should not have changed expect(groups['new@g.us']).toBeUndefined(); }); it('main group cannot register with unsafe folder path', async () => { await processTaskIpc( { type: 'register_group', jid: 'new@g.us', name: 'New Group', folder: '../../outside', trigger: '@Andy', }, 'whatsapp_main', true, deps, ); expect(groups['new@g.us']).toBeUndefined(); }); }); // --- refresh_groups authorization --- describe('refresh_groups authorization', () => { it('non-main group cannot trigger refresh', async () => { // This should be silently blocked (no crash, no effect) await processTaskIpc( { type: 'refresh_groups' }, 'other-group', false, deps, ); // If we got here without error, the auth gate worked }); }); // --- IPC message authorization --- // Tests the authorization pattern from startIpcWatcher (ipc.ts). // The logic: isMain || (targetGroup && targetGroup.folder === sourceGroup) describe('IPC message authorization', () => { // Replicate the exact check from the IPC watcher function isMessageAuthorized( sourceGroup: string, isMain: boolean, targetChatJid: string, registeredGroups: Record, ): boolean { const targetGroup = registeredGroups[targetChatJid]; return isMain || (!!targetGroup && targetGroup.folder === sourceGroup); } it('main group can send to any group', () => { expect( isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups), ).toBe(true); expect( isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups), ).toBe(true); }); it('non-main group can send to its own chat', () => { expect( isMessageAuthorized('other-group', false, 'other@g.us', groups), ).toBe(true); }); it('non-main group cannot send to another groups chat', () => { expect(isMessageAuthorized('other-group', false, 'main@g.us', groups)).toBe( false, ); expect( isMessageAuthorized('other-group', false, 'third@g.us', groups), ).toBe(false); }); it('non-main group cannot send to unregistered JID', () => { expect( isMessageAuthorized('other-group', false, 'unknown@g.us', groups), ).toBe(false); }); it('main group can send to unregistered JID', () => { // Main is always authorized regardless of target expect( isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups), ).toBe(true); }); }); // --- schedule_task with cron and interval types --- describe('schedule_task schedule types', () => { it('creates task with cron schedule and computes next_run', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'cron task', schedule_type: 'cron', schedule_value: '0 9 * * *', // every day at 9am targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].schedule_type).toBe('cron'); expect(tasks[0].next_run).toBeTruthy(); // next_run should be a valid ISO date in the future expect(new Date(tasks[0].next_run!).getTime()).toBeGreaterThan( Date.now() - 60000, ); }); it('rejects invalid cron expression', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad cron', schedule_type: 'cron', schedule_value: 'not a cron', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('creates task with interval schedule', async () => { const before = Date.now(); await processTaskIpc( { type: 'schedule_task', prompt: 'interval task', schedule_type: 'interval', schedule_value: '3600000', // 1 hour targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks).toHaveLength(1); expect(tasks[0].schedule_type).toBe('interval'); // next_run should be ~1 hour from now const nextRun = new Date(tasks[0].next_run!).getTime(); expect(nextRun).toBeGreaterThanOrEqual(before + 3600000 - 1000); expect(nextRun).toBeLessThanOrEqual(Date.now() + 3600000 + 1000); }); it('rejects invalid interval (non-numeric)', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad interval', schedule_type: 'interval', schedule_value: 'abc', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('rejects invalid interval (zero)', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'zero interval', schedule_type: 'interval', schedule_value: '0', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); it('rejects invalid once timestamp', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad once', schedule_type: 'once', schedule_value: 'not-a-date', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); expect(getAllTasks()).toHaveLength(0); }); }); // --- context_mode defaulting --- describe('schedule_task context_mode', () => { it('accepts context_mode=group', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'group context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'group', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('group'); }); it('accepts context_mode=isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'isolated context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'isolated', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); it('defaults invalid context_mode to isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'bad context', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', context_mode: 'bogus' as any, targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); it('defaults missing context_mode to isolated', async () => { await processTaskIpc( { type: 'schedule_task', prompt: 'no context mode', schedule_type: 'once', schedule_value: '2025-06-01T00:00:00', targetJid: 'other@g.us', }, 'whatsapp_main', true, deps, ); const tasks = getAllTasks(); expect(tasks[0].context_mode).toBe('isolated'); }); }); // --- register_group success path --- describe('register_group success', () => { it('main group can register a new group', async () => { await processTaskIpc( { type: 'register_group', jid: 'new@g.us', name: 'New Group', folder: 'new-group', trigger: '@Andy', }, 'whatsapp_main', true, deps, ); // Verify group was registered in DB const group = getRegisteredGroup('new@g.us'); expect(group).toBeDefined(); expect(group!.name).toBe('New Group'); expect(group!.folder).toBe('new-group'); expect(group!.trigger).toBe('@Andy'); }); it('register_group rejects request with missing fields', async () => { await processTaskIpc( { type: 'register_group', jid: 'partial@g.us', name: 'Partial', // missing folder and trigger }, 'whatsapp_main', true, deps, ); expect(getRegisteredGroup('partial@g.us')).toBeUndefined(); }); }); ================================================ FILE: src/ipc.ts ================================================ import fs from 'fs'; import path from 'path'; import { CronExpressionParser } from 'cron-parser'; import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; import { AvailableGroup } from './container-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; import { RegisteredGroup } from './types.js'; export interface IpcDeps { sendMessage: (jid: string, text: string) => Promise; registeredGroups: () => Record; registerGroup: (jid: string, group: RegisteredGroup) => void; syncGroups: (force: boolean) => Promise; getAvailableGroups: () => AvailableGroup[]; writeGroupsSnapshot: ( groupFolder: string, isMain: boolean, availableGroups: AvailableGroup[], registeredJids: Set, ) => void; onTasksChanged: () => void; } let ipcWatcherRunning = false; export function startIpcWatcher(deps: IpcDeps): void { if (ipcWatcherRunning) { logger.debug('IPC watcher already running, skipping duplicate start'); return; } ipcWatcherRunning = true; const ipcBaseDir = path.join(DATA_DIR, 'ipc'); fs.mkdirSync(ipcBaseDir, { recursive: true }); const processIpcFiles = async () => { // Scan all group IPC directories (identity determined by directory) let groupFolders: string[]; try { groupFolders = fs.readdirSync(ipcBaseDir).filter((f) => { const stat = fs.statSync(path.join(ipcBaseDir, f)); return stat.isDirectory() && f !== 'errors'; }); } catch (err) { logger.error({ err }, 'Error reading IPC base directory'); setTimeout(processIpcFiles, IPC_POLL_INTERVAL); return; } const registeredGroups = deps.registeredGroups(); // Build folder→isMain lookup from registered groups const folderIsMain = new Map(); for (const group of Object.values(registeredGroups)) { if (group.isMain) folderIsMain.set(group.folder, true); } for (const sourceGroup of groupFolders) { const isMain = folderIsMain.get(sourceGroup) === true; const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages'); const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks'); // Process messages from this group's IPC directory try { if (fs.existsSync(messagesDir)) { const messageFiles = fs .readdirSync(messagesDir) .filter((f) => f.endsWith('.json')); for (const file of messageFiles) { const filePath = path.join(messagesDir, file); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); if (data.type === 'message' && data.chatJid && data.text) { // Authorization: verify this group can send to this chatJid const targetGroup = registeredGroups[data.chatJid]; if ( isMain || (targetGroup && targetGroup.folder === sourceGroup) ) { await deps.sendMessage(data.chatJid, data.text); logger.info( { chatJid: data.chatJid, sourceGroup }, 'IPC message sent', ); } else { logger.warn( { chatJid: data.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked', ); } } fs.unlinkSync(filePath); } catch (err) { logger.error( { file, sourceGroup, err }, 'Error processing IPC message', ); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); fs.renameSync( filePath, path.join(errorDir, `${sourceGroup}-${file}`), ); } } } } catch (err) { logger.error( { err, sourceGroup }, 'Error reading IPC messages directory', ); } // Process tasks from this group's IPC directory try { if (fs.existsSync(tasksDir)) { const taskFiles = fs .readdirSync(tasksDir) .filter((f) => f.endsWith('.json')); for (const file of taskFiles) { const filePath = path.join(tasksDir, file); try { const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); // Pass source group identity to processTaskIpc for authorization await processTaskIpc(data, sourceGroup, isMain, deps); fs.unlinkSync(filePath); } catch (err) { logger.error( { file, sourceGroup, err }, 'Error processing IPC task', ); const errorDir = path.join(ipcBaseDir, 'errors'); fs.mkdirSync(errorDir, { recursive: true }); fs.renameSync( filePath, path.join(errorDir, `${sourceGroup}-${file}`), ); } } } } catch (err) { logger.error({ err, sourceGroup }, 'Error reading IPC tasks directory'); } } setTimeout(processIpcFiles, IPC_POLL_INTERVAL); }; processIpcFiles(); logger.info('IPC watcher started (per-group namespaces)'); } export async function processTaskIpc( data: { type: string; taskId?: string; prompt?: string; schedule_type?: string; schedule_value?: string; context_mode?: string; groupFolder?: string; chatJid?: string; targetJid?: string; // For register_group jid?: string; name?: string; folder?: string; trigger?: string; requiresTrigger?: boolean; containerConfig?: RegisteredGroup['containerConfig']; }, sourceGroup: string, // Verified identity from IPC directory isMain: boolean, // Verified from directory path deps: IpcDeps, ): Promise { const registeredGroups = deps.registeredGroups(); switch (data.type) { case 'schedule_task': if ( data.prompt && data.schedule_type && data.schedule_value && data.targetJid ) { // Resolve the target group from JID const targetJid = data.targetJid as string; const targetGroupEntry = registeredGroups[targetJid]; if (!targetGroupEntry) { logger.warn( { targetJid }, 'Cannot schedule task: target group not registered', ); break; } const targetFolder = targetGroupEntry.folder; // Authorization: non-main groups can only schedule for themselves if (!isMain && targetFolder !== sourceGroup) { logger.warn( { sourceGroup, targetFolder }, 'Unauthorized schedule_task attempt blocked', ); break; } const scheduleType = data.schedule_type as 'cron' | 'interval' | 'once'; let nextRun: string | null = null; if (scheduleType === 'cron') { try { const interval = CronExpressionParser.parse(data.schedule_value, { tz: TIMEZONE, }); nextRun = interval.next().toISOString(); } catch { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid cron expression', ); break; } } else if (scheduleType === 'interval') { const ms = parseInt(data.schedule_value, 10); if (isNaN(ms) || ms <= 0) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid interval', ); break; } nextRun = new Date(Date.now() + ms).toISOString(); } else if (scheduleType === 'once') { const date = new Date(data.schedule_value); if (isNaN(date.getTime())) { logger.warn( { scheduleValue: data.schedule_value }, 'Invalid timestamp', ); break; } nextRun = date.toISOString(); } const taskId = data.taskId || `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const contextMode = data.context_mode === 'group' || data.context_mode === 'isolated' ? data.context_mode : 'isolated'; createTask({ id: taskId, group_folder: targetFolder, chat_jid: targetJid, prompt: data.prompt, schedule_type: scheduleType, schedule_value: data.schedule_value, context_mode: contextMode, next_run: nextRun, status: 'active', created_at: new Date().toISOString(), }); logger.info( { taskId, sourceGroup, targetFolder, contextMode }, 'Task created via IPC', ); deps.onTasksChanged(); } break; case 'pause_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'paused' }); logger.info( { taskId: data.taskId, sourceGroup }, 'Task paused via IPC', ); deps.onTasksChanged(); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task pause attempt', ); } } break; case 'resume_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { updateTask(data.taskId, { status: 'active' }); logger.info( { taskId: data.taskId, sourceGroup }, 'Task resumed via IPC', ); deps.onTasksChanged(); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task resume attempt', ); } } break; case 'cancel_task': if (data.taskId) { const task = getTaskById(data.taskId); if (task && (isMain || task.group_folder === sourceGroup)) { deleteTask(data.taskId); logger.info( { taskId: data.taskId, sourceGroup }, 'Task cancelled via IPC', ); deps.onTasksChanged(); } else { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task cancel attempt', ); } } break; case 'update_task': if (data.taskId) { const task = getTaskById(data.taskId); if (!task) { logger.warn( { taskId: data.taskId, sourceGroup }, 'Task not found for update', ); break; } if (!isMain && task.group_folder !== sourceGroup) { logger.warn( { taskId: data.taskId, sourceGroup }, 'Unauthorized task update attempt', ); break; } const updates: Parameters[1] = {}; if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.schedule_type !== undefined) updates.schedule_type = data.schedule_type as | 'cron' | 'interval' | 'once'; if (data.schedule_value !== undefined) updates.schedule_value = data.schedule_value; // Recompute next_run if schedule changed if (data.schedule_type || data.schedule_value) { const updatedTask = { ...task, ...updates, }; if (updatedTask.schedule_type === 'cron') { try { const interval = CronExpressionParser.parse( updatedTask.schedule_value, { tz: TIMEZONE }, ); updates.next_run = interval.next().toISOString(); } catch { logger.warn( { taskId: data.taskId, value: updatedTask.schedule_value }, 'Invalid cron in task update', ); break; } } else if (updatedTask.schedule_type === 'interval') { const ms = parseInt(updatedTask.schedule_value, 10); if (!isNaN(ms) && ms > 0) { updates.next_run = new Date(Date.now() + ms).toISOString(); } } } updateTask(data.taskId, updates); logger.info( { taskId: data.taskId, sourceGroup, updates }, 'Task updated via IPC', ); deps.onTasksChanged(); } break; case 'refresh_groups': // Only main group can request a refresh if (isMain) { logger.info( { sourceGroup }, 'Group metadata refresh requested via IPC', ); await deps.syncGroups(true); // Write updated snapshot immediately const availableGroups = deps.getAvailableGroups(); deps.writeGroupsSnapshot( sourceGroup, true, availableGroups, new Set(Object.keys(registeredGroups)), ); } else { logger.warn( { sourceGroup }, 'Unauthorized refresh_groups attempt blocked', ); } break; case 'register_group': // Only main group can register new groups if (!isMain) { logger.warn( { sourceGroup }, 'Unauthorized register_group attempt blocked', ); break; } if (data.jid && data.name && data.folder && data.trigger) { if (!isValidGroupFolder(data.folder)) { logger.warn( { sourceGroup, folder: data.folder }, 'Invalid register_group request - unsafe folder name', ); break; } // Defense in depth: agent cannot set isMain via IPC deps.registerGroup(data.jid, { name: data.name, folder: data.folder, trigger: data.trigger, added_at: new Date().toISOString(), containerConfig: data.containerConfig, requiresTrigger: data.requiresTrigger, }); } else { logger.warn( { data }, 'Invalid register_group request - missing required fields', ); } break; default: logger.warn({ type: data.type }, 'Unknown IPC task type'); } } ================================================ FILE: src/logger.ts ================================================ import pino from 'pino'; export const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { colorize: true } }, }); // Route uncaught errors through pino so they get timestamps in stderr process.on('uncaughtException', (err) => { logger.fatal({ err }, 'Uncaught exception'); process.exit(1); }); process.on('unhandledRejection', (reason) => { logger.error({ err: reason }, 'Unhandled rejection'); }); ================================================ FILE: src/mount-security.ts ================================================ /** * Mount Security Module for NanoClaw * * Validates additional mounts against an allowlist stored OUTSIDE the project root. * This prevents container agents from modifying security configuration. * * Allowlist location: ~/.config/nanoclaw/mount-allowlist.json */ import fs from 'fs'; import os from 'os'; import path from 'path'; import pino from 'pino'; import { MOUNT_ALLOWLIST_PATH } from './config.js'; import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info', transport: { target: 'pino-pretty', options: { colorize: true } }, }); // Cache the allowlist in memory - only reloads on process restart let cachedAllowlist: MountAllowlist | null = null; let allowlistLoadError: string | null = null; /** * Default blocked patterns - paths that should never be mounted */ const DEFAULT_BLOCKED_PATTERNS = [ '.ssh', '.gnupg', '.gpg', '.aws', '.azure', '.gcloud', '.kube', '.docker', 'credentials', '.env', '.netrc', '.npmrc', '.pypirc', 'id_rsa', 'id_ed25519', 'private_key', '.secret', ]; /** * Load the mount allowlist from the external config location. * Returns null if the file doesn't exist or is invalid. * Result is cached in memory for the lifetime of the process. */ export function loadMountAllowlist(): MountAllowlist | null { if (cachedAllowlist !== null) { return cachedAllowlist; } if (allowlistLoadError !== null) { // Already tried and failed, don't spam logs return null; } try { if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) { allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`; logger.warn( { path: MOUNT_ALLOWLIST_PATH }, 'Mount allowlist not found - additional mounts will be BLOCKED. ' + 'Create the file to enable additional mounts.', ); return null; } const content = fs.readFileSync(MOUNT_ALLOWLIST_PATH, 'utf-8'); const allowlist = JSON.parse(content) as MountAllowlist; // Validate structure if (!Array.isArray(allowlist.allowedRoots)) { throw new Error('allowedRoots must be an array'); } if (!Array.isArray(allowlist.blockedPatterns)) { throw new Error('blockedPatterns must be an array'); } if (typeof allowlist.nonMainReadOnly !== 'boolean') { throw new Error('nonMainReadOnly must be a boolean'); } // Merge with default blocked patterns const mergedBlockedPatterns = [ ...new Set([...DEFAULT_BLOCKED_PATTERNS, ...allowlist.blockedPatterns]), ]; allowlist.blockedPatterns = mergedBlockedPatterns; cachedAllowlist = allowlist; logger.info( { path: MOUNT_ALLOWLIST_PATH, allowedRoots: allowlist.allowedRoots.length, blockedPatterns: allowlist.blockedPatterns.length, }, 'Mount allowlist loaded successfully', ); return cachedAllowlist; } catch (err) { allowlistLoadError = err instanceof Error ? err.message : String(err); logger.error( { path: MOUNT_ALLOWLIST_PATH, error: allowlistLoadError, }, 'Failed to load mount allowlist - additional mounts will be BLOCKED', ); return null; } } /** * Expand ~ to home directory and resolve to absolute path */ function expandPath(p: string): string { const homeDir = process.env.HOME || os.homedir(); if (p.startsWith('~/')) { return path.join(homeDir, p.slice(2)); } if (p === '~') { return homeDir; } return path.resolve(p); } /** * Get the real path, resolving symlinks. * Returns null if the path doesn't exist. */ function getRealPath(p: string): string | null { try { return fs.realpathSync(p); } catch { return null; } } /** * Check if a path matches any blocked pattern */ function matchesBlockedPattern( realPath: string, blockedPatterns: string[], ): string | null { const pathParts = realPath.split(path.sep); for (const pattern of blockedPatterns) { // Check if any path component matches the pattern for (const part of pathParts) { if (part === pattern || part.includes(pattern)) { return pattern; } } // Also check if the full path contains the pattern if (realPath.includes(pattern)) { return pattern; } } return null; } /** * Check if a real path is under an allowed root */ function findAllowedRoot( realPath: string, allowedRoots: AllowedRoot[], ): AllowedRoot | null { for (const root of allowedRoots) { const expandedRoot = expandPath(root.path); const realRoot = getRealPath(expandedRoot); if (realRoot === null) { // Allowed root doesn't exist, skip it continue; } // Check if realPath is under realRoot const relative = path.relative(realRoot, realPath); if (!relative.startsWith('..') && !path.isAbsolute(relative)) { return root; } } return null; } /** * Validate the container path to prevent escaping /workspace/extra/ */ function isValidContainerPath(containerPath: string): boolean { // Must not contain .. to prevent path traversal if (containerPath.includes('..')) { return false; } // Must not be absolute (it will be prefixed with /workspace/extra/) if (containerPath.startsWith('/')) { return false; } // Must not be empty if (!containerPath || containerPath.trim() === '') { return false; } return true; } export interface MountValidationResult { allowed: boolean; reason: string; realHostPath?: string; resolvedContainerPath?: string; effectiveReadonly?: boolean; } /** * Validate a single additional mount against the allowlist. * Returns validation result with reason. */ export function validateMount( mount: AdditionalMount, isMain: boolean, ): MountValidationResult { const allowlist = loadMountAllowlist(); // If no allowlist, block all additional mounts if (allowlist === null) { return { allowed: false, reason: `No mount allowlist configured at ${MOUNT_ALLOWLIST_PATH}`, }; } // Derive containerPath from hostPath basename if not specified const containerPath = mount.containerPath || path.basename(mount.hostPath); // Validate container path (cheap check) if (!isValidContainerPath(containerPath)) { return { allowed: false, reason: `Invalid container path: "${containerPath}" - must be relative, non-empty, and not contain ".."`, }; } // Expand and resolve the host path const expandedPath = expandPath(mount.hostPath); const realPath = getRealPath(expandedPath); if (realPath === null) { return { allowed: false, reason: `Host path does not exist: "${mount.hostPath}" (expanded: "${expandedPath}")`, }; } // Check against blocked patterns const blockedMatch = matchesBlockedPattern( realPath, allowlist.blockedPatterns, ); if (blockedMatch !== null) { return { allowed: false, reason: `Path matches blocked pattern "${blockedMatch}": "${realPath}"`, }; } // Check if under an allowed root const allowedRoot = findAllowedRoot(realPath, allowlist.allowedRoots); if (allowedRoot === null) { return { allowed: false, reason: `Path "${realPath}" is not under any allowed root. Allowed roots: ${allowlist.allowedRoots .map((r) => expandPath(r.path)) .join(', ')}`, }; } // Determine effective readonly status const requestedReadWrite = mount.readonly === false; let effectiveReadonly = true; // Default to readonly if (requestedReadWrite) { if (!isMain && allowlist.nonMainReadOnly) { // Non-main groups forced to read-only effectiveReadonly = true; logger.info( { mount: mount.hostPath, }, 'Mount forced to read-only for non-main group', ); } else if (!allowedRoot.allowReadWrite) { // Root doesn't allow read-write effectiveReadonly = true; logger.info( { mount: mount.hostPath, root: allowedRoot.path, }, 'Mount forced to read-only - root does not allow read-write', ); } else { // Read-write allowed effectiveReadonly = false; } } return { allowed: true, reason: `Allowed under root "${allowedRoot.path}"${allowedRoot.description ? ` (${allowedRoot.description})` : ''}`, realHostPath: realPath, resolvedContainerPath: containerPath, effectiveReadonly, }; } /** * Validate all additional mounts for a group. * Returns array of validated mounts (only those that passed validation). * Logs warnings for rejected mounts. */ export function validateAdditionalMounts( mounts: AdditionalMount[], groupName: string, isMain: boolean, ): Array<{ hostPath: string; containerPath: string; readonly: boolean; }> { const validatedMounts: Array<{ hostPath: string; containerPath: string; readonly: boolean; }> = []; for (const mount of mounts) { const result = validateMount(mount, isMain); if (result.allowed) { validatedMounts.push({ hostPath: result.realHostPath!, containerPath: `/workspace/extra/${result.resolvedContainerPath}`, readonly: result.effectiveReadonly!, }); logger.debug( { group: groupName, hostPath: result.realHostPath, containerPath: result.resolvedContainerPath, readonly: result.effectiveReadonly, reason: result.reason, }, 'Mount validated successfully', ); } else { logger.warn( { group: groupName, requestedPath: mount.hostPath, containerPath: mount.containerPath, reason: result.reason, }, 'Additional mount REJECTED', ); } } return validatedMounts; } /** * Generate a template allowlist file for users to customize */ export function generateAllowlistTemplate(): string { const template: MountAllowlist = { allowedRoots: [ { path: '~/projects', allowReadWrite: true, description: 'Development projects', }, { path: '~/repos', allowReadWrite: true, description: 'Git repositories', }, { path: '~/Documents/work', allowReadWrite: false, description: 'Work documents (read-only)', }, ], blockedPatterns: [ // Additional patterns beyond defaults 'password', 'secret', 'token', ], nonMainReadOnly: true, }; return JSON.stringify(template, null, 2); } ================================================ FILE: src/remote-control.test.ts ================================================ import fs from 'fs'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; // Mock config before importing the module under test vi.mock('./config.js', () => ({ DATA_DIR: '/tmp/nanoclaw-rc-test', })); // Mock child_process const spawnMock = vi.fn(); vi.mock('child_process', () => ({ spawn: (...args: any[]) => spawnMock(...args), })); import { startRemoteControl, stopRemoteControl, restoreRemoteControl, getActiveSession, _resetForTesting, _getStateFilePath, } from './remote-control.js'; // --- Helpers --- function createMockProcess(pid = 12345) { return { pid, unref: vi.fn(), kill: vi.fn(), stdin: { write: vi.fn(), end: vi.fn() }, }; } describe('remote-control', () => { const STATE_FILE = _getStateFilePath(); let readFileSyncSpy: ReturnType; let writeFileSyncSpy: ReturnType; let unlinkSyncSpy: ReturnType; let mkdirSyncSpy: ReturnType; let openSyncSpy: ReturnType; let closeSyncSpy: ReturnType; // Track what readFileSync should return for the stdout file let stdoutFileContent: string; beforeEach(() => { _resetForTesting(); spawnMock.mockReset(); stdoutFileContent = ''; // Default fs mocks mkdirSyncSpy = vi .spyOn(fs, 'mkdirSync') .mockImplementation(() => undefined as any); writeFileSyncSpy = vi .spyOn(fs, 'writeFileSync') .mockImplementation(() => {}); unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); // readFileSync: return stdoutFileContent for the stdout file, state file, etc. readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation((( p: string, ) => { if (p.endsWith('remote-control.stdout')) return stdoutFileContent; if (p.endsWith('remote-control.json')) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); } return ''; }) as any); }); afterEach(() => { _resetForTesting(); vi.restoreAllMocks(); }); // --- startRemoteControl --- describe('startRemoteControl', () => { it('spawns claude remote-control and returns the URL', async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc); // Simulate URL appearing in stdout file on first poll stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const result = await startRemoteControl('user1', 'tg:123', '/project'); expect(result).toEqual({ ok: true, url: 'https://claude.ai/code?bridge=env_abc123', }); expect(spawnMock).toHaveBeenCalledWith( 'claude', ['remote-control', '--name', 'NanoClaw Remote'], expect.objectContaining({ cwd: '/project', detached: true }), ); expect(proc.unref).toHaveBeenCalled(); }); it('uses file descriptors for stdout/stderr (not pipes)', async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); const spawnCall = spawnMock.mock.calls[0]; const options = spawnCall[2]; // stdio[0] is 'pipe' so we can write 'y' to accept the prompt expect(options.stdio[0]).toBe('pipe'); expect(typeof options.stdio[1]).toBe('number'); expect(typeof options.stdio[2]).toBe('number'); }); it('closes file descriptors in parent after spawn', async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); // Two openSync calls (stdout + stderr), two closeSync calls expect(openSyncSpy).toHaveBeenCalledTimes(2); expect(closeSyncSpy).toHaveBeenCalledTimes(2); }); it('saves state to disk after capturing URL', async () => { const proc = createMockProcess(99999); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); expect(writeFileSyncSpy).toHaveBeenCalledWith( STATE_FILE, expect.stringContaining('"pid":99999'), ); }); it('returns existing URL if session is already active', async () => { const proc = createMockProcess(); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); // Second call should return existing URL without spawning const result = await startRemoteControl('user2', 'tg:456', '/project'); expect(result).toEqual({ ok: true, url: 'https://claude.ai/code?bridge=env_existing', }); expect(spawnMock).toHaveBeenCalledTimes(1); }); it('starts new session if existing process is dead', async () => { const proc1 = createMockProcess(11111); const proc2 = createMockProcess(22222); spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); // First start: process alive, URL found const killSpy = vi .spyOn(process, 'kill') .mockImplementation((() => true) as any); stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; await startRemoteControl('user1', 'tg:123', '/project'); // Old process (11111) is dead, new process (22222) is alive killSpy.mockImplementation(((pid: number, sig: any) => { if (pid === 11111 && (sig === 0 || sig === undefined)) { throw new Error('ESRCH'); } return true; }) as any); stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n'; const result = await startRemoteControl('user1', 'tg:123', '/project'); expect(result).toEqual({ ok: true, url: 'https://claude.ai/code?bridge=env_second', }); expect(spawnMock).toHaveBeenCalledTimes(2); }); it('returns error if process exits before URL', async () => { const proc = createMockProcess(33333); spawnMock.mockReturnValue(proc); stdoutFileContent = ''; // Process is dead (poll will detect this) vi.spyOn(process, 'kill').mockImplementation((() => { throw new Error('ESRCH'); }) as any); const result = await startRemoteControl('user1', 'tg:123', '/project'); expect(result).toEqual({ ok: false, error: 'Process exited before producing URL', }); }); it('times out if URL never appears', async () => { vi.useFakeTimers(); const proc = createMockProcess(44444); spawnMock.mockReturnValue(proc); stdoutFileContent = 'no url here'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const promise = startRemoteControl('user1', 'tg:123', '/project'); // Advance past URL_TIMEOUT_MS (30s), with enough steps for polls for (let i = 0; i < 160; i++) { await vi.advanceTimersByTimeAsync(200); } const result = await promise; expect(result).toEqual({ ok: false, error: 'Timed out waiting for Remote Control URL', }); vi.useRealTimers(); }); it('returns error if spawn throws', async () => { spawnMock.mockImplementation(() => { throw new Error('ENOENT'); }); const result = await startRemoteControl('user1', 'tg:123', '/project'); expect(result).toEqual({ ok: false, error: 'Failed to start: ENOENT', }); }); }); // --- stopRemoteControl --- describe('stopRemoteControl', () => { it('kills the process and clears state', async () => { const proc = createMockProcess(55555); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; const killSpy = vi .spyOn(process, 'kill') .mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); const result = stopRemoteControl(); expect(result).toEqual({ ok: true }); expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM'); expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE); expect(getActiveSession()).toBeNull(); }); it('returns error when no session is active', () => { const result = stopRemoteControl(); expect(result).toEqual({ ok: false, error: 'No active Remote Control session', }); }); }); // --- restoreRemoteControl --- describe('restoreRemoteControl', () => { it('restores session if state file exists and process is alive', () => { const session = { pid: 77777, url: 'https://claude.ai/code?bridge=env_restored', startedBy: 'user1', startedInChat: 'tg:123', startedAt: '2026-01-01T00:00:00.000Z', }; readFileSyncSpy.mockImplementation(((p: string) => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); vi.spyOn(process, 'kill').mockImplementation((() => true) as any); restoreRemoteControl(); const active = getActiveSession(); expect(active).not.toBeNull(); expect(active!.pid).toBe(77777); expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored'); }); it('clears state if process is dead', () => { const session = { pid: 88888, url: 'https://claude.ai/code?bridge=env_dead', startedBy: 'user1', startedInChat: 'tg:123', startedAt: '2026-01-01T00:00:00.000Z', }; readFileSyncSpy.mockImplementation(((p: string) => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); vi.spyOn(process, 'kill').mockImplementation((() => { throw new Error('ESRCH'); }) as any); restoreRemoteControl(); expect(getActiveSession()).toBeNull(); expect(unlinkSyncSpy).toHaveBeenCalled(); }); it('does nothing if no state file exists', () => { // readFileSyncSpy default throws ENOENT for .json restoreRemoteControl(); expect(getActiveSession()).toBeNull(); }); it('clears state on corrupted JSON', () => { readFileSyncSpy.mockImplementation(((p: string) => { if (p.endsWith('remote-control.json')) return 'not json{{{'; return ''; }) as any); restoreRemoteControl(); expect(getActiveSession()).toBeNull(); expect(unlinkSyncSpy).toHaveBeenCalled(); }); // ** This is the key integration test: restore → stop must work ** it('stopRemoteControl works after restoreRemoteControl', () => { const session = { pid: 77777, url: 'https://claude.ai/code?bridge=env_restored', startedBy: 'user1', startedInChat: 'tg:123', startedAt: '2026-01-01T00:00:00.000Z', }; readFileSyncSpy.mockImplementation(((p: string) => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); const killSpy = vi .spyOn(process, 'kill') .mockImplementation((() => true) as any); restoreRemoteControl(); expect(getActiveSession()).not.toBeNull(); const result = stopRemoteControl(); expect(result).toEqual({ ok: true }); expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM'); expect(unlinkSyncSpy).toHaveBeenCalled(); expect(getActiveSession()).toBeNull(); }); it('startRemoteControl returns restored URL without spawning', () => { const session = { pid: 77777, url: 'https://claude.ai/code?bridge=env_restored', startedBy: 'user1', startedInChat: 'tg:123', startedAt: '2026-01-01T00:00:00.000Z', }; readFileSyncSpy.mockImplementation(((p: string) => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); vi.spyOn(process, 'kill').mockImplementation((() => true) as any); restoreRemoteControl(); return startRemoteControl('user2', 'tg:456', '/project').then( (result) => { expect(result).toEqual({ ok: true, url: 'https://claude.ai/code?bridge=env_restored', }); expect(spawnMock).not.toHaveBeenCalled(); }, ); }); }); }); ================================================ FILE: src/remote-control.ts ================================================ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { DATA_DIR } from './config.js'; import { logger } from './logger.js'; interface RemoteControlSession { pid: number; url: string; startedBy: string; startedInChat: string; startedAt: string; } let activeSession: RemoteControlSession | null = null; const URL_REGEX = /https:\/\/claude\.ai\/code\S+/; const URL_TIMEOUT_MS = 30_000; const URL_POLL_MS = 200; const STATE_FILE = path.join(DATA_DIR, 'remote-control.json'); const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout'); const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr'); function saveState(session: RemoteControlSession): void { fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true }); fs.writeFileSync(STATE_FILE, JSON.stringify(session)); } function clearState(): void { try { fs.unlinkSync(STATE_FILE); } catch { // ignore } } function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; } catch { return false; } } /** * Restore session from disk on startup. * If the process is still alive, adopt it. Otherwise, clean up. */ export function restoreRemoteControl(): void { let data: string; try { data = fs.readFileSync(STATE_FILE, 'utf-8'); } catch { return; } try { const session: RemoteControlSession = JSON.parse(data); if (session.pid && isProcessAlive(session.pid)) { activeSession = session; logger.info( { pid: session.pid, url: session.url }, 'Restored Remote Control session from previous run', ); } else { clearState(); } } catch { clearState(); } } export function getActiveSession(): RemoteControlSession | null { return activeSession; } /** @internal — exported for testing only */ export function _resetForTesting(): void { activeSession = null; } /** @internal — exported for testing only */ export function _getStateFilePath(): string { return STATE_FILE; } export async function startRemoteControl( sender: string, chatJid: string, cwd: string, ): Promise<{ ok: true; url: string } | { ok: false; error: string }> { if (activeSession) { // Verify the process is still alive if (isProcessAlive(activeSession.pid)) { return { ok: true, url: activeSession.url }; } // Process died — clean up and start a new one activeSession = null; clearState(); } // Redirect stdout/stderr to files so the process has no pipes to the parent. // This prevents SIGPIPE when NanoClaw restarts. fs.mkdirSync(DATA_DIR, { recursive: true }); const stdoutFd = fs.openSync(STDOUT_FILE, 'w'); const stderrFd = fs.openSync(STDERR_FILE, 'w'); let proc; try { proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], { cwd, stdio: ['pipe', stdoutFd, stderrFd], detached: true, }); } catch (err: any) { fs.closeSync(stdoutFd); fs.closeSync(stderrFd); return { ok: false, error: `Failed to start: ${err.message}` }; } // Auto-accept the "Enable Remote Control?" prompt if (proc.stdin) { proc.stdin.write('y\n'); proc.stdin.end(); } // Close FDs in the parent — the child inherited copies fs.closeSync(stdoutFd); fs.closeSync(stderrFd); // Fully detach from parent proc.unref(); const pid = proc.pid; if (!pid) { return { ok: false, error: 'Failed to get process PID' }; } // Poll the stdout file for the URL return new Promise((resolve) => { const startTime = Date.now(); const poll = () => { // Check if process died if (!isProcessAlive(pid)) { resolve({ ok: false, error: 'Process exited before producing URL' }); return; } // Check for URL in stdout file let content = ''; try { content = fs.readFileSync(STDOUT_FILE, 'utf-8'); } catch { // File might not have content yet } const match = content.match(URL_REGEX); if (match) { const session: RemoteControlSession = { pid, url: match[0], startedBy: sender, startedInChat: chatJid, startedAt: new Date().toISOString(), }; activeSession = session; saveState(session); logger.info( { url: match[0], pid, sender, chatJid }, 'Remote Control session started', ); resolve({ ok: true, url: match[0] }); return; } // Timeout check if (Date.now() - startTime >= URL_TIMEOUT_MS) { try { process.kill(-pid, 'SIGTERM'); } catch { try { process.kill(pid, 'SIGTERM'); } catch { // already dead } } resolve({ ok: false, error: 'Timed out waiting for Remote Control URL', }); return; } setTimeout(poll, URL_POLL_MS); }; poll(); }); } export function stopRemoteControl(): | { ok: true; } | { ok: false; error: string } { if (!activeSession) { return { ok: false, error: 'No active Remote Control session' }; } const { pid } = activeSession; try { process.kill(pid, 'SIGTERM'); } catch { // already dead } activeSession = null; clearState(); logger.info({ pid }, 'Remote Control session stopped'); return { ok: true }; } ================================================ FILE: src/router.ts ================================================ import { Channel, NewMessage } from './types.js'; import { formatLocalTime } from './timezone.js'; export function escapeXml(s: string): string { if (!s) return ''; return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } export function formatMessages( messages: NewMessage[], timezone: string, ): string { const lines = messages.map((m) => { const displayTime = formatLocalTime(m.timestamp, timezone); return `${escapeXml(m.content)}`; }); const header = `\n`; return `${header}\n${lines.join('\n')}\n`; } export function stripInternalTags(text: string): string { return text.replace(/[\s\S]*?<\/internal>/g, '').trim(); } export function formatOutbound(rawText: string): string { const text = stripInternalTags(rawText); if (!text) return ''; return text; } export function routeOutbound( channels: Channel[], jid: string, text: string, ): Promise { const channel = channels.find((c) => c.ownsJid(jid) && c.isConnected()); if (!channel) throw new Error(`No channel for JID: ${jid}`); return channel.sendMessage(jid, text); } export function findChannel( channels: Channel[], jid: string, ): Channel | undefined { return channels.find((c) => c.ownsJid(jid)); } ================================================ FILE: src/routing.test.ts ================================================ import { describe, it, expect, beforeEach } from 'vitest'; import { _initTestDatabase, getAllChats, storeChatMetadata } from './db.js'; import { getAvailableGroups, _setRegisteredGroups } from './index.js'; beforeEach(() => { _initTestDatabase(); _setRegisteredGroups({}); }); // --- JID ownership patterns --- describe('JID ownership patterns', () => { // These test the patterns that will become ownsJid() on the Channel interface it('WhatsApp group JID: ends with @g.us', () => { const jid = '12345678@g.us'; expect(jid.endsWith('@g.us')).toBe(true); }); it('WhatsApp DM JID: ends with @s.whatsapp.net', () => { const jid = '12345678@s.whatsapp.net'; expect(jid.endsWith('@s.whatsapp.net')).toBe(true); }); }); // --- getAvailableGroups --- describe('getAvailableGroups', () => { it('returns only groups, excludes DMs', () => { storeChatMetadata( 'group1@g.us', '2024-01-01T00:00:01.000Z', 'Group 1', 'whatsapp', true, ); storeChatMetadata( 'user@s.whatsapp.net', '2024-01-01T00:00:02.000Z', 'User DM', 'whatsapp', false, ); storeChatMetadata( 'group2@g.us', '2024-01-01T00:00:03.000Z', 'Group 2', 'whatsapp', true, ); const groups = getAvailableGroups(); expect(groups).toHaveLength(2); expect(groups.map((g) => g.jid)).toContain('group1@g.us'); expect(groups.map((g) => g.jid)).toContain('group2@g.us'); expect(groups.map((g) => g.jid)).not.toContain('user@s.whatsapp.net'); }); it('excludes __group_sync__ sentinel', () => { storeChatMetadata('__group_sync__', '2024-01-01T00:00:00.000Z'); storeChatMetadata( 'group@g.us', '2024-01-01T00:00:01.000Z', 'Group', 'whatsapp', true, ); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); expect(groups[0].jid).toBe('group@g.us'); }); it('marks registered groups correctly', () => { storeChatMetadata( 'reg@g.us', '2024-01-01T00:00:01.000Z', 'Registered', 'whatsapp', true, ); storeChatMetadata( 'unreg@g.us', '2024-01-01T00:00:02.000Z', 'Unregistered', 'whatsapp', true, ); _setRegisteredGroups({ 'reg@g.us': { name: 'Registered', folder: 'registered', trigger: '@Andy', added_at: '2024-01-01T00:00:00.000Z', }, }); const groups = getAvailableGroups(); const reg = groups.find((g) => g.jid === 'reg@g.us'); const unreg = groups.find((g) => g.jid === 'unreg@g.us'); expect(reg?.isRegistered).toBe(true); expect(unreg?.isRegistered).toBe(false); }); it('returns groups ordered by most recent activity', () => { storeChatMetadata( 'old@g.us', '2024-01-01T00:00:01.000Z', 'Old', 'whatsapp', true, ); storeChatMetadata( 'new@g.us', '2024-01-01T00:00:05.000Z', 'New', 'whatsapp', true, ); storeChatMetadata( 'mid@g.us', '2024-01-01T00:00:03.000Z', 'Mid', 'whatsapp', true, ); const groups = getAvailableGroups(); expect(groups[0].jid).toBe('new@g.us'); expect(groups[1].jid).toBe('mid@g.us'); expect(groups[2].jid).toBe('old@g.us'); }); it('excludes non-group chats regardless of JID format', () => { // Unknown JID format stored without is_group should not appear storeChatMetadata( 'unknown-format-123', '2024-01-01T00:00:01.000Z', 'Unknown', ); // Explicitly non-group with unusual JID storeChatMetadata( 'custom:abc', '2024-01-01T00:00:02.000Z', 'Custom DM', 'custom', false, ); // A real group for contrast storeChatMetadata( 'group@g.us', '2024-01-01T00:00:03.000Z', 'Group', 'whatsapp', true, ); const groups = getAvailableGroups(); expect(groups).toHaveLength(1); expect(groups[0].jid).toBe('group@g.us'); }); it('returns empty array when no chats exist', () => { const groups = getAvailableGroups(); expect(groups).toHaveLength(0); }); }); ================================================ FILE: src/sender-allowlist.test.ts ================================================ import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { isSenderAllowed, isTriggerAllowed, loadSenderAllowlist, SenderAllowlistConfig, shouldDropMessage, } from './sender-allowlist.js'; let tmpDir: string; function cfgPath(name = 'sender-allowlist.json'): string { return path.join(tmpDir, name); } function writeConfig(config: unknown, name?: string): string { const p = cfgPath(name); fs.writeFileSync(p, JSON.stringify(config)); return p; } beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); describe('loadSenderAllowlist', () => { it('returns allow-all defaults when file is missing', () => { const cfg = loadSenderAllowlist(cfgPath()); expect(cfg.default.allow).toBe('*'); expect(cfg.default.mode).toBe('trigger'); expect(cfg.logDenied).toBe(true); }); it('loads allow=* config', () => { const p = writeConfig({ default: { allow: '*', mode: 'trigger' }, chats: {}, logDenied: false, }); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toBe('*'); expect(cfg.logDenied).toBe(false); }); it('loads allow=[] (deny all)', () => { const p = writeConfig({ default: { allow: [], mode: 'trigger' }, chats: {}, }); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toEqual([]); }); it('loads allow=[list]', () => { const p = writeConfig({ default: { allow: ['alice', 'bob'], mode: 'drop' }, chats: {}, }); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toEqual(['alice', 'bob']); expect(cfg.default.mode).toBe('drop'); }); it('per-chat override beats default', () => { const p = writeConfig({ default: { allow: '*', mode: 'trigger' }, chats: { 'group-a': { allow: ['alice'], mode: 'drop' } }, }); const cfg = loadSenderAllowlist(p); expect(cfg.chats['group-a'].allow).toEqual(['alice']); expect(cfg.chats['group-a'].mode).toBe('drop'); }); it('returns allow-all on invalid JSON', () => { const p = cfgPath(); fs.writeFileSync(p, '{ not valid json }}}'); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toBe('*'); }); it('returns allow-all on invalid schema', () => { const p = writeConfig({ default: { oops: true } }); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toBe('*'); }); it('rejects non-string allow array items', () => { const p = writeConfig({ default: { allow: [123, null, true], mode: 'trigger' }, chats: {}, }); const cfg = loadSenderAllowlist(p); expect(cfg.default.allow).toBe('*'); // falls back to default }); it('skips invalid per-chat entries', () => { const p = writeConfig({ default: { allow: '*', mode: 'trigger' }, chats: { good: { allow: ['alice'], mode: 'trigger' }, bad: { allow: 123 }, }, }); const cfg = loadSenderAllowlist(p); expect(cfg.chats['good']).toBeDefined(); expect(cfg.chats['bad']).toBeUndefined(); }); }); describe('isSenderAllowed', () => { it('allow=* allows any sender', () => { const cfg: SenderAllowlistConfig = { default: { allow: '*', mode: 'trigger' }, chats: {}, logDenied: true, }; expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true); }); it('allow=[] denies any sender', () => { const cfg: SenderAllowlistConfig = { default: { allow: [], mode: 'trigger' }, chats: {}, logDenied: true, }; expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false); }); it('allow=[list] allows exact match only', () => { const cfg: SenderAllowlistConfig = { default: { allow: ['alice', 'bob'], mode: 'trigger' }, chats: {}, logDenied: true, }; expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true); expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false); }); it('uses per-chat entry over default', () => { const cfg: SenderAllowlistConfig = { default: { allow: '*', mode: 'trigger' }, chats: { g1: { allow: ['alice'], mode: 'trigger' } }, logDenied: true, }; expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false); expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true); }); }); describe('shouldDropMessage', () => { it('returns false for trigger mode', () => { const cfg: SenderAllowlistConfig = { default: { allow: '*', mode: 'trigger' }, chats: {}, logDenied: true, }; expect(shouldDropMessage('g1', cfg)).toBe(false); }); it('returns true for drop mode', () => { const cfg: SenderAllowlistConfig = { default: { allow: '*', mode: 'drop' }, chats: {}, logDenied: true, }; expect(shouldDropMessage('g1', cfg)).toBe(true); }); it('per-chat mode override', () => { const cfg: SenderAllowlistConfig = { default: { allow: '*', mode: 'trigger' }, chats: { g1: { allow: '*', mode: 'drop' } }, logDenied: true, }; expect(shouldDropMessage('g1', cfg)).toBe(true); expect(shouldDropMessage('g2', cfg)).toBe(false); }); }); describe('isTriggerAllowed', () => { it('allows trigger for allowed sender', () => { const cfg: SenderAllowlistConfig = { default: { allow: ['alice'], mode: 'trigger' }, chats: {}, logDenied: false, }; expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true); }); it('denies trigger for disallowed sender', () => { const cfg: SenderAllowlistConfig = { default: { allow: ['alice'], mode: 'trigger' }, chats: {}, logDenied: false, }; expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false); }); it('logs when logDenied is true', () => { const cfg: SenderAllowlistConfig = { default: { allow: ['alice'], mode: 'trigger' }, chats: {}, logDenied: true, }; isTriggerAllowed('g1', 'eve', cfg); // Logger.debug is called — we just verify no crash; logger is a real pino instance }); }); ================================================ FILE: src/sender-allowlist.ts ================================================ import fs from 'fs'; import { SENDER_ALLOWLIST_PATH } from './config.js'; import { logger } from './logger.js'; export interface ChatAllowlistEntry { allow: '*' | string[]; mode: 'trigger' | 'drop'; } export interface SenderAllowlistConfig { default: ChatAllowlistEntry; chats: Record; logDenied: boolean; } const DEFAULT_CONFIG: SenderAllowlistConfig = { default: { allow: '*', mode: 'trigger' }, chats: {}, logDenied: true, }; function isValidEntry(entry: unknown): entry is ChatAllowlistEntry { if (!entry || typeof entry !== 'object') return false; const e = entry as Record; const validAllow = e.allow === '*' || (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string')); const validMode = e.mode === 'trigger' || e.mode === 'drop'; return validAllow && validMode; } export function loadSenderAllowlist( pathOverride?: string, ): SenderAllowlistConfig { const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH; let raw: string; try { raw = fs.readFileSync(filePath, 'utf-8'); } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG; logger.warn( { err, path: filePath }, 'sender-allowlist: cannot read config', ); return DEFAULT_CONFIG; } let parsed: unknown; try { parsed = JSON.parse(raw); } catch { logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON'); return DEFAULT_CONFIG; } const obj = parsed as Record; if (!isValidEntry(obj.default)) { logger.warn( { path: filePath }, 'sender-allowlist: invalid or missing default entry', ); return DEFAULT_CONFIG; } const chats: Record = {}; if (obj.chats && typeof obj.chats === 'object') { for (const [jid, entry] of Object.entries( obj.chats as Record, )) { if (isValidEntry(entry)) { chats[jid] = entry; } else { logger.warn( { jid, path: filePath }, 'sender-allowlist: skipping invalid chat entry', ); } } } return { default: obj.default as ChatAllowlistEntry, chats, logDenied: obj.logDenied !== false, }; } function getEntry( chatJid: string, cfg: SenderAllowlistConfig, ): ChatAllowlistEntry { return cfg.chats[chatJid] ?? cfg.default; } export function isSenderAllowed( chatJid: string, sender: string, cfg: SenderAllowlistConfig, ): boolean { const entry = getEntry(chatJid, cfg); if (entry.allow === '*') return true; return entry.allow.includes(sender); } export function shouldDropMessage( chatJid: string, cfg: SenderAllowlistConfig, ): boolean { return getEntry(chatJid, cfg).mode === 'drop'; } export function isTriggerAllowed( chatJid: string, sender: string, cfg: SenderAllowlistConfig, ): boolean { const allowed = isSenderAllowed(chatJid, sender, cfg); if (!allowed && cfg.logDenied) { logger.debug( { chatJid, sender }, 'sender-allowlist: trigger denied for sender', ); } return allowed; } ================================================ FILE: src/task-scheduler.test.ts ================================================ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { _initTestDatabase, createTask, getTaskById } from './db.js'; import { _resetSchedulerLoopForTests, computeNextRun, startSchedulerLoop, } from './task-scheduler.js'; describe('task scheduler', () => { beforeEach(() => { _initTestDatabase(); _resetSchedulerLoopForTests(); vi.useFakeTimers(); }); afterEach(() => { vi.useRealTimers(); }); it('pauses due tasks with invalid group folders to prevent retry churn', async () => { createTask({ id: 'task-invalid-folder', group_folder: '../../outside', chat_jid: 'bad@g.us', prompt: 'run', schedule_type: 'once', schedule_value: '2026-02-22T00:00:00.000Z', context_mode: 'isolated', next_run: new Date(Date.now() - 60_000).toISOString(), status: 'active', created_at: '2026-02-22T00:00:00.000Z', }); const enqueueTask = vi.fn( (_groupJid: string, _taskId: string, fn: () => Promise) => { void fn(); }, ); startSchedulerLoop({ registeredGroups: () => ({}), getSessions: () => ({}), queue: { enqueueTask } as any, onProcess: () => {}, sendMessage: async () => {}, }); await vi.advanceTimersByTimeAsync(10); const task = getTaskById('task-invalid-folder'); expect(task?.status).toBe('paused'); }); it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => { const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago const task = { id: 'drift-test', group_folder: 'test', chat_jid: 'test@g.us', prompt: 'test', schedule_type: 'interval' as const, schedule_value: '60000', // 1 minute context_mode: 'isolated' as const, next_run: scheduledTime, last_run: null, last_result: null, status: 'active' as const, created_at: '2026-01-01T00:00:00.000Z', }; const nextRun = computeNextRun(task); expect(nextRun).not.toBeNull(); // Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s const expected = new Date(scheduledTime).getTime() + 60000; expect(new Date(nextRun!).getTime()).toBe(expected); }); it('computeNextRun returns null for once-tasks', () => { const task = { id: 'once-test', group_folder: 'test', chat_jid: 'test@g.us', prompt: 'test', schedule_type: 'once' as const, schedule_value: '2026-01-01T00:00:00.000Z', context_mode: 'isolated' as const, next_run: new Date(Date.now() - 1000).toISOString(), last_run: null, last_result: null, status: 'active' as const, created_at: '2026-01-01T00:00:00.000Z', }; expect(computeNextRun(task)).toBeNull(); }); it('computeNextRun skips missed intervals without infinite loop', () => { // Task was due 10 intervals ago (missed) const ms = 60000; const missedBy = ms * 10; const scheduledTime = new Date(Date.now() - missedBy).toISOString(); const task = { id: 'skip-test', group_folder: 'test', chat_jid: 'test@g.us', prompt: 'test', schedule_type: 'interval' as const, schedule_value: String(ms), context_mode: 'isolated' as const, next_run: scheduledTime, last_run: null, last_result: null, status: 'active' as const, created_at: '2026-01-01T00:00:00.000Z', }; const nextRun = computeNextRun(task); expect(nextRun).not.toBeNull(); // Must be in the future expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now()); // Must be aligned to the original schedule grid const offset = (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms; expect(offset).toBe(0); }); }); ================================================ FILE: src/task-scheduler.ts ================================================ import { ChildProcess } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; import { ContainerOutput, runContainerAgent, writeTasksSnapshot, } from './container-runner.js'; import { getAllTasks, getDueTasks, getTaskById, logTaskRun, updateTask, updateTaskAfterRun, } from './db.js'; import { GroupQueue } from './group-queue.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; import { RegisteredGroup, ScheduledTask } from './types.js'; /** * Compute the next run time for a recurring task, anchored to the * task's scheduled time rather than Date.now() to prevent cumulative * drift on interval-based tasks. * * Co-authored-by: @community-pr-601 */ export function computeNextRun(task: ScheduledTask): string | null { if (task.schedule_type === 'once') return null; const now = Date.now(); if (task.schedule_type === 'cron') { const interval = CronExpressionParser.parse(task.schedule_value, { tz: TIMEZONE, }); return interval.next().toISOString(); } if (task.schedule_type === 'interval') { const ms = parseInt(task.schedule_value, 10); if (!ms || ms <= 0) { // Guard against malformed interval that would cause an infinite loop logger.warn( { taskId: task.id, value: task.schedule_value }, 'Invalid interval value', ); return new Date(now + 60_000).toISOString(); } // Anchor to the scheduled time, not now, to prevent drift. // Skip past any missed intervals so we always land in the future. let next = new Date(task.next_run!).getTime() + ms; while (next <= now) { next += ms; } return new Date(next).toISOString(); } return null; } export interface SchedulerDependencies { registeredGroups: () => Record; getSessions: () => Record; queue: GroupQueue; onProcess: ( groupJid: string, proc: ChildProcess, containerName: string, groupFolder: string, ) => void; sendMessage: (jid: string, text: string) => Promise; } async function runTask( task: ScheduledTask, deps: SchedulerDependencies, ): Promise { const startTime = Date.now(); let groupDir: string; try { groupDir = resolveGroupFolderPath(task.group_folder); } catch (err) { const error = err instanceof Error ? err.message : String(err); // Stop retry churn for malformed legacy rows. updateTask(task.id, { status: 'paused' }); logger.error( { taskId: task.id, groupFolder: task.group_folder, error }, 'Task has invalid group folder', ); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), duration_ms: Date.now() - startTime, status: 'error', result: null, error, }); return; } fs.mkdirSync(groupDir, { recursive: true }); logger.info( { taskId: task.id, group: task.group_folder }, 'Running scheduled task', ); const groups = deps.registeredGroups(); const group = Object.values(groups).find( (g) => g.folder === task.group_folder, ); if (!group) { logger.error( { taskId: task.id, groupFolder: task.group_folder }, 'Group not found for task', ); logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), duration_ms: Date.now() - startTime, status: 'error', result: null, error: `Group not found: ${task.group_folder}`, }); return; } // Update tasks snapshot for container to read (filtered by group) const isMain = group.isMain === true; const tasks = getAllTasks(); writeTasksSnapshot( task.group_folder, isMain, tasks.map((t) => ({ id: t.id, groupFolder: t.group_folder, prompt: t.prompt, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, next_run: t.next_run, })), ); let result: string | null = null; let error: string | null = null; // For group context mode, use the group's current session const sessions = deps.getSessions(); const sessionId = task.context_mode === 'group' ? sessions[task.group_folder] : undefined; // After the task produces a result, close the container promptly. // Tasks are single-turn — no need to wait IDLE_TIMEOUT (30 min) for the // query loop to time out. A short delay handles any final MCP calls. const TASK_CLOSE_DELAY_MS = 10000; let closeTimer: ReturnType | null = null; const scheduleClose = () => { if (closeTimer) return; // already scheduled closeTimer = setTimeout(() => { logger.debug({ taskId: task.id }, 'Closing task container after result'); deps.queue.closeStdin(task.chat_jid); }, TASK_CLOSE_DELAY_MS); }; try { const output = await runContainerAgent( group, { prompt: task.prompt, sessionId, groupFolder: task.group_folder, chatJid: task.chat_jid, isMain, isScheduledTask: true, assistantName: ASSISTANT_NAME, }, (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), async (streamedOutput: ContainerOutput) => { if (streamedOutput.result) { result = streamedOutput.result; // Forward result to user (sendMessage handles formatting) await deps.sendMessage(task.chat_jid, streamedOutput.result); scheduleClose(); } if (streamedOutput.status === 'success') { deps.queue.notifyIdle(task.chat_jid); scheduleClose(); // Close promptly even when result is null (e.g. IPC-only tasks) } if (streamedOutput.status === 'error') { error = streamedOutput.error || 'Unknown error'; } }, ); if (closeTimer) clearTimeout(closeTimer); if (output.status === 'error') { error = output.error || 'Unknown error'; } else if (output.result) { // Result was already forwarded to the user via the streaming callback above result = output.result; } logger.info( { taskId: task.id, durationMs: Date.now() - startTime }, 'Task completed', ); } catch (err) { if (closeTimer) clearTimeout(closeTimer); error = err instanceof Error ? err.message : String(err); logger.error({ taskId: task.id, error }, 'Task failed'); } const durationMs = Date.now() - startTime; logTaskRun({ task_id: task.id, run_at: new Date().toISOString(), duration_ms: durationMs, status: error ? 'error' : 'success', result, error, }); const nextRun = computeNextRun(task); const resultSummary = error ? `Error: ${error}` : result ? result.slice(0, 200) : 'Completed'; updateTaskAfterRun(task.id, nextRun, resultSummary); } let schedulerRunning = false; export function startSchedulerLoop(deps: SchedulerDependencies): void { if (schedulerRunning) { logger.debug('Scheduler loop already running, skipping duplicate start'); return; } schedulerRunning = true; logger.info('Scheduler loop started'); const loop = async () => { try { const dueTasks = getDueTasks(); if (dueTasks.length > 0) { logger.info({ count: dueTasks.length }, 'Found due tasks'); } for (const task of dueTasks) { // Re-check task status in case it was paused/cancelled const currentTask = getTaskById(task.id); if (!currentTask || currentTask.status !== 'active') { continue; } deps.queue.enqueueTask(currentTask.chat_jid, currentTask.id, () => runTask(currentTask, deps), ); } } catch (err) { logger.error({ err }, 'Error in scheduler loop'); } setTimeout(loop, SCHEDULER_POLL_INTERVAL); }; loop(); } /** @internal - for tests only. */ export function _resetSchedulerLoopForTests(): void { schedulerRunning = false; } ================================================ FILE: src/timezone.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { formatLocalTime } from './timezone.js'; // --- formatLocalTime --- describe('formatLocalTime', () => { it('converts UTC to local time display', () => { // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM const result = formatLocalTime( '2026-02-04T18:30:00.000Z', 'America/New_York', ); expect(result).toContain('1:30'); expect(result).toContain('PM'); expect(result).toContain('Feb'); expect(result).toContain('2026'); }); it('handles different timezones', () => { // Same UTC time should produce different local times const utc = '2026-06-15T12:00:00.000Z'; const ny = formatLocalTime(utc, 'America/New_York'); const tokyo = formatLocalTime(utc, 'Asia/Tokyo'); // NY is UTC-4 in summer (EDT), Tokyo is UTC+9 expect(ny).toContain('8:00'); expect(tokyo).toContain('9:00'); }); }); ================================================ FILE: src/timezone.ts ================================================ /** * Convert a UTC ISO timestamp to a localized display string. * Uses the Intl API (no external dependencies). */ export function formatLocalTime(utcIso: string, timezone: string): string { const date = new Date(utcIso); return date.toLocaleString('en-US', { timeZone: timezone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); } ================================================ FILE: src/types.ts ================================================ export interface AdditionalMount { hostPath: string; // Absolute path on host (supports ~ for home) containerPath?: string; // Optional — defaults to basename of hostPath. Mounted at /workspace/extra/{value} readonly?: boolean; // Default: true for safety } /** * Mount Allowlist - Security configuration for additional mounts * This file should be stored at ~/.config/nanoclaw/mount-allowlist.json * and is NOT mounted into any container, making it tamper-proof from agents. */ export interface MountAllowlist { // Directories that can be mounted into containers allowedRoots: AllowedRoot[]; // Glob patterns for paths that should never be mounted (e.g., ".ssh", ".gnupg") blockedPatterns: string[]; // If true, non-main groups can only mount read-only regardless of config nonMainReadOnly: boolean; } export interface AllowedRoot { // Absolute path or ~ for home (e.g., "~/projects", "/var/repos") path: string; // Whether read-write mounts are allowed under this root allowReadWrite: boolean; // Optional description for documentation description?: string; } export interface ContainerConfig { additionalMounts?: AdditionalMount[]; timeout?: number; // Default: 300000 (5 minutes) } export interface RegisteredGroup { name: string; folder: string; trigger: string; added_at: string; containerConfig?: ContainerConfig; requiresTrigger?: boolean; // Default: true for groups, false for solo chats isMain?: boolean; // True for the main control group (no trigger, elevated privileges) } export interface NewMessage { id: string; chat_jid: string; sender: string; sender_name: string; content: string; timestamp: string; is_from_me?: boolean; is_bot_message?: boolean; } export interface ScheduledTask { id: string; group_folder: string; chat_jid: string; prompt: string; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; context_mode: 'group' | 'isolated'; next_run: string | null; last_run: string | null; last_result: string | null; status: 'active' | 'paused' | 'completed'; created_at: string; } export interface TaskRunLog { task_id: string; run_at: string; duration_ms: number; status: 'success' | 'error'; result: string | null; error: string | null; } // --- Channel abstraction --- export interface Channel { name: string; connect(): Promise; sendMessage(jid: string, text: string): Promise; isConnected(): boolean; ownsJid(jid: string): boolean; disconnect(): Promise; // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; } // Callback type that channels use to deliver inbound messages export type OnInboundMessage = (chatJid: string, message: NewMessage) => void; // Callback for chat metadata discovery. // name is optional — channels that deliver names inline (Telegram) pass it here; // channels that sync names separately (via syncGroups) omit it. export type OnChatMetadata = ( chatJid: string, timestamp: string, name?: string, channel?: string, isGroup?: boolean, ) => void; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "declaration": true, "declarationMap": true, "sourceMap": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], }, }); ================================================ FILE: vitest.skills.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { include: ['.claude/skills/**/tests/*.test.ts'], }, });