refactor: remove legacy container and non-discord remnants

This commit is contained in:
Eyejoker
2026-03-20 01:07:46 +09:00
parent ea09560128
commit bb0628e8f4
82 changed files with 2712 additions and 10523 deletions

View File

@@ -1,135 +1,46 @@
---
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.
description: Verify or port the existing /compact session command in current NanoClaw.
---
# 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.
현재 NanoClaw에는 `/compact`가 이미 들어 있습니다. 이 스킬은 새로 브랜치를 머지하는 용도보다, 오래된 포크에 같은 기능을 옮기거나 리팩터링 뒤 동작을 검증하는 체크리스트로 씁니다.
**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
- `src/session-commands.ts` - 명령 파싱, 권한 검사, pre-compact 처리
- `src/index.ts` - 메시지 루프에서 세션 명령을 가로채는 호출부
- `runners/agent-runner/src/index.ts` - Claude Code 쪽 compaction 처리
- `runners/codex-runner/src/index.ts` - Codex 쪽 compaction 처리
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
## 확인할 동작
1. `/compact`가 정확히 명령으로 인식되는지
2. 메인 그룹 또는 admin/trusted sender만 허용되는지
3. `/compact` 전에 들어온 메시지를 먼저 세션에 반영하는지
4. compaction 후 세션이 이어지고 `newSessionId`가 갱신되는지
5. 대화 아카이브가 `groups/<folder>/conversations/`에 남는지
## 포팅할 때 원칙
- 예전 skill branch를 다시 머지하지 말고 현재 트리의 관련 파일을 기준으로 옮깁니다
- Claude 러너와 Codex 러너 양쪽을 같이 봅니다
- `/clear``/compact`의 의미를 섞지 않습니다
## 검증
```bash
npm run typecheck
npm test
npm run build:runners
npm run build
```
### Rebuild container
그 다음 디스코드에서 확인합니다.
```bash
./container/build.sh
```
- 메인 채널에서 `/compact`
- 비메인 채널에서 일반 사용자 `/compact` 또는 멘션 포함 `/compact`
- 비메인 채널에서 admin/trusted sender의 `/compact`
### 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: `@<assistant> /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: `@<assistant> /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 `@<assistant> /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 <your-fork> /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 @<assistant> /compact from non-main as non-admin, verify denial
# Manual: send @<assistant> /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.
정상이라면 비권한 사용자는 거부되고, 권한 있는 쪽은 세션이 압축된 뒤 계속 이어집니다.

View File

@@ -1,212 +1,84 @@
---
name: add-discord
description: Add Discord bot channel integration to NanoClaw.
description: Finish Discord bot setup and registration for NanoClaw.
---
# Add Discord Channel
This skill adds Discord support to NanoClaw, then walks through interactive setup.
현재 저장소에는 디스코드 채널 구현이 이미 포함되어 있습니다. 이 스킬은 외부 브랜치를 머지하는 용도가 아니라, 토큰 설정과 채널 등록을 빠르게 끝내는 체크리스트로 사용합니다.
## Phase 1: Pre-flight
## 1. 전제 확인
### 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.
- `src/channels/discord.ts`가 존재하는지
- `package.json``discord.js`가 있는지
- `npm run typecheck`가 통과하는지
### Ask the user
## 2. 봇 토큰 준비
Use `AskUserQuestion` to collect configuration:
토큰이 없다면 디스코드 개발자 포털에서 새 애플리케이션과 봇을 만들고 아래 권한을 켭니다.
AskUserQuestion: Do you have a Discord bot token, or do you need to create one?
- `Message Content Intent`
- 필요하면 `Server Members Intent`
If they have one, collect it now. If not, we'll create one in Phase 3.
봇 초대 링크는 `bot` 스코프와 최소 `Send Messages`, `Read Message History`, `View Channels` 권한으로 생성합니다.
## Phase 2: Apply Code Changes
## 3. 환경 변수 설정
### Ensure channel remote
`.env`:
```bash
git remote -v
DISCORD_BOT_TOKEN=<token>
```
If `discord` is missing, add it:
선택:
```bash
git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
DISCORD_CODEX_BOT_TOKEN=<second-token>
```
### Merge the skill branch
두 번째 토큰이 있으면 Codex 전용 디스코드 봇도 따로 띄울 수 있습니다.
## 4. 채널 등록
디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:<channel-id>` 형식입니다.
메인 채널:
```bash
git fetch discord main
git merge discord/main
npm run setup -- --step register -- \
--jid dc:<channel-id> \
--name "<server-name> #<channel-name>" \
--folder discord_main \
--trigger @Andy \
--is-main \
--no-trigger-required
```
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
npm run setup -- --step register -- \
--jid dc:<channel-id> \
--name "<server-name> #<channel-name>" \
--folder discord_<channel-name> \
--trigger @Andy
```
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=<their-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
## 5. 빌드와 검증
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
npm run build:runners
npm run setup -- --step service
npm run setup -- --step verify
```
## 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
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
For a main channel (responds to all messages):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "discord_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```
For additional channels (trigger-only):
```typescript
registerGroup("dc:<channel-id>", {
name: "<server-name> #<channel-name>",
folder: "discord_<channel-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```
## 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
메인 채널이면 일반 메시지, 보조 채널이면 멘션 또는 트리거 메시지로 테스트합니다.

View File

@@ -1,216 +0,0 @@
---
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
```
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)

View File

@@ -1,90 +0,0 @@
---
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
```
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.

View File

@@ -1,153 +1,84 @@
---
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.
description: Add an Ollama MCP tool to the current host-process Claude runner.
---
# Add Ollama Integration
# Add Ollama Tool
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.
현재 구조에서는 컨테이너를 건드리지 않습니다. Ollama 연동은 호스트에서 실행되는 Claude 러너에 MCP를 추가하는 방식으로 넣습니다.
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.
- Ollama가 설치되어 있어야 합니다.
- 최소 한 개 이상의 모델이 있어야 합니다.
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
ollama pull gemma3:1b
ollama pull llama3.2
```
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
## 수정 지점
### 1. 환경 변수 전달
`src/agent-runner.ts`
- `.env`에서 `OLLAMA_HOST`를 읽도록 `readEnvFile([...])` 목록에 추가합니다.
- Claude 러너 child env에 `OLLAMA_HOST`를 명시적으로 넣습니다.
기본값을 쓰면 생략 가능하지만, 원격 Ollama나 다른 포트를 쓸 때는 필요합니다.
## 2. MCP 서버 추가
`runners/agent-runner/src/index.ts`
- 호스트에서 동작하는 stdio MCP 서버 파일을 추가합니다.
- 예: `runners/agent-runner/src/ollama-mcp-stdio.ts`
- `query({ options: { mcpServers, allowedTools } })` 쪽에 Ollama 서버를 등록합니다.
- `allowedTools``mcp__ollama__*` 또는 실제 툴 prefix를 추가합니다.
핵심은 두 가지입니다.
- Claude가 Ollama를 CLI가 아니라 MCP 도구로 보게 만들 것
- 툴 이름이 `allowedTools`에 포함될 것
## 3. 프롬프트 문서화
`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md`
- 언제 Ollama를 쓰는지
- 어떤 모델을 우선 쓰는지
- 빠른 요약/분류/초안 작업에 먼저 쓰도록 할지
이 부분을 적어두지 않으면 에이전트가 도구를 잘 안 고릅니다.
## 4. 빌드와 검증
```bash
npm run build:runners
npm run build
./container/build.sh
npm run setup -- --step service
```
Build must be clean before proceeding.
디스코드에서 테스트:
## Phase 3: Configure
> `ollama 도구를 써서 이 문단을 3줄로 요약해줘`
### 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`:
### 에이전트가 `ollama` CLI를 직접 치려고 함
```bash
OLLAMA_HOST=http://your-ollama-host:11434
```
- MCP 서버 등록이 빠졌거나
- `allowedTools`에 툴 prefix가 없거나
- 프롬프트 문서에 사용 규칙이 없습니다
### 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: ..."
- `ollama list`가 호스트에서 되는지 먼저 확인합니다
- 원격 호스트면 `.env``OLLAMA_HOST`를 확인합니다

View File

@@ -1,290 +1,73 @@
---
name: add-parallel
description: Add Parallel AI MCP tools to the current host-process Claude runner.
---
# Add Parallel AI Integration
Adds Parallel AI MCP integration to NanoClaw for advanced web research capabilities.
Parallel AI는 현재 구조에서도 유효한 도구 연동입니다. 다만 예전 문서처럼 컨테이너를 수정하지 않고, Claude 러너의 MCP 설정만 바꿉니다.
## What This Adds
## 1. API 키 준비
- **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`:
`PARALLEL_API_KEY``.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
PARALLEL_API_KEY=...
```
Verify:
```bash
grep "PARALLEL_API_KEY" .env | head -c 50
```
## 2. 환경 변수 전달
### 3. Update Container Runner
`src/agent-runner.ts`
Add `PARALLEL_API_KEY` to allowed environment variables in `src/container-runner.ts`:
- `.env`에서 `PARALLEL_API_KEY`를 읽도록 `readEnvFile([...])` 목록에 추가합니다.
- Claude 러너 child env에 `PARALLEL_API_KEY`를 넣습니다.
Find the line:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
```
## 3. MCP 서버 등록
Replace with:
```typescript
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
```
`runners/agent-runner/src/index.ts`
### 4. Configure MCP Servers in Agent Runner
- `query({ options: { mcpServers } })`에 Parallel HTTP MCP 서버를 추가합니다.
- `allowedTools``mcp__parallel-search__*`, `mcp__parallel-task__*`를 추가합니다.
Update `container/agent-runner/src/index.ts`:
구분은 이렇게 가져가는 편이 안전합니다.
Find the section where `mcpServers` is configured (around line 237-252):
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
```
- `parallel-search`: 빠른 검색, 저비용, 기본 허용
- `parallel-task`: 긴 조사 작업, 비용/시간이 더 큼, 명시적 승인 후 사용
Add Parallel AI MCP servers after the nanoclaw server:
```typescript
const mcpServers: Record<string, any> = {
nanoclaw: ipcMcp
};
## 4. 프롬프트 규칙 추가
// 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');
}
```
`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md`
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
- 빠른 사실 조회나 최신 정보 확인은 `parallel-search`
- 긴 조사나 깊은 분석은 `parallel-task`
- `parallel-task`는 항상 먼저 사용자 허가를 받을 것
- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__nanoclaw__schedule_task`로 후속 체크를 맡길 것
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:
## 5. 빌드와 검증
```bash
npm run build:runners
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
npm run setup -- --step service
```
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
> `최신 AI 뉴스 찾아줘`
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.
> `AI 에이전트 역사 자세히 조사해줘`
Check logs to verify MCP servers loaded:
```bash
tail -20 logs/nanoclaw.log
```
두 번째 요청에서는 비용/시간 안내 후 허가를 먼저 묻게 만드는 게 맞습니다.
Look for: `Parallel AI MCP servers configured`
## 문제 확인
## Troubleshooting
### MCP가 안 뜸
**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`
- `PARALLEL_API_KEY`가 child env까지 전달되는지 확인합니다
- `allowedTools`에 Parallel prefix가 빠지지 않았는지 봅니다
**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)
- `parallel-task` 결과를 기다리며 블로킹하지 말고 스케줄러로 넘기도록 프롬프트와 구현을 같이 수정합니다

View File

@@ -1,100 +0,0 @@
---
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
```
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 <url>`.
### 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.

View File

@@ -1,113 +0,0 @@
---
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
```
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

View File

@@ -1,216 +0,0 @@
---
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
```
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
Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
For a main channel (responds to all messages):
```typescript
registerGroup("slack:<channel-id>", {
name: "<channel-name>",
folder: "slack_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```
For additional channels (trigger-only):
```typescript
registerGroup("slack:<channel-id>", {
name: "<channel-name>",
folder: "slack_<channel-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```
## 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: `@<assistant-name> 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.

View File

@@ -1,384 +0,0 @@
---
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<string, number>();
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<void> {
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<void> {
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<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.' }] };
},
```
### 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 `<internal>` 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)

View File

@@ -1,231 +0,0 @@
---
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
```
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=<their-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
Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
For a main chat (responds to all messages):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "telegram_main",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: false,
isMain: true,
});
```
For additional chats (trigger-only):
```typescript
registerGroup("tg:<chat-id>", {
name: "<chat-name>",
folder: "telegram_<group-name>",
trigger: `@${ASSISTANT_NAME}`,
added_at: new Date().toISOString(),
requiresTrigger: true,
});
```
## 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)

View File

@@ -1,90 +1,60 @@
---
name: add-voice-transcription
description: Add voice message transcription to NanoClaw using Groq Whisper (fast, free) with OpenAI fallback. Works on Discord and WhatsApp.
description: Enable Discord voice transcription using Groq Whisper with OpenAI fallback.
---
# Add Voice Transcription
This skill adds automatic voice message transcription to NanoClaw. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice message transcription]: <text>`.
디스코드 음성 첨부 전사는 이미 코드에 들어 있습니다. 현재 구현은 `src/channels/discord.ts`에 있고, 별도 브랜치 머지나 채널 추가 작업은 필요 없습니다.
**Provider priority:** Groq Whisper (fast, free) > OpenAI Whisper (fallback).
우선순위는 `Groq Whisper -> OpenAI Whisper fallback` 입니다.
## Phase 1: Pre-flight
## 1. 환경 변수 설정
### Discord
Voice transcription is built into `src/channels/discord.ts`. No code changes needed — just configure API keys (Phase 3).
### WhatsApp
Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place.
If not, merge the skill branch:
`.env`에 아래 중 하나 이상을 넣습니다.
```bash
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git 2>/dev/null
git fetch whatsapp skill/voice-transcription
git merge whatsapp/skill/voice-transcription
npm install --legacy-peer-deps
npm run build
GROQ_API_KEY=gsk_... # 권장. 빠르고 무료 티어가 있음
OPENAI_API_KEY=sk-... # fallback
```
## Phase 2: Configure
Groq를 쓰면 `whisper-large-v3-turbo`, OpenAI를 쓰면 `whisper-1` 경로를 탑니다.
### Get API key
**Groq (recommended — fast + free):**
> 1. Go to https://console.groq.com
> 2. Sign up (no credit card needed)
> 3. Create an API key (starts with `gsk_`)
>
> Free tier: 2,000 requests/day, 8 hours of audio/day. Uses `whisper-large-v3-turbo` at ~200x real-time speed.
**OpenAI (fallback):**
> 1. Go to https://platform.openai.com/api-keys
> 2. Create a key (starts with `sk-`)
>
> Cost: ~$0.006 per minute of audio. Requires funded account.
### Add to environment
Add to `.env`:
```bash
GROQ_API_KEY=gsk_... # Primary (fast, free)
OPENAI_API_KEY=sk-... # Fallback (optional if Groq is set)
```
### Build and restart
## 2. 재시작
```bash
npm run build
systemctl --user restart nanoclaw nanoclaw-codex # Linux
# macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw
npm run setup -- --step service
```
## Phase 3: Verify
이미 서비스가 떠 있다면 플랫폼에 맞게 재시작만 해도 됩니다.
Send a voice note in any registered chat. The agent should receive it as `[Voice message transcription]: <text>`.
## 3. 검증
### Check logs
등록된 디스코드 채널에 음성 메시지나 오디오 첨부를 보냅니다. 정상이라면 에이전트 입력에 전사 텍스트가 포함됩니다.
```bash
tail -f logs/nanoclaw.log | grep -iE "transcri|audio"
tail -f logs/nanoclaw.log | grep -iE 'transcri|audio'
```
Look for:
- `Audio transcribed + cached` with `provider: "groq"` and `elapsed` — success
- `Transcription cache hit` — second service read from cache (no duplicate API call)
- `no transcription API key` — neither `GROQ_API_KEY` nor `OPENAI_API_KEY` set
- `groq Whisper 4xx` — check key validity
성공 신호:
- `Audio transcribed + cached`
- `provider: "groq"` 또는 `provider: "openai"`
- 동일 첨부 재처리 시 cache hit
## Troubleshooting
**No transcription:** Check `GROQ_API_KEY` (or `OPENAI_API_KEY`) is set in `.env`. Restart service after changes.
**전사가 전혀 안 됨**
**Slow transcription:** Verify `provider: "groq"` in logs. If it shows `openai`, the Groq key may be missing or invalid.
- `.env``GROQ_API_KEY``OPENAI_API_KEY`가 없는 경우가 대부분입니다.
- 서비스 재시작 전에는 새 키가 반영되지 않습니다.
**Agent doesn't respond to voice notes:** Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups.
**너무 느림**
- 로그에 `provider: "groq"`가 안 보이면 Groq 키가 빠졌거나 잘못된 상태입니다.
**채널에서는 보이는데 에이전트가 못 읽음**
- 채널 등록과 서비스 상태를 먼저 확인합니다.
- `npm run setup -- --step verify` 결과에서 `REGISTERED_GROUPS``SERVICE`를 같이 봅니다.

View File

@@ -1,368 +0,0 @@
---
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
```
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 <their-phone-number> > /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 "<jid>" \
--name "<chat-name>" \
--trigger "@<trigger>" \
--folder "whatsapp_main" \
--channel whatsapp \
--assistant-name "<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 "<group-jid>" \
--name "<group-name>" \
--trigger "@<trigger>" \
--folder "whatsapp_<group-name>" \
--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 <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)

View File

@@ -1,175 +0,0 @@
---
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` |

View File

@@ -1,115 +1,70 @@
---
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.
description: Customize Discord-only NanoClaw behavior, routing, integrations, prompts, or commands.
---
# NanoClaw Customization
This skill helps users add capabilities or modify behavior. Use AskUserQuestion to understand what they want before making changes.
이 스킬은 현재 디스코드 전용 NanoClaw를 기준으로 동작합니다. 새 채널 추가보다 기존 디스코드 흐름, 러너, 프롬프트, 스케줄링, 도구 연결을 직접 수정하는 쪽을 우선합니다.
## Workflow
## 진행 순서
1. **Install marketplace** - If feature skills aren't available yet, install the marketplace plugin:
```bash
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
```
This is hot-loaded — all feature skills become immediately available.
2. **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
1. 요청을 `행동 변경`, `명령 추가`, `도구 연동`, `설정/배포`, `프롬프트 수정` 중 어디에 속하는지 먼저 정리합니다.
2. 영향 범위를 좁힙니다.
3. 관련 파일만 수정합니다.
4. 가능하면 바로 검증합니다.
## 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 |
- `src/channels/discord.ts` - 디스코드 메시지 파싱, 멘션 규칙, 첨부파일 처리, 음성 전사
- `src/index.ts` - 오케스트레이션, 세션 명령, 라우팅, 상태 갱신
- `src/session-commands.ts` - `/compact`, `/clear` 같은 세션 명령
- `src/db.ts` - 등록 그룹, 세션, 스케줄, 마이그레이션
- `src/config.ts` - 서비스 식별값, 디렉터리, 타임아웃, 기능 플래그
- `src/agent-runner.ts` - 에이전트 실행과 환경 변수 전달
- `runners/agent-runner/src/index.ts` - Claude Code 쪽 허용 도구와 MCP
- `runners/codex-runner/src/index.ts` - Codex 쪽 실행 경로
- `setup/register.ts` - 디스코드 채널 등록
- `groups/global/CLAUDE.md` 및 각 그룹의 `CLAUDE.md` - 프롬프트/운영 규칙
## 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?
- 응답 조건, 멘션 처리, 첨부파일 처리: `src/channels/discord.ts`
- 세션 명령, 상태머신, 런루프: `src/index.ts`, `src/session-commands.ts`
- 페르소나/응답 스타일: `groups/global/CLAUDE.md`
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()`
### 도구나 MCP를 붙일 때
### Adding a New MCP Integration
- 러너에 실제 도구 허용과 MCP 설정을 추가합니다.
- Claude Code 쪽은 `runners/agent-runner/src/index.ts`를 먼저 보고, 필요하면 `src/agent-runner.ts`의 env 전달 범위를 같이 수정합니다.
- 그룹별 사용 규칙은 해당 `CLAUDE.md`에 문서화합니다.
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`
- 먼저 슬래시 명령인지, 자연어 규칙인지 구분합니다.
- 슬래시/예약 명령이면 `src/session-commands.ts``src/index.ts` 경로를 봅니다.
- 자연어 지시만으로 충분하면 프롬프트 문서만 수정합니다.
### Changing Assistant Behavior
### 등록/배포 흐름을 바꿀 때
Questions to ask:
- What aspect? (name, trigger, persona, response style)
- Apply to all groups or specific ones?
- 설치/검증 단계는 `setup/*`
- 서비스 동작은 `setup/service.ts`
- 그룹 등록 형식은 `setup/register.ts`
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
npm run typecheck
npm test
```
## 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
```bash
npm run build:runners
npm run setup -- --step verify
```

View File

@@ -1,349 +1,91 @@
---
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.
description: Debug NanoClaw runtime issues on the current Discord-only, host-process architecture.
---
# NanoClaw Container Debugging
# NanoClaw Debugging
This guide covers debugging the containerized agent execution system.
현재 기준 NanoClaw는 디스코드 전용이고, 에이전트는 컨테이너가 아니라 호스트 프로세스로 실행됩니다. 디버깅은 `채널`, `서비스`, `러너`, `DB 등록`, `자격 증명` 순서로 좁혀갑니다.
## 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
```
1. 타입과 테스트부터 확인
```bash
npm run typecheck
npm test
```
2. 서비스 상태 확인
```bash
npm run setup -- --step verify
```
3. 런타임 로그 확인
```bash
tail -f logs/nanoclaw.log
tail -f logs/nanoclaw.error.log
ls -t groups/*/logs/agent-*.log | head
```
4. 러너 빌드 확인
```bash
npm run build:runners
```
**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
- `src/index.ts` - 메시지 루프, 세션 명령, 라우팅
- `src/channels/discord.ts` - 디스코드 수신/송신, 멘션, 첨부파일, 음성 전사
- `src/agent-runner.ts` - 러너 실행, 환경 변수 전달, 워크디렉터리 처리
- `runners/agent-runner/src/index.ts` - Claude Code 러너
- `runners/codex-runner/src/index.ts` - Codex 러너
- `src/db.ts` - 등록 그룹, 세션, 스케줄 저장
- `setup/register.ts` - 디스코드 채널 등록
- `setup/verify.ts` - 설치 상태 검증
| 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:
<key>LOG_LEVEL</key>
<string>debug</string>
# For systemd service (Linux), add to unit [Service] section:
# Environment=LOG_LEVEL=debug
grep -n '^DISCORD_BOT_TOKEN=' .env
npm run setup -- --step verify
```
Debug level shows:
- Full mount configurations
- Container command arguments
- Real-time container stderr
- `DISCORD_BOT_TOKEN`이 없으면 채널이 안 붙습니다.
- `REGISTERED_GROUPS=0`이면 채널 등록이 안 된 상태입니다.
- 메인 채널이 아니면 디스코드 멘션이나 트리거 조건을 먼저 확인합니다.
## 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
grep -nE '^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_OPENAI_API_KEY)=' .env
ls -t groups/*/logs/agent-*.log | head -3
```
## Checking Container Image
- Claude 계열은 `CLAUDE_CODE_OAUTH_TOKEN` 또는 `ANTHROPIC_API_KEY`가 필요합니다.
- Codex 계열은 `OPENAI_API_KEY` 또는 `CODEX_OPENAI_API_KEY`가 필요합니다.
- 러너 로그에 인증 실패나 CLI 실행 오류가 바로 찍힙니다.
### 음성 전사가 안 됨
```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/
'
grep -nE '^(GROQ_API_KEY|OPENAI_API_KEY)=' .env
tail -f logs/nanoclaw.log | grep -iE 'transcri|audio|whisper|groq'
```
## Session Persistence
- 기본 우선순위는 Groq Whisper, 없으면 OpenAI Whisper fallback입니다.
- 키가 없으면 디스코드 채널은 음성 첨부를 텍스트로 확장하지 못합니다.
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}'"
sqlite3 store/messages.db "select jid, folder, requires_trigger, is_main, agent_type from registered_groups;"
```
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
```
- `jid`는 `dc:<channel_id>` 형식이어야 합니다.
- `folder`는 `discord_main` 또는 `discord_<name>` 형태를 유지합니다.
- 세션 명령 문제면 `src/session-commands.ts`와 `src/index.ts` 호출부를 같이 봅니다.
## 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"
```
- 채널 문제와 에이전트 문제를 섞지 말고 분리해서 봅니다.
- `.env`, DB 등록, 서비스, 러너 빌드 중 하나라도 틀리면 상위 증상이 비슷하게 보입니다.
- 컨테이너 전제 문서는 무시하고 `runners/*`와 `setup/*` 기준으로 확인합니다.

View File

@@ -1,203 +1,114 @@
---
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.
description: Run initial NanoClaw setup for the current Discord-only, host-process architecture.
---
# 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`.
설치는 `bash setup.sh`로 부트스트랩하고, 나머지는 `npm run setup -- --step <name>`으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다.
**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.
**Architecture:** NanoClaw runs agents as direct host processes (no Docker/containers). Each agent type (Claude Code, Codex) has its own runner in `container/agent-runner/` and `container/codex-runner/`.
## 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` and `package-lock.json`, 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
## 3. Build Agent Runners
Run `npx tsx setup/index.ts --step runners` and parse the status block.
This builds the agent runners that execute as host processes:
- `container/agent-runner/` — Claude Code agent (via Claude Agent SDK)
- `container/codex-runner/` — Codex agent (via Codex CLI)
**If BUILD_OK=false:** Read `logs/setup.log` for the error. Common fix: `cd container/agent-runner && npm install && npm run build`.
**If CODEX_RUNNER=false but AGENT_RUNNER=true:** Codex runner failed to build. Not critical if you only use Claude Code. Fix: `cd container/codex-runner && npm install && npm run build`.
## 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`.
### Codex Authentication (Optional)
AskUserQuestion: Do you also want to use Codex (OpenAI) agents?
If yes: Tell user to add `OPENAI_API_KEY=<key>` (or `CODEX_OPENAI_API_KEY=<key>`) to `.env`. The Codex runner will use this key.
### Voice Transcription (Optional)
AskUserQuestion: Do you want voice message transcription in Discord/WhatsApp?
If yes: Tell user to get a free API key at https://console.groq.com (no credit card needed) and add `GROQ_API_KEY=<key>` to `.env`. Uses Groq Whisper (whisper-large-v3-turbo, ~200x faster than OpenAI). Falls back to OpenAI Whisper (`OPENAI_API_KEY`) if Groq key is not set.
## 5. Install Skills Marketplace
Register and install the NanoClaw skills marketplace plugin so all feature skills (channel integrations, add-ons) are available:
## 1. 부트스트랩
```bash
claude plugin marketplace add qwibitai/nanoclaw-skills
claude plugin marketplace update nanoclaw-skills
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
bash setup.sh
```
The marketplace update ensures the local cache is fresh before installing. This is hot-loaded — no restart needed. All feature skills become immediately available.
- Node 20 이상과 의존성이 준비되어야 합니다.
- 실패하면 `logs/setup.log`를 먼저 봅니다.
## 6. 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:
## 2. 현재 상태 확인
```bash
npm install && npm run build
npm run setup -- --step environment
```
If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 7.
여기서 확인할 것:
## 7. Mount Allowlist
- `.env` 존재 여부
- 기존 등록 그룹 존재 여부
- 이미 초기화된 설치인지 여부
AskUserQuestion: Agent access to external directories?
## 3. 필수 환경 변수
**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}'`
`.env`에 최소한 아래 값이 있어야 합니다.
## 8. Start Service
```bash
DISCORD_BOT_TOKEN=...
CLAUDE_CODE_OAUTH_TOKEN=... # 또는 ANTHROPIC_API_KEY=...
```
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.
```bash
OPENAI_API_KEY=... # Codex 사용 시
CODEX_OPENAI_API_KEY=... # Codex 전용 키를 따로 쓸 때
GROQ_API_KEY=... # Discord 음성 전사
```
**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.
## 4. 러너 빌드
**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.
```bash
npm run setup -- --step runners
```
## 9. Verify
이 단계는 아래 두 러너를 빌드합니다.
Run `npx tsx setup/index.ts --step verify` and parse the status block.
- `runners/agent-runner`
- `runners/codex-runner`
**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 8
- 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 6
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
실패하면 보통 `npm run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다.
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
## 5. 디스코드 채널 등록
## Troubleshooting
먼저 디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:<channel_id>` 형식입니다.
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 8), missing `.env` (step 4), missing channel credentials (re-invoke channel skill).
메인 채널 예시:
**Agent fails ("Claude Code process exited with code 1"):** Check agent logs in `groups/<folder>/logs/agent-*.log`. Common causes: missing credentials in `.env`, `codex` CLI not in PATH, stale session IDs.
```bash
npm run setup -- --step register -- \
--jid dc:123456789012345678 \
--name "My Server #general" \
--folder discord_main \
--trigger @Andy \
--is-main \
--no-trigger-required
```
**Codex agent not responding:** Ensure `OPENAI_API_KEY` is set in `.env` and `codex` CLI is installed globally (`npm install -g @openai/codex`). The runner needs `codex` in PATH.
보조 채널 예시:
**Voice transcription not working:** Check `GROQ_API_KEY` (primary) or `OPENAI_API_KEY` (fallback) is set in `.env`. Test: send a voice message in Discord and check `logs/nanoclaw.log` for `Audio transcribed` entries with `provider` and `elapsed` fields.
```bash
npm run setup -- --step register -- \
--jid dc:123456789012345678 \
--name "My Server #ops" \
--folder discord_ops \
--trigger @Andy
```
**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`.
## 6. 서비스 시작
**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.
```bash
npm run setup -- --step service
```
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
- macOS `launchd`
- Linux는 `systemd` 또는 fallback wrapper
## 7. 최종 검증
```bash
npm run setup -- --step verify
```
성공 기준:
- 서비스가 running
- Claude 인증이 configured
- `CHANNEL_AUTH``discord`
- 등록 그룹 수가 1 이상
## 빠른 문제 해결
- 빌드 문제: `npm run typecheck`, `npm test`, `npm run build:runners`
- 서비스 문제: `logs/nanoclaw.error.log`
- 디스코드 연결 문제: `.env``DISCORD_BOT_TOKEN`과 등록된 `dc:*` JID 확인
- 응답 문제: `tail -f logs/nanoclaw.log`

View File

@@ -1,235 +1,100 @@
---
name: update-nanoclaw
description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage.
description: Safely merge upstream NanoClaw changes into a customized install with minimal drift.
---
# About
# Update NanoClaw
Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications.
커스텀된 NanoClaw에 upstream 변경을 가져올 때 쓰는 절차입니다. 현재 기준 중요 경로는 `src/`, `runners/`, `setup/`, `.claude/skills/`, `groups/`, `README.md` 입니다.
Run `/update-nanoclaw` in Claude Code.
## 원칙
## How it works
- 워킹트리가 더럽다면 진행하지 않습니다
- 시작 전에 항상 백업 브랜치와 태그를 만듭니다
- 먼저 `preview`, 그 다음 `merge` 또는 `cherry-pick` 순서로 갑니다
- 충돌이 나면 충돌 파일만 엽니다
**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`).
## 1. 사전 점검
**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>
```bash
git status --porcelain
git remote -v
git fetch upstream --prune
```
Backup branch `backup/pre-update-<hash>-<timestamp>` also exists.
- 변경 파일이 남아 있으면 먼저 정리하게 합니다
- `upstream`이 없으면 추가합니다
- upstream 기본 브랜치가 `main`인지 먼저 확인합니다
## Token usage
## 2. 백업
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>.
```bash
HASH=$(git rev-parse --short HEAD)
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
git branch backup/pre-update-$HASH-$TIMESTAMP
git tag pre-update-$HASH-$TIMESTAMP
```
If no `[BREAKING]` lines are found:
- Skip this step silently. Proceed to Step 7 (skill updates check).
## 3. 미리 보기
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).
```bash
BASE=$(git merge-base HEAD upstream/main)
git log --oneline $BASE..upstream/main
git log --oneline $BASE..HEAD
git diff --name-only $BASE..upstream/main
```
# 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.
- `.claude/skills/` - 스킬 문서/도우미
- `src/` - 런타임과 채널 처리
- `runners/` - Claude/Codex 실행 경로
- `setup/` - 설치, 등록, 검증, 서비스
- `README.md`, `groups/` - 운영 문서와 프롬프트
# 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`
## 4. 적용
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`
기본은 merge 입니다.
```bash
git merge upstream/main --no-edit
```
일부 커밋만 가져올 거면 cherry-pick으로 갑니다.
```bash
git cherry-pick <hash1> <hash2>
```
## 5. 충돌 처리
- 충돌 파일만 엽니다
- 로컬 커스터마이징은 유지합니다
- 주변 리팩터링은 하지 않습니다
- 해결 후 바로 `git add <file>` 합니다
## 6. 검증
```bash
npm run typecheck
npm test
npm run build:runners
npm run build
```
런타임 경로가 바뀌었으면 추가로 확인합니다.
```bash
npm run setup -- --step verify
```
## 7. 요약
사용자에게 최소한 이 네 가지를 알려줍니다.
- 백업 태그 이름
- 새 HEAD
- 해결한 충돌 파일 목록
- upstream 대비 아직 남은 로컬 diff
롤백 안내는 태그 기준으로 알려줍니다.

View File

@@ -1,148 +0,0 @@
---
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
```
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`.

View File

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

View File

@@ -1,243 +0,0 @@
/**
* 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
};
}
)
];
}

View File

@@ -1,159 +0,0 @@
/**
* 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;
}

View File

@@ -1,148 +0,0 @@
/**
* 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);
}
}

View File

@@ -1,62 +0,0 @@
/**
* 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'],
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,87 +0,0 @@
#!/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);
});

11
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules/
.npm-cache/
# Build output
dist/
runners/*/dist/
# Local data & auth
store/
@@ -24,12 +25,22 @@ groups/global/*
# Secrets
*.keys.json
.env
.env.codex
# Temp files
.tmp-*
tmp/
backups/
dist-backups/
.deploy-backups/
cache/
runners/*/node_modules/
runners/codex-runner-backups/
*.bak-*
# OS
.DS_Store
._*
# IDE
.idea/

View File

@@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n
## Quick Context
Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups. Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses `codex app-server` via JSON-RPC. Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but run with separate stores, data, and groups (will be unified — DB supports shared access via WAL mode + service partitioning). Agents run as direct host processes (no containers). Claude Code uses the Agent SDK; Codex uses the Codex SDK (`codex exec`). Auth via `CLAUDE_CODE_OAUTH_TOKEN` in `.env` (1-year token from `claude setup-token`).
## Key Files
@@ -18,8 +18,8 @@ Two systemd services (`nanoclaw`, `nanoclaw-codex`) share the same codebase but
| `src/config.ts` | Trigger pattern, paths, intervals |
| `src/task-scheduler.ts` | Runs scheduled tasks |
| `src/db.ts` | SQLite operations |
| `container/agent-runner/` | Claude Code runner (Agent SDK) |
| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) |
| `runners/agent-runner/` | Claude Code runner (Agent SDK) |
| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) |
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
## Skills
@@ -54,19 +54,39 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
## Dual-Service Architecture
- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/`
- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/`
- Both share the same codebase (`dist/index.js`), differentiated by env vars (`NANOCLAW_STORE_DIR`, etc.)
- Channel registration is per-service DB (`registered_groups` table)
- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
- `nanoclaw-codex.service` — Codex bot (`@codex`), `SERVICE_ID=codex`, `SERVICE_AGENT_TYPE=codex`
- Both share the same codebase (`dist/index.js`), differentiated by env vars
- Unified dirs (`store/`, `groups/`, `data/` shared by both services):
- `router_state`: keys prefixed with `{SERVICE_ID}:` (e.g., `claude:last_timestamp`)
- `sessions`: composite PK `(group_folder, agent_type)`
- `registered_groups`: filtered by `agent_type` on load
- SQLite WAL mode + `busy_timeout=5000` for concurrent access
## Codex App-Server
## Debugging Paths (Server: clone-ej@100.64.185.108)
Codex runner uses `codex app-server` JSON-RPC (not `codex exec`):
- `thread/start` / `thread/resume` for session persistence (threadId-based)
- `turn/start` for streaming responses (`item/agentMessage/delta`)
- `turn/steer` for mid-execution message injection (IPC polling during turn)
- `approvalPolicy: "never"` + `sandbox: "danger-full-access"` for bypass
Unified DB + directories (both services share `store/`, `groups/`, `data/`):
| 항목 | 경로 |
|------|------|
| **DB** | `store/messages.db` (공유, WAL 모드) |
| 서비스 로그 (Claude) | `journalctl --user -u nanoclaw -f` 또는 `logs/nanoclaw.log` |
| 서비스 로그 (Codex) | `journalctl --user -u nanoclaw-codex -f` 또는 `logs/nanoclaw-codex.log` |
| 그룹별 로그 | `groups/{name}/logs/` (공유 채널은 양쪽 봇 로그가 같은 폴더) |
| Claude 세션 | `data/sessions/{name}/.claude/` |
| Codex 세션 | `data/sessions/{name}/.codex/` |
| Claude 플랫폼 규칙 | `prompts/claude-platform.md` |
| Codex 플랫폼 규칙 | `prompts/codex-platform.md` |
| Claude 글로벌 메모리 | `groups/global/CLAUDE.md` |
## Codex SDK
Codex runner uses `@openai/codex-sdk` (wraps `codex exec`):
- `codex.startThread()` / `codex.resumeThread()` for session persistence
- `thread.run(input)` for single-shot turn execution (completes all work before returning)
- `approvalPolicy: "never"` + `sandboxMode: "danger-full-access"` for bypass
- Per-group: model (`CODEX_MODEL`), effort (`CODEX_EFFORT`), MCP servers via `config.toml`
- `CODEX_HOME` set to per-group session dir, reads `AGENTS.md` from there + CWD
## Voice Transcription

View File

@@ -60,7 +60,7 @@ nanoclaw/
│ └── channels/
│ ├── registry.ts # Channel self-registration system
│ └── discord.ts # Discord: mentions, images, typing, file attachments
├── container/
├── runners/
│ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input)
│ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue)
│ └── skills/ # Shared agent skills (browser, etc.)
@@ -77,7 +77,7 @@ nanoclaw/
### Codex App-Server Integration
The Codex runner (`container/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio:
The Codex runner (`runners/codex-runner/`) communicates with `codex app-server` via JSON-RPC over stdio:
- **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk
- **Streaming**: `item/agentMessage/delta` notifications for real-time text
@@ -98,19 +98,10 @@ Bidirectional image support through Discord:
Skills are managed from a single source of truth (`~/.claude/skills/` on the server) and automatically synced to all agent session directories at process start:
- Claude Code sessions: `~/.claude/skills/` + project `container/skills/`
- Claude Code sessions: `~/.claude/skills/` + project `runners/skills/`
- Codex sessions: Same sources, synced to per-group `.codex/` directories
- Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex
### OAuth Token Auto-Refresh
`src/token-refresh.ts` handles Claude Code OAuth token lifecycle:
- Checks every 5 minutes, refreshes 30 minutes before expiry
- Tries `platform.claude.com` then falls back to `api.anthropic.com`
- Syncs refreshed credentials to all per-group session directories
- Solves the known headless environment token expiry issue
### GroupQueue
`src/group-queue.ts` manages agent execution with:
@@ -257,7 +248,7 @@ Fields:
| `agent_type` | `claude-code`, `codex`, or `both` |
| `trigger_pattern` | Regex for activation (e.g., `@claude`) |
| `work_dir` | Optional working directory override |
| `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
| `agent_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
### macOS (launchd)

View File

@@ -1,47 +0,0 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -1,665 +0,0 @@
/**
* NanoClaw Codex Runner (app-server mode)
*
* Spawns a single `codex app-server` process and communicates via JSON-RPC
* over stdio. Supports streaming responses, session persistence (threadId),
* and mid-turn message injection via turn/steer.
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/
* Sentinel: _close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import { spawn, ChildProcess } from 'child_process';
import fs from 'fs';
import path from 'path';
import readline from 'readline';
// ── Types ──────────────────────────────────────────────────────────
interface ContainerInput {
prompt: string;
sessionId?: string; // threadId from previous session
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
interface JsonRpcRequest {
method: string;
id?: number;
params?: Record<string, unknown>;
}
interface JsonRpcResponse {
id?: number;
method?: string;
result?: Record<string, unknown>;
error?: { code: number; message: string };
params?: Record<string, unknown>;
}
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
// ── Helpers ────────────────────────────────────────────────────────
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(`[codex-runner] ${message}`);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function shouldClose(): boolean {
if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
return true;
}
return false;
}
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 [];
}
}
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();
});
}
// ── App-Server Client ──────────────────────────────────────────────
class CodexAppServer {
private proc: ChildProcess;
private rl: readline.Interface;
private nextId = 1;
private pending = new Map<number, {
resolve: (value: JsonRpcResponse) => void;
reject: (err: Error) => void;
}>();
private notificationHandler: ((msg: JsonRpcResponse) => void) | null = null;
private serverRequestHandler: ((msg: JsonRpcResponse) => void) | null = null;
constructor() {
this.proc = spawn('codex', ['app-server'], {
stdio: ['pipe', 'pipe', 'pipe'],
cwd: EFFECTIVE_CWD,
env: { ...process.env },
});
this.rl = readline.createInterface({ input: this.proc.stdout! });
this.rl.on('line', (line: string) => {
if (!line.trim()) return;
try {
const msg: JsonRpcResponse = JSON.parse(line);
this.handleMessage(msg);
} catch {
// Non-JSON output, ignore
}
});
this.proc.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().trim().split('\n')) {
if (line) log(line);
}
});
this.proc.on('error', (err: Error) => {
log(`App-server spawn error: ${err.message}`);
// Reject all pending requests
for (const [, { reject }] of this.pending) {
reject(err);
}
this.pending.clear();
});
this.proc.on('close', (code: number | null) => {
log(`App-server exited with code ${code}`);
const err = new Error(`App-server exited with code ${code}`);
for (const [, { reject }] of this.pending) {
reject(err);
}
this.pending.clear();
});
}
private handleMessage(msg: JsonRpcResponse): void {
// Response to a request we made
if (msg.id !== undefined && this.pending.has(msg.id)) {
const handler = this.pending.get(msg.id)!;
this.pending.delete(msg.id);
handler.resolve(msg);
return;
}
// Server-initiated request (has id + method) — needs a response
if (msg.id !== undefined && msg.method) {
this.serverRequestHandler?.(msg);
return;
}
// Notification (has method, no id)
if (msg.method) {
this.notificationHandler?.(msg);
}
}
setNotificationHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
this.notificationHandler = handler;
}
setServerRequestHandler(handler: ((msg: JsonRpcResponse) => void) | null): void {
this.serverRequestHandler = handler;
}
send(msg: JsonRpcRequest): void {
this.proc.stdin!.write(JSON.stringify(msg) + '\n');
}
async request(method: string, params: Record<string, unknown> = {}, timeoutMs = 30_000): Promise<JsonRpcResponse> {
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
}, timeoutMs);
this.pending.set(id, {
resolve: (resp) => {
clearTimeout(timer);
resolve(resp);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
this.send({ method, id, params });
});
}
respond(id: number, result: Record<string, unknown>): void {
this.proc.stdin!.write(JSON.stringify({ id, result }) + '\n');
}
async initialize(): Promise<void> {
const resp = await this.request('initialize', {
clientInfo: { name: 'nanoclaw', title: 'NanoClaw Codex', version: '1.0' },
capabilities: { experimentalApi: false },
}, 15_000);
if (resp.error) {
throw new Error(`Initialize failed: ${resp.error.message}`);
}
// Send initialized notification
this.send({ method: 'initialized' });
log('App-server initialized');
}
async startThread(): Promise<string> {
const params: Record<string, unknown> = {
cwd: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
experimentalRawEvents: false,
persistExtendedHistory: false,
};
if (CODEX_MODEL) params.model = CODEX_MODEL;
const resp = await this.request('thread/start', params, 30_000);
if (resp.error) {
throw new Error(`thread/start failed: ${resp.error.message}`);
}
const thread = resp.result?.thread as Record<string, unknown> | undefined;
const threadId = thread?.id as string;
log(`Thread started: ${threadId}`);
return threadId;
}
async resumeThread(threadId: string): Promise<string> {
const params: Record<string, unknown> = {
threadId,
cwd: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
persistExtendedHistory: false,
};
if (CODEX_MODEL) params.model = CODEX_MODEL;
const resp = await this.request('thread/resume', params, 30_000);
if (resp.error) {
// If resume fails (e.g., thread not found), start a new one
log(`thread/resume failed: ${resp.error.message}, starting new thread`);
return this.startThread();
}
const thread = resp.result?.thread as Record<string, unknown> | undefined;
const actualId = (thread?.id as string) || threadId;
log(`Thread resumed: ${actualId}`);
return actualId;
}
async startTurn(threadId: string, text: string): Promise<string> {
// Parse [Image: /absolute/path] patterns and convert to multimodal input
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const input: Array<Record<string, unknown>> = [];
const imagePaths: string[] = [];
let match;
while ((match = imagePattern.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
// Add text (with image tags stripped) as first input block
const cleanText = text.replace(imagePattern, '').trim();
if (cleanText) {
input.push({ type: 'text', text: cleanText, text_elements: [] });
}
// Add image input blocks
for (const imgPath of imagePaths) {
if (fs.existsSync(imgPath)) {
input.push({ type: 'localImage', path: imgPath });
log(`Adding image input: ${imgPath}`);
} else {
log(`Image not found, skipping: ${imgPath}`);
}
}
if (input.length === 0) {
input.push({ type: 'text', text, text_elements: [] });
}
const params: Record<string, unknown> = {
threadId,
input,
};
if (CODEX_EFFORT) params.effort = CODEX_EFFORT;
const resp = await this.request('turn/start', params, 30_000);
if (resp.error) {
throw new Error(`turn/start failed: ${resp.error.message}`);
}
const turn = resp.result?.turn as Record<string, unknown> | undefined;
const turnId = turn?.id as string;
log(`Turn started: ${turnId}`);
return turnId;
}
async steerTurn(threadId: string, turnId: string, text: string): Promise<void> {
const resp = await this.request('turn/steer', {
threadId,
input: [{ type: 'text', text, text_elements: [] }],
expectedTurnId: turnId,
}, 10_000);
if (resp.error) {
log(`turn/steer failed: ${resp.error.message}`);
}
}
async interruptTurn(threadId: string, turnId: string): Promise<void> {
try {
await this.request('turn/interrupt', { threadId, turnId }, 10_000);
} catch (err) {
log(`turn/interrupt failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
kill(): void {
try {
this.proc.kill('SIGTERM');
setTimeout(() => {
if (!this.proc.killed) this.proc.kill('SIGKILL');
}, 5000);
} catch { /* ignore */ }
}
get alive(): boolean {
return !this.proc.killed && this.proc.exitCode === null;
}
}
// ── Turn Execution ─────────────────────────────────────────────────
/**
* Execute a turn and collect the agent's text response.
* While the turn is running, polls IPC for new messages and injects
* them via turn/steer (mid-execution message injection).
* Returns when turn/completed notification is received.
*/
async function executeTurn(
server: CodexAppServer,
threadId: string,
prompt: string,
): Promise<{ result: string; error?: string; turnId: string }> {
return new Promise((resolve) => {
let agentText = '';
let turnId = '';
let resolved = false;
let ipcPollTimer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
server.setNotificationHandler(null);
server.setServerRequestHandler(null);
if (ipcPollTimer) {
clearTimeout(ipcPollTimer);
ipcPollTimer = null;
}
};
// Timeout safety (5 minutes per turn)
const timer = setTimeout(() => {
if (!resolved) {
resolved = true;
cleanup();
log('Turn execution timed out (5min)');
resolve({
result: agentText || '',
error: 'Turn timed out after 5 minutes',
turnId,
});
}
}, 5 * 60 * 1000);
// IPC polling during turn — steer messages into the running turn
const pollIpcDuringTurn = () => {
if (resolved) return;
// Check close sentinel
if (shouldClose()) {
log('Close sentinel during turn, interrupting');
if (turnId) {
server.interruptTurn(threadId, turnId).catch(() => {});
}
return;
}
// Check for new messages to steer
const messages = drainIpcInput();
if (messages.length > 0 && turnId) {
const text = messages.join('\n');
log(`Steering message into turn (${text.length} chars)`);
server.steerTurn(threadId, turnId, text).catch((err) => {
log(`Steer failed: ${err instanceof Error ? err.message : String(err)}`);
});
}
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
};
// Handle notifications (streaming events)
server.setNotificationHandler((msg) => {
if (resolved) return;
switch (msg.method) {
case 'item/agentMessage/delta': {
const delta = (msg.params as Record<string, unknown>)?.delta as string;
if (delta) agentText += delta;
break;
}
case 'item/completed': {
const item = (msg.params as Record<string, unknown>)?.item as Record<string, unknown>;
if (item?.type === 'agentMessage') {
// Use authoritative text from completed item
const text = item.text as string;
if (text) agentText = text;
}
break;
}
case 'turn/completed': {
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
const status = turn?.status as string;
const error = turn?.error as Record<string, unknown> | null;
clearTimeout(timer);
resolved = true;
cleanup();
if (status === 'failed') {
const errMsg = (error?.message as string) || 'Turn failed';
resolve({ result: agentText || '', error: errMsg, turnId });
} else {
resolve({ result: agentText, turnId });
}
break;
}
case 'turn/started': {
const turn = (msg.params as Record<string, unknown>)?.turn as Record<string, unknown>;
if (turn?.id) turnId = turn.id as string;
break;
}
}
});
// Handle server requests (approval auto-accept)
server.setServerRequestHandler((msg) => {
if (msg.id === undefined) return;
if (msg.method === 'item/commandExecution/requestApproval' ||
msg.method === 'item/fileChange/requestApproval' ||
msg.method === 'item/permissions/requestApproval') {
server.respond(msg.id, { decision: 'accept' });
return;
}
// Unknown server request — accept generically
server.respond(msg.id, {});
});
// Start IPC polling for mid-turn message injection
ipcPollTimer = setTimeout(pollIpcDuringTurn, IPC_POLL_MS);
// Start the turn
server.startTurn(threadId, prompt)
.then((id) => { turnId = id; })
.catch((err) => {
if (!resolved) {
clearTimeout(timer);
resolved = true;
cleanup();
resolve({
result: '',
error: `Failed to start turn: ${err.message}`,
turnId: '',
});
}
});
});
}
// ── Main ───────────────────────────────────────────────────────────
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);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
// Build initial prompt
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
// Spawn app-server
const server = new CodexAppServer();
try {
await server.initialize();
// Start or resume thread
const threadId = containerInput.sessionId
? await server.resumeThread(containerInput.sessionId)
: await server.startThread();
let turnCount = 0;
// Main turn loop
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: threadId,
});
break;
}
log(`Starting turn ${turnCount}/${MAX_TURNS}...`);
const { result, error } = await executeTurn(server, threadId, prompt);
// Check close sentinel
if (shouldClose()) {
if (result) {
writeOutput({ status: 'success', result, newSessionId: threadId });
}
log('Close sentinel detected, exiting');
break;
}
if (error) {
log(`Turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: threadId,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: threadId,
});
}
log('Turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting');
break;
}
log(`Got new message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
} finally {
server.kill();
}
}
main();

View File

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

View File

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

View File

@@ -1,196 +0,0 @@
# 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).

View File

@@ -1,643 +0,0 @@
# Claude Agent SDK Deep Dive
Findings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.290.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)

View File

@@ -1,122 +0,0 @@
# 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 │
└──────────────────────────────────────────────────────────────────┘
```

View File

@@ -1,785 +0,0 @@
# 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
registerGroup("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 diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,662 +0,0 @@
# 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`.

View File

@@ -1,58 +1,7 @@
# Andy
# Global Memory
You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
This file is for mutable memory shared across Claude groups.
## What You Can Do
Use it for durable facts, preferences, and shared context that may change over time.
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Your Workspace
Files you create are saved in `/workspace/group/`. Use this for notes, research, or anything that should persist.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## Message Formatting
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**.
Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`.

View File

@@ -1,246 +1,5 @@
# Andy
# Main Group Memory
You are Andy, a personal assistant. You help with tasks, answer questions, and can schedule reminders.
This file is for mutable memory and admin context specific to the main group.
## What You Can Do
- Answer questions and have conversations
- Search the web and fetch content from URLs
- **Browse the web** with `agent-browser` — open pages, click, fill forms, take screenshots, extract data (run `agent-browser open <url>` to start, then `agent-browser snapshot -i` to see interactive elements)
- Read and write files in your workspace
- Run bash commands in your sandbox
- Schedule tasks to run later or on a recurring basis
- Send messages back to the chat
## Communication
Your output is sent to the user or group.
You also have `mcp__nanoclaw__send_message` which sends a message immediately while you're still working. This is useful when you want to acknowledge a request before starting longer work.
### Internal thoughts
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user. If you've already sent the key information via `send_message`, you can wrap the recap in `<internal>` to avoid sending it again.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if instructed to by the main agent.
## Memory
The `conversations/` folder contains searchable history of past conversations. Use this to recall context from previous sessions.
When you learn something important:
- Create files for structured data (e.g., `customers.md`, `preferences.md`)
- Split files larger than 500 lines into folders
- Keep an index in your memory for the files you create
## WhatsApp Formatting (and other messaging apps)
Do NOT use markdown headings (##) in WhatsApp messages. Only use:
- *Bold* (single asterisks) (NEVER **double asterisks**)
- _Italic_ (underscores)
- • Bullets (bullet points)
- ```Code blocks``` (triple backticks)
Keep messages clean and readable for WhatsApp.
---
## Admin Context
This is the **main channel**, which has elevated privileges.
## Container Mounts
Main has read-only access to the project and read-write access to its group folder:
| Container Path | Host Path | Access |
|----------------|-----------|--------|
| `/workspace/project` | Project root | read-only |
| `/workspace/group` | `groups/main/` | read-write |
Key paths inside the container:
- `/workspace/project/store/messages.db` - SQLite database
- `/workspace/project/store/messages.db` (registered_groups table) - Group config
- `/workspace/project/groups/` - All group folders
---
## Managing Groups
### Finding Available Groups
Available groups are provided in `/workspace/ipc/available_groups.json`:
```json
{
"groups": [
{
"jid": "120363336345536173@g.us",
"name": "Family Chat",
"lastActivity": "2026-01-31T12:00:00.000Z",
"isRegistered": false
}
],
"lastSync": "2026-01-31T12:00:00.000Z"
}
```
Groups are ordered by most recent activity. The list is synced from WhatsApp daily.
If a group the user mentions isn't in the list, request a fresh sync:
```bash
echo '{"type": "refresh_groups"}' > /workspace/ipc/tasks/refresh_$(date +%s).json
```
Then wait a moment and re-read `available_groups.json`.
**Fallback**: Query the SQLite database directly:
```bash
sqlite3 /workspace/project/store/messages.db "
SELECT jid, name, last_message_time
FROM chats
WHERE jid LIKE '%@g.us' AND jid != '__group_sync__'
ORDER BY last_message_time DESC
LIMIT 10;
"
```
### Registered Groups Config
Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
"folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
}
```
Fields:
- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
2. Use the `register_group` MCP tool with the JID, name, folder, and trigger
3. Optionally include `containerConfig` for additional mounts
4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
5. Optionally create an initial `CLAUDE.md` for the group
Folder naming convention — channel prefix with underscore separator:
- WhatsApp "Family Chat" → `whatsapp_family-chat`
- Telegram "Dev Team" → `telegram_dev-team`
- Discord "General" → `discord_general`
- Slack "Engineering" → `slack_engineering`
- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
Groups can have extra directories mounted. Add `containerConfig` to their entry:
```json
{
"1234567890@g.us": {
"name": "Dev Team",
"folder": "dev-team",
"trigger": "@Andy",
"added_at": "2026-01-31T12:00:00Z",
"containerConfig": {
"additionalMounts": [
{
"hostPath": "~/projects/webapp",
"containerPath": "webapp",
"readonly": false
}
]
}
}
}
```
The directory will appear at `/workspace/extra/webapp` in that group's container.
#### Sender Allowlist
After registering a group, explain the sender allowlist feature to the user:
> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
>
> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
> - **Drop mode**: Messages from non-allowed senders are not stored at all.
>
> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
```json
{
"default": { "allow": "*", "mode": "trigger" },
"chats": {
"<chat-jid>": {
"allow": ["sender-id-1", "sender-id-2"],
"mode": "trigger"
}
},
"logDenied": true
}
```
Notes:
- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
2. Remove the entry for that group
3. Write the updated JSON back
4. The group folder and its files remain (don't delete them)
### Listing Groups
Read `/workspace/project/data/registered_groups.json` and format it nicely.
---
## Global Memory
You can read and write to `/workspace/project/groups/global/CLAUDE.md` for facts that should apply to all groups. Only update global memory when explicitly asked to "remember this globally" or similar.
---
## Scheduling for Other Groups
When scheduling tasks for other groups, use the `target_group_jid` parameter with the group's JID from `registered_groups.json`:
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
Keep platform-wide operating rules in `prompts/claude-platform.md`.

View File

@@ -6,7 +6,7 @@
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"build:runners": "cd container/agent-runner && npm install && npm run build && cd ../codex-runner && npm install && npm run build",
"build:runners": "npm --prefix runners/agent-runner install && npm --prefix runners/agent-runner run build && npm --prefix runners/codex-runner install && npm --prefix runners/codex-runner run build",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"typecheck": "tsc --noEmit",

View File

@@ -0,0 +1,20 @@
# Claude Paired Room Rules
This room has both Claude and Codex.
Both of you can read the same room conversation and respond in the same thread.
Your default role is review, test planning, verification, and risk checking.
Discussion and design debate are shared responsibilities. You can challenge Codex, refine its approach, and propose alternatives when they are stronger.
Keep coordination with Codex public by default. Use `<internal>` only for content that truly needs to stay hidden from the room.
When Codex is already implementing, prefer:
- clarifying requirements
- surfacing edge cases and regressions
- proposing focused tests
- reviewing results and calling out risks
Let Codex take the lead on implementation in most cases.
You can still implement when the user explicitly asks you to, when Codex is blocked, or when a small targeted patch is the fastest way to verify a point.

View File

@@ -0,0 +1,46 @@
# Claude Platform Rules
You are Andy, a personal assistant.
## Communication
Your output is sent directly to the user or Discord group.
You also have a `send_message` tool, which sends a message immediately while you are still working. Use it when you want to acknowledge a request before starting longer work.
### Internal thoughts
Use `<internal>` only for genuinely hidden content.
If part of your output is internal reasoning rather than something for the user, wrap it in `<internal>` tags:
```text
<internal>Compiled all three reports, ready to summarize.</internal>
Here are the key findings from the research...
```
Text inside `<internal>` tags is logged but not sent to the user.
Prefer public replies for coordination, status updates, review comments, and anything Codex or the user should react to.
### Sub-agents and teammates
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
## Memory
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
When you learn something important:
- Create files for structured data when that is genuinely useful
- Split files larger than 500 lines into smaller folders or documents
- Keep an index if you start building a larger memory structure
## Message formatting
Do not use markdown headings in chat replies. Keep messages clean and readable for Discord.
- Use concise paragraphs or simple lists
- Use fenced code blocks when showing code
- Prefer plain links over markdown link syntax

View File

@@ -0,0 +1,18 @@
# Codex Paired Room Rules
This room has both Claude and Codex.
Both of you can read the same room conversation and respond in the same thread.
Your default role is implementation, debugging, command execution, and concrete code changes.
Take the lead on implementation in this room unless the user explicitly redirects the work.
Discussion and design debate are shared responsibilities. Engage with Claude critically and evaluate its feedback on the merits.
Treat Claude's feedback as input to inspect, test, and reason through, not as something to accept automatically.
When Claude is already reviewing or testing, prefer:
- making the code change
- running commands and checks
- narrowing the bug or failure
- reporting concrete results back to the room

28
prompts/codex-platform.md Normal file
View File

@@ -0,0 +1,28 @@
# Codex Platform Rules
You are 코덱스, a participant in a Discord chat.
## Core rules
- Respond directly to messages. Do not provide reply suggestions or draft responses for someone else to send.
- Respond in Korean.
- When coding, debugging, or file work is needed, do it directly.
## Communication
Your output is sent directly to the Discord group.
- Keep answers concise unless more detail is genuinely needed
- Give conclusions and concrete next steps, not hidden reasoning
- Use code blocks for commands or code when helpful
- Do not claim you will keep watching, monitor later, report back later, or continue tracking unless you actually scheduled an EJClaw task with `watch_ci` or `schedule_task`
- If no task was scheduled, do not imply that background tracking is active. If future follow-up is needed, tell the user to ping you again or explicitly ask for scheduling
- When you do schedule background follow-up, mention that it was scheduled. Include the task ID only when it is useful for later reference
## Working style
- Prefer reading the current workspace before making assumptions
- Modify only what is needed for the task
- Verify changes when you can instead of claiming they should work
- For CI/status/watch requests that require future follow-up, schedule `watch_ci`
- Use generic `schedule_task` for reminders or other non-CI recurring work

View File

@@ -724,7 +724,6 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -929,7 +928,6 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1509,7 +1507,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,10 +1,10 @@
/**
* NanoClaw Agent Runner
* Runs inside a container, receives config via stdin, outputs result to stdout
* Runs as a child process, 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/
* Stdin: Full RunnerInput JSON (read until EOF, like before)
* IPC: Follow-up messages written as JSON files to $NANOCLAW_IPC_DIR/input/
* Files: {type:"message", text:"..."}.json polled and consumed
* Sentinel: /workspace/ipc/input/_close signals session end
*
@@ -59,11 +59,9 @@ interface SDKUserMessage {
session_id: string;
}
// Paths configurable via env vars (defaults to container paths for backwards compat)
// Paths configurable via env vars.
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const GLOBAL_DIR = process.env.NANOCLAW_GLOBAL_DIR || '/workspace/global';
const EXTRA_BASE = process.env.NANOCLAW_EXTRA_DIR || '/workspace/extra';
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
@@ -450,13 +448,6 @@ async function runQuery(
let messageCount = 0;
let resultCount = 0;
// Load global CLAUDE.md as additional system context (shared across all groups)
const globalClaudeMdPath = path.join(GLOBAL_DIR, 'CLAUDE.md');
let globalClaudeMd: string | undefined;
if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
}
// Discover additional directories
const extraDirs: string[] = [];
@@ -465,17 +456,6 @@ async function runQuery(
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
extraDirs.push(GROUP_DIR);
log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`);
} else {
// Only scan EXTRA_BASE when no WORK_DIR override (legacy container mount support)
const extraBase = EXTRA_BASE;
if (extraBase && 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(', ')}`);
@@ -507,9 +487,6 @@ async function runQuery(
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',

184
runners/codex-runner/package-lock.json generated Normal file
View File

@@ -0,0 +1,184 @@
{
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw-codex-runner",
"version": "1.0.0",
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"
}
},
"node_modules/@openai/codex": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0.tgz",
"integrity": "sha512-uu689DHUzvuPcb39hJ+7fqy++TAvY32w9VggDpcz3HS0Sx0WadWoAPPcMK547P2T6AqfMsAtA8kspkR3tqErOg==",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"@openai/codex-darwin-arm64": "npm:@openai/codex@0.115.0-darwin-arm64",
"@openai/codex-darwin-x64": "npm:@openai/codex@0.115.0-darwin-x64",
"@openai/codex-linux-arm64": "npm:@openai/codex@0.115.0-linux-arm64",
"@openai/codex-linux-x64": "npm:@openai/codex@0.115.0-linux-x64",
"@openai/codex-win32-arm64": "npm:@openai/codex@0.115.0-win32-arm64",
"@openai/codex-win32-x64": "npm:@openai/codex@0.115.0-win32-x64"
}
},
"node_modules/@openai/codex-darwin-arm64": {
"name": "@openai/codex",
"version": "0.115.0-darwin-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-arm64.tgz",
"integrity": "sha512-wTWV+YlDTL0y0mL+FMmbzhilm+dPkbIZTe/lH3LwBzcEMhgSGLsPdZLfAiMND4wFE21HS7H0pjMogNQEMLQmqg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-darwin-x64": {
"name": "@openai/codex",
"version": "0.115.0-darwin-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-darwin-x64.tgz",
"integrity": "sha512-EPzgymU4CFp83qjv29wXFwhWib3zC8g6SLTJGh2OpcJiOYyLY0bO53FB+QchL1jC9gm1uLyD5j5F3ddI0AbjIg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-arm64": {
"name": "@openai/codex",
"version": "0.115.0-linux-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-arm64.tgz",
"integrity": "sha512-40+SCyI+LvVx/iX30qH7dTQzWt9MZxDaK2E6YRB4hoYej5UALrhkFNzweHa5uvqvhmqGZCuagZOYB8285eGCgw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-linux-x64": {
"name": "@openai/codex",
"version": "0.115.0-linux-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-linux-x64.tgz",
"integrity": "sha512-+6eRd2p4KMrhQvuF7XpjYIdCA2Ok2LbhOq+ywZdLpHbmwQ/Yynd0gJ/Q90xCeo2vloCwl9WsbsiVVSahG5FyWg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-sdk": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.115.0.tgz",
"integrity": "sha512-BPoPhim0uUm3rzugY7JFaFJ+rPG/wMRhvKNFii//Dp3kTb+gFy3jrip7ijXqhswsnqXM3nwTiv7kYHt1+TMUPg==",
"license": "Apache-2.0",
"dependencies": {
"@openai/codex": "0.115.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@openai/codex-win32-arm64": {
"name": "@openai/codex",
"version": "0.115.0-win32-arm64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-arm64.tgz",
"integrity": "sha512-yaYhQ0kPL9Kithmrr1vh5A4c+gqAMhMZeA/htTtkpWW8RQkmwgcYYpX/Ky2cEzu0w51pvxQQy63LgHftc+pg1Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@openai/codex-win32-x64": {
"name": "@openai/codex",
"version": "0.115.0-win32-x64",
"resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.115.0-win32-x64.tgz",
"integrity": "sha512-wtYf2HJCB+p+Uj7o1W08fRgZMzKCVGRQ4YdSfLRNfF4wwPqX5XsK9blsv7brukn5J9lclYxagiR6qGvbfHbD7w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -8,7 +8,9 @@
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {},
"dependencies": {
"@openai/codex-sdk": "^0.115.0"
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3"

View File

@@ -0,0 +1,444 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process';
import { createRequire } from 'module';
import path from 'path';
import {
createInitialAppServerTurnState,
getAppServerTurnResult,
isAppServerTurnFinished,
reduceAppServerTurnState,
type AppServerTurnEvent,
type AppServerTurnState,
} from './app-server-state.js';
export interface AppServerInputItemText {
type: 'text';
text: string;
}
export interface AppServerInputItemLocalImage {
type: 'localImage';
path: string;
}
export type AppServerInputItem =
| AppServerInputItemText
| AppServerInputItemLocalImage;
export interface CodexAppServerThreadOptions {
cwd: string;
model?: string;
}
export interface CodexAppServerTurnOptions {
cwd: string;
model?: string;
effort?: string;
}
export interface CodexAppServerTurnResult {
state: AppServerTurnState;
result: string | null;
}
interface JsonRpcResponse {
id: number;
result?: unknown;
error?: {
code?: number;
message?: string;
data?: unknown;
};
}
interface JsonRpcNotification {
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcServerRequest extends JsonRpcNotification {
id: number;
}
interface PendingRequest {
method: string;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
}
interface ActiveTurn {
threadId: string;
state: AppServerTurnState;
resolve: (value: CodexAppServerTurnResult) => void;
reject: (reason?: unknown) => void;
}
export interface CodexAppServerClientOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
log: (message: string) => void;
}
export class CodexAppServerClient {
private readonly cwd: string;
private readonly env: NodeJS.ProcessEnv;
private readonly log: (message: string) => void;
private readonly pending = new Map<number, PendingRequest>();
private readonly require = createRequire(import.meta.url);
private nextId = 1;
private stdoutBuffer = '';
private activeTurn: ActiveTurn | null = null;
private proc: ChildProcessWithoutNullStreams | null = null;
constructor(options: CodexAppServerClientOptions) {
this.cwd = options.cwd;
this.env = options.env || process.env;
this.log = options.log;
}
async start(): Promise<void> {
if (this.proc) return;
const codexPackagePath = this.require.resolve('@openai/codex/package.json');
const codexBin = path.join(path.dirname(codexPackagePath), 'bin', 'codex.js');
this.proc = spawn(process.execPath, [codexBin, 'app-server'], {
cwd: this.cwd,
env: this.env,
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc.stdout.setEncoding('utf8');
this.proc.stdout.on('data', (chunk: string) => {
this.stdoutBuffer += chunk;
const lines = this.stdoutBuffer.split('\n');
this.stdoutBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
this.handleStdoutLine(trimmed);
}
});
this.proc.stderr.setEncoding('utf8');
this.proc.stderr.on('data', (chunk: string) => {
for (const line of chunk.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
this.log(`[app-server] ${trimmed}`);
}
});
this.proc.on('close', (code) => {
const error = new Error(
`Codex app-server exited with code ${code ?? 'unknown'}`,
);
this.rejectAll(error);
});
this.proc.on('error', (error) => {
this.rejectAll(error);
});
await this.request('initialize', {
clientInfo: {
name: 'nanoclaw_codex_runner',
title: 'NanoClaw Codex Runner',
version: '1.0.0',
},
capabilities: {
experimentalApi: true,
optOutNotificationMethods: [
'item/agentMessage/delta',
'item/plan/delta',
'item/reasoning/textDelta',
'item/reasoning/summaryTextDelta',
'item/reasoning/summaryPartAdded',
],
},
});
this.notify('initialized', {});
}
async close(): Promise<void> {
if (!this.proc) return;
const proc = this.proc;
this.proc = null;
try {
proc.kill('SIGTERM');
} catch {
/* ignore */
}
}
async startOrResumeThread(
sessionId: string | undefined,
options: CodexAppServerThreadOptions,
): Promise<string> {
const params = {
cwd: options.cwd,
model: options.model,
approvalPolicy: 'never',
sandbox: 'danger-full-access',
serviceName: 'nanoclaw',
};
const result = sessionId
? await this.request('thread/resume', {
threadId: sessionId,
...params,
})
: await this.request('thread/start', params);
const thread = (result as { thread?: { id?: string } }).thread;
if (!thread?.id) {
throw new Error('Codex app-server did not return a thread id.');
}
return thread.id;
}
async startTurn(
threadId: string,
input: AppServerInputItem[],
options: CodexAppServerTurnOptions,
): Promise<{
turnId: string;
steer: (nextInput: AppServerInputItem[]) => Promise<void>;
interrupt: () => Promise<void>;
wait: () => Promise<CodexAppServerTurnResult>;
}> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
resolve,
reject,
};
});
let turnId = '';
try {
const response = (await this.request('turn/start', {
threadId,
input,
cwd: options.cwd,
approvalPolicy: 'never',
sandboxPolicy: {
type: 'dangerFullAccess',
networkAccess: true,
},
model: options.model,
effort: options.effort,
summary: 'concise',
})) as { turn?: { id?: string; status?: string } };
turnId = response.turn?.id || '';
if (!turnId) {
throw new Error('Codex app-server did not return a turn id.');
}
const activeTurn = this.activeTurn as ActiveTurn | null;
if (activeTurn !== null) {
activeTurn.state = reduceAppServerTurnState(activeTurn.state, {
method: 'turn/started',
params: {
turn: {
id: turnId,
status: response.turn?.status || 'inProgress',
},
},
});
}
} catch (error) {
this.activeTurn = null;
throw error;
}
return {
turnId,
steer: async (nextInput) => {
await this.request('turn/steer', {
threadId,
input: nextInput,
expectedTurnId: turnId,
});
},
interrupt: async () => {
await this.request('turn/interrupt', {
threadId,
turnId,
});
},
wait: async () => turnPromise,
};
}
async startCompaction(threadId: string): Promise<CodexAppServerTurnResult> {
if (this.activeTurn) {
throw new Error('A Codex app-server turn is already active.');
}
const turnPromise = new Promise<CodexAppServerTurnResult>((resolve, reject) => {
this.activeTurn = {
threadId,
state: createInitialAppServerTurnState(),
resolve,
reject,
};
});
try {
await this.request('thread/compact/start', { threadId });
} catch (error) {
this.activeTurn = null;
throw error;
}
return turnPromise;
}
private handleStdoutLine(line: string): void {
let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest;
try {
message = JSON.parse(line);
} catch {
this.log(`[app-server] non-JSON stdout: ${line}`);
return;
}
if (
typeof (message as JsonRpcResponse).id === 'number' &&
('result' in message || 'error' in message) &&
!('method' in message)
) {
this.handleResponse(message as JsonRpcResponse);
return;
}
if (
typeof (message as JsonRpcServerRequest).id === 'number' &&
typeof (message as JsonRpcServerRequest).method === 'string'
) {
this.handleServerRequest(message as JsonRpcServerRequest);
return;
}
if (typeof (message as JsonRpcNotification).method === 'string') {
this.handleNotification(message as JsonRpcNotification);
}
}
private handleResponse(message: JsonRpcResponse): void {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (message.error) {
pending.reject(
new Error(
message.error.message ||
`${pending.method} failed with JSON-RPC error ${message.error.code ?? 'unknown'}`,
),
);
return;
}
pending.resolve(message.result);
}
private handleServerRequest(message: JsonRpcServerRequest): void {
if (message.method.endsWith('/requestApproval')) {
this.respond(message.id, 'acceptForSession');
return;
}
this.respondError(
message.id,
-32601,
`NanoClaw does not handle server request ${message.method}`,
);
}
private handleNotification(message: JsonRpcNotification): void {
if (!this.activeTurn) return;
this.activeTurn.state = reduceAppServerTurnState(
this.activeTurn.state,
message as AppServerTurnEvent,
);
if (!isAppServerTurnFinished(this.activeTurn.state)) {
return;
}
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.resolve({
state: activeTurn.state,
result: getAppServerTurnResult(activeTurn.state),
});
}
private rejectAll(error: unknown): void {
for (const pending of this.pending.values()) {
pending.reject(error);
}
this.pending.clear();
if (this.activeTurn) {
const activeTurn = this.activeTurn;
this.activeTurn = null;
activeTurn.reject(error);
}
}
private request(method: string, params?: unknown): Promise<unknown> {
const id = this.nextId++;
const payload = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise((resolve, reject) => {
this.pending.set(id, { method, resolve, reject });
this.write(payload);
});
}
private notify(method: string, params?: unknown): void {
this.write({
jsonrpc: '2.0',
method,
params,
});
}
private respond(id: number, result: unknown): void {
this.write({
jsonrpc: '2.0',
id,
result,
});
}
private respondError(id: number, code: number, message: string): void {
this.write({
jsonrpc: '2.0',
id,
error: { code, message },
});
}
private write(message: Record<string, unknown>): void {
if (!this.proc?.stdin.writable) {
throw new Error('Codex app-server stdin is not writable.');
}
this.proc.stdin.write(JSON.stringify(message) + '\n');
}
}

View File

@@ -0,0 +1,174 @@
export interface AppServerAgentMessageItem {
type: 'agentMessage';
text?: string | null;
phase?: 'commentary' | 'final_answer' | string | null;
}
export interface AppServerContextCompactionItem {
type: 'contextCompaction';
id?: string;
}
export type AppServerItem =
| AppServerAgentMessageItem
| AppServerContextCompactionItem
| {
type: string;
[key: string]: unknown;
};
export interface AppServerTurnState {
turnId?: string;
status: 'pending' | 'inProgress' | 'completed' | 'failed' | 'interrupted';
finalAnswer: string | null;
latestAgentMessage: string | null;
errorMessage: string | null;
compactionCompleted: boolean;
}
export type AppServerTurnEvent =
| {
method: 'turn/started';
params?: { turn?: { id?: string | null; status?: string | null } };
}
| {
method: 'turn/completed';
params?: {
turn?: {
id?: string | null;
status?: string | null;
error?:
| { message?: string | null }
| string
| null;
};
};
}
| {
method: 'item/completed';
params?: { item?: AppServerItem | null };
}
| {
method: 'error';
params?: {
error?: {
message?: string | null;
codexErrorInfo?: {
httpStatusCode?: number | null;
type?: string | null;
} | null;
} | null;
};
};
export function createInitialAppServerTurnState(): AppServerTurnState {
return {
status: 'pending',
finalAnswer: null,
latestAgentMessage: null,
errorMessage: null,
compactionCompleted: false,
};
}
export function reduceAppServerTurnState(
state: AppServerTurnState,
event: AppServerTurnEvent,
): AppServerTurnState {
if (event.method === 'turn/started') {
return {
...state,
turnId: event.params?.turn?.id || state.turnId,
status: 'inProgress',
};
}
if (event.method === 'item/completed') {
const item = event.params?.item;
if (!item) return state;
if (item.type === 'agentMessage') {
const text =
typeof item.text === 'string' && item.text.trim().length > 0
? item.text
: null;
if (!text) return state;
if (item.phase === 'final_answer') {
return {
...state,
finalAnswer: text,
latestAgentMessage: text,
};
}
return {
...state,
latestAgentMessage: text,
};
}
if (item.type === 'contextCompaction') {
return {
...state,
compactionCompleted: true,
};
}
return state;
}
if (event.method === 'error') {
const error = event.params?.error;
const message =
typeof error?.message === 'string' && error.message.trim().length > 0
? error.message.trim()
: 'Codex app-server turn failed.';
const httpStatusCode = error?.codexErrorInfo?.httpStatusCode;
return {
...state,
errorMessage:
typeof httpStatusCode === 'number'
? `${message} (HTTP ${httpStatusCode})`
: message,
};
}
if (event.method === 'turn/completed') {
const turn = event.params?.turn;
const status =
turn?.status === 'completed' ||
turn?.status === 'failed' ||
turn?.status === 'interrupted'
? turn.status
: 'completed';
const turnError =
typeof turn?.error === 'string'
? turn.error
: turn?.error?.message || null;
return {
...state,
turnId: turn?.id || state.turnId,
status,
errorMessage: turnError || state.errorMessage,
};
}
return state;
}
export function isAppServerTurnFinished(state: AppServerTurnState): boolean {
return (
state.status === 'completed' ||
state.status === 'failed' ||
state.status === 'interrupted'
);
}
export function getAppServerTurnResult(
state: AppServerTurnState,
): string | null {
return state.finalAnswer || state.latestAgentMessage;
}

View File

@@ -0,0 +1,631 @@
/**
* NanoClaw Codex Runner
*
* Default runtime is Codex app-server, with SDK fallback available via
* CODEX_RUNTIME=sdk or automatic fallback when app-server startup fails.
*
* Input protocol:
* Stdin: Full ContainerInput JSON (read until EOF)
* IPC: Follow-up messages as JSON files in $NANOCLAW_IPC_DIR/input/
* Sentinel: _close — signals session end
*
* Stdout protocol:
* Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
*/
import { Codex, type Thread, type ThreadOptions, type UserInput } from '@openai/codex-sdk';
import fs from 'fs';
import path from 'path';
import {
CodexAppServerClient,
type AppServerInputItem,
} from './app-server-client.js';
// ── Types ──────────────────────────────────────────────────────────
interface ContainerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
agentType?: string;
}
interface ContainerOutput {
status: 'success' | 'error';
result: string | null;
newSessionId?: string;
error?: string;
}
// ── Constants ──────────────────────────────────────────────────────
const GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, 'input');
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
const IPC_POLL_MS = 500;
const MAX_TURNS = 100;
const CODEX_RUNTIME = (process.env.CODEX_RUNTIME || 'app-server').toLowerCase();
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
const EFFECTIVE_CWD = WORK_DIR || GROUP_DIR;
const CODEX_MODEL = process.env.CODEX_MODEL || '';
const CODEX_EFFORT = process.env.CODEX_EFFORT || '';
let closeRequested = false;
// ── Helpers ────────────────────────────────────────────────────────
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(`[codex-runner] ${message}`);
}
async function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk: string) => {
data += chunk;
});
process.stdin.on('end', () => resolve(data));
process.stdin.on('error', reject);
});
}
function consumeCloseSentinel(): boolean {
if (closeRequested) return true;
if (!fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) return false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
closeRequested = true;
return true;
}
function drainIpcInput(): string[] {
try {
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
const files = fs
.readdirSync(IPC_INPUT_DIR)
.filter((file) => file.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 [];
}
}
function waitForIpcMessage(): Promise<string | null> {
return new Promise((resolve) => {
const poll = () => {
if (consumeCloseSentinel()) {
resolve(null);
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
resolve(messages.join('\n'));
return;
}
setTimeout(poll, IPC_POLL_MS);
};
poll();
});
}
function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } {
const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g;
const imagePaths: string[] = [];
let match: RegExpExecArray | null;
while ((match = imagePattern.exec(text)) !== null) {
imagePaths.push(match[1].trim());
}
return {
cleanText: text.replace(imagePattern, '').trim(),
imagePaths,
};
}
function parseSdkInput(text: string): string | UserInput[] {
const { cleanText, imagePaths } = extractImagePaths(text);
if (imagePaths.length === 0) return text;
const input: UserInput[] = [];
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
for (const imgPath of imagePaths) {
if (fs.existsSync(imgPath)) {
input.push({ type: 'local_image', path: imgPath });
log(`Adding image input: ${imgPath}`);
} else {
log(`Image not found, skipping: ${imgPath}`);
}
}
return input.length > 0 ? input : text;
}
function parseAppServerInput(text: string): AppServerInputItem[] {
const { cleanText, imagePaths } = extractImagePaths(text);
const input: AppServerInputItem[] = [];
if (cleanText) {
input.push({ type: 'text', text: cleanText });
}
for (const imgPath of imagePaths) {
if (fs.existsSync(imgPath)) {
input.push({ type: 'localImage', path: imgPath });
log(`Adding image input: ${imgPath}`);
} else {
log(`Image not found, skipping: ${imgPath}`);
}
}
if (input.length === 0) {
input.push({ type: 'text', text });
}
return input;
}
function getThreadOptions(): ThreadOptions {
const threadOptions: ThreadOptions = {
workingDirectory: EFFECTIVE_CWD,
approvalPolicy: 'never',
sandboxMode: 'danger-full-access',
networkAccessEnabled: true,
webSearchMode: 'live',
};
if (CODEX_MODEL) threadOptions.model = CODEX_MODEL;
if (CODEX_EFFORT) {
threadOptions.modelReasoningEffort =
CODEX_EFFORT as ThreadOptions['modelReasoningEffort'];
}
return threadOptions;
}
async function executeSdkTurn(
thread: Thread,
input: string | UserInput[],
): Promise<{ result: string; error?: string }> {
const ac = new AbortController();
let turnSeconds = 0;
const sentinel = setInterval(() => {
if (consumeCloseSentinel()) {
log('Close sentinel detected during SDK turn, aborting');
ac.abort();
return;
}
turnSeconds += 5;
if (turnSeconds % 60 === 0) {
log(`Turn in progress... (${Math.round(turnSeconds / 60)}min)`);
}
}, 5000);
try {
const turn = await thread.run(input, { signal: ac.signal });
return { result: turn.finalResponse };
} catch (err) {
if (ac.signal.aborted) {
return { result: '' };
}
return {
result: '',
error: err instanceof Error ? err.message : String(err),
};
} finally {
clearInterval(sentinel);
}
}
async function executeAppServerTurn(
client: CodexAppServerClient,
threadId: string,
prompt: string,
): Promise<{ result: string; error?: string }> {
const activeTurn = await client.startTurn(threadId, parseAppServerInput(prompt), {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
effort: CODEX_EFFORT || undefined,
});
let elapsedMs = 0;
let polling = true;
const pollDuringTurn = async () => {
if (!polling) return;
if (consumeCloseSentinel()) {
log('Close sentinel detected during app-server turn, interrupting');
polling = false;
try {
await activeTurn.interrupt();
} catch (err) {
log(
`Failed to interrupt active turn: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
return;
}
const messages = drainIpcInput();
if (messages.length > 0) {
const merged = messages.join('\n');
log(`Steering active turn with ${messages.length} queued message(s)`);
try {
await activeTurn.steer(parseAppServerInput(merged));
} catch (err) {
log(
`turn/steer failed: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
elapsedMs += IPC_POLL_MS;
if (elapsedMs > 0 && elapsedMs % 60000 === 0) {
log(`Turn in progress... (${Math.round(elapsedMs / 60000)}min)`);
}
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
};
setTimeout(() => void pollDuringTurn(), IPC_POLL_MS);
try {
const { state, result } = await activeTurn.wait();
if (state.status === 'completed') {
return { result: result || '' };
}
if (state.status === 'interrupted' && consumeCloseSentinel()) {
return { result: result || '' };
}
return {
result: result || '',
error: state.errorMessage || `Codex turn finished with status ${state.status}`,
};
} finally {
polling = false;
}
}
async function runSdkSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const threadOptions = getThreadOptions();
const codex = new Codex();
let thread: Thread;
if (containerInput.sessionId) {
thread = codex.resumeThread(containerInput.sessionId, threadOptions);
log(`Thread resuming (session: ${containerInput.sessionId})`);
} else {
thread = codex.startThread(threadOptions);
log('Thread started (new session)');
}
let turnCount = 0;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: thread.id || undefined,
});
break;
}
const input = parseSdkInput(prompt);
log(`Starting SDK turn ${turnCount}/${MAX_TURNS}...`);
let { result, error } = await executeSdkTurn(thread, input);
if (error && turnCount === 1 && containerInput.sessionId) {
log(`Resume may have failed, retrying with new thread: ${error}`);
thread = codex.startThread(threadOptions);
({ result, error } = await executeSdkTurn(thread, input));
}
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: thread.id || undefined,
});
}
log('Close sentinel detected, exiting SDK runtime');
break;
}
if (error) {
log(`SDK turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: thread.id || undefined,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: thread.id || undefined,
});
}
log('SDK turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting SDK runtime');
break;
}
log(`Got new SDK message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
}
async function runAppServerCompact(
client: CodexAppServerClient,
threadId: string | undefined,
): Promise<void> {
if (!threadId) {
writeOutput({
status: 'success',
result: '현재 활성 Codex 세션이 없어 compact를 건너뜁니다.',
});
return;
}
const { state } = await client.startCompaction(threadId);
if (state.status === 'failed') {
writeOutput({
status: 'error',
result: null,
newSessionId: threadId,
error: state.errorMessage || 'Conversation compaction failed.',
});
return;
}
writeOutput({
status: 'success',
result: state.compactionCompleted
? 'Conversation compacted.'
: 'Compaction requested but contextCompaction was not observed.',
newSessionId: threadId,
});
}
async function runAppServerSession(
containerInput: ContainerInput,
prompt: string,
): Promise<void> {
const client = new CodexAppServerClient({
cwd: EFFECTIVE_CWD,
env: process.env,
log,
});
await client.start();
let threadId: string | undefined;
try {
try {
threadId = await client.startOrResumeThread(containerInput.sessionId, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(
containerInput.sessionId
? `App-server thread resumed (${threadId})`
: `App-server thread started (${threadId})`,
);
} catch (err) {
if (!containerInput.sessionId) throw err;
log(
`App-server resume failed, retrying with new thread: ${
err instanceof Error ? err.message : String(err)
}`,
);
threadId = await client.startOrResumeThread(undefined, {
cwd: EFFECTIVE_CWD,
model: CODEX_MODEL || undefined,
});
log(`App-server thread restarted (${threadId})`);
}
const trimmedPrompt = prompt.trim();
if (trimmedPrompt === '/compact') {
await runAppServerCompact(client, threadId);
return;
}
let turnCount = 0;
while (true) {
turnCount++;
if (turnCount > MAX_TURNS) {
log(`Turn limit reached (${MAX_TURNS}), exiting`);
writeOutput({
status: 'success',
result: '[세션 턴 제한 도달. 새 메시지로 다시 시작됩니다.]',
newSessionId: threadId,
});
break;
}
log(`Starting app-server turn ${turnCount}/${MAX_TURNS}...`);
const { result, error } = await executeAppServerTurn(client, threadId, prompt);
if (consumeCloseSentinel()) {
if (result) {
writeOutput({
status: 'success',
result,
newSessionId: threadId,
});
}
log('Close sentinel detected, exiting app-server runtime');
break;
}
if (error) {
log(`App-server turn error: ${error}`);
writeOutput({
status: 'error',
result: result || null,
newSessionId: threadId,
error,
});
} else {
writeOutput({
status: 'success',
result: result || null,
newSessionId: threadId,
});
}
log('App-server turn done, waiting for next IPC message...');
const nextMessage = await waitForIpcMessage();
if (nextMessage === null) {
log('Close sentinel received, exiting app-server runtime');
break;
}
log(`Got new app-server message (${nextMessage.length} chars)`);
prompt = nextMessage;
}
} finally {
await client.close();
}
}
function shouldUseAppServer(): boolean {
return CODEX_RUNTIME !== 'sdk';
}
// ── Main ──────────────────────────────────────────────────────────
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);
}
fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
closeRequested = false;
try {
fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL);
} catch {
/* ignore */
}
let prompt = containerInput.prompt;
if (containerInput.isScheduledTask) {
prompt = `[SCHEDULED TASK]\n\n${prompt}`;
}
const pending = drainIpcInput();
if (pending.length > 0) {
prompt += '\n' + pending.join('\n');
}
const preferAppServer = shouldUseAppServer();
try {
if (preferAppServer) {
try {
log(`Runtime selected: app-server (${CODEX_RUNTIME})`);
await runAppServerSession(containerInput, prompt);
return;
} catch (err) {
if (CODEX_RUNTIME === 'app-server') {
log(
`App-server runtime failed, falling back to SDK: ${
err instanceof Error ? err.message : String(err)
}`,
);
} else {
throw err;
}
}
}
log('Runtime selected: sdk');
await runSdkSession(containerInput, prompt);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log(`Runner error: ${errorMessage}`);
writeOutput({
status: 'error',
result: null,
error: errorMessage,
});
}
}
main();

View File

@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
/**
* Tests for the environment check step.
*
* Verifies: config detection, Docker/AC detection, DB queries.
* Verifies: config detection, platform helpers, DB queries.
*/
describe('environment detection', () => {
@@ -28,7 +28,7 @@ describe('registered groups DB query', () => {
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1
)`);
});
@@ -96,7 +96,7 @@ describe('credentials detection', () => {
});
});
describe('Docker detection logic', () => {
describe('platform command detection', () => {
it('commandExists returns boolean', async () => {
const { commandExists } = await import('./platform.js');
expect(typeof commandExists('docker')).toBe('boolean');
@@ -104,18 +104,3 @@ describe('Docker detection logic', () => {
});
});
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);
});
});

View File

@@ -1,10 +1,7 @@
/**
* 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
* Step: groups — List known Discord groups from the local chat metadata store.
* Discord channel names are discovered at runtime, so the sync path is a no-op.
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
@@ -28,7 +25,6 @@ function parseArgs(args: string[]): { list: boolean; limit: number } {
}
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const { list, limit } = parseArgs(args);
if (list) {
@@ -36,7 +32,7 @@ export async function run(args: string[]): Promise<void> {
return;
}
await syncGroups(projectRoot);
await syncGroups();
}
async function listGroups(limit: number): Promise<void> {
@@ -51,9 +47,9 @@ async function listGroups(limit: number): Promise<void> {
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 ?`,
WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid
ORDER BY last_message_time DESC
LIMIT ?`,
)
.all(limit) as Array<{ jid: string; name: string }>;
db.close();
@@ -63,167 +59,33 @@ async function listGroups(limit: number): Promise<void> {
}
}
async function syncGroups(projectRoot: string): Promise<void> {
// 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;
async function syncGroups(): Promise<void> {
const dbPath = path.join(STORE_DIR, 'messages.db');
let groupsInDb = 0;
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__'",
`SELECT COUNT(*) as count FROM chats
WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid`,
)
.get() as { count: number };
groupsInDb = row.count;
db.close();
} catch {
// DB may not exist yet
} catch (err) {
logger.warn({ err }, 'Failed to count Discord groups during setup');
}
}
const status = syncOk ? 'success' : 'failed';
logger.info({ groupsInDb }, 'Discord groups are discovered at runtime');
emitStatus('SYNC_GROUPS', {
BUILD: buildOk ? 'success' : 'failed',
SYNC: syncOk ? 'success' : 'failed',
BUILD: 'skipped',
SYNC: 'skipped',
GROUPS_IN_DB: groupsInDb,
STATUS: status,
REASON: 'discord_runtime_sync',
STATUS: 'success',
LOG: 'logs/setup.log',
});
if (status === 'failed') process.exit(1);
}

View File

@@ -10,10 +10,9 @@ const STEPS: Record<
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
environment: () => import('./environment.js'),
runners: () => import('./container.js'),
runners: () => import('./runners.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
service: () => import('./service.js'),
verify: () => import('./verify.js'),
};

View File

@@ -1,115 +0,0 @@
/**
* 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<void> {
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',
});
}

View File

@@ -17,7 +17,7 @@ function createTestDb(): Database.Database {
folder TEXT NOT NULL UNIQUE,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
)`);
@@ -34,7 +34,7 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
@@ -67,7 +67,7 @@ describe('parameterized SQL registration', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'456@g.us',
@@ -92,7 +92,7 @@ describe('parameterized SQL registration', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
@@ -113,10 +113,10 @@ describe('parameterized SQL registration', () => {
it('handles requiresTrigger=false', () => {
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'789@s.whatsapp.net',
'dc:789',
'Personal',
'main',
'@Andy',
@@ -126,7 +126,7 @@ describe('parameterized SQL registration', () => {
const row = db
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { requires_trigger: number };
.get('dc:789') as { requires_trigger: number };
expect(row.requires_trigger).toBe(0);
});
@@ -134,12 +134,12 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(
'789@s.whatsapp.net',
'dc:789',
'Personal',
'whatsapp_main',
'discord_main',
'@Andy',
'2024-01-01T00:00:00.000Z',
0,
@@ -148,7 +148,7 @@ describe('parameterized SQL registration', () => {
const row = db
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
.get('789@s.whatsapp.net') as { is_main: number };
.get('dc:789') as { is_main: number };
expect(row.is_main).toBe(1);
});
@@ -156,12 +156,12 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
).run(
'123@g.us',
'Some Group',
'whatsapp_some-group',
'discord_some-group',
'@Andy',
'2024-01-01T00:00:00.000Z',
1,
@@ -177,7 +177,7 @@ describe('parameterized SQL registration', () => {
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)
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger)
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
);

View File

@@ -1,7 +1,7 @@
/**
* Step: register — Write channel registration config, create group folders.
*
* Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord).
* NanoClaw is Discord-only, so registrations must target Discord channel IDs.
* Uses parameterized SQL queries to prevent injection.
*/
import fs from 'fs';
@@ -9,7 +9,7 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
import { SERVICE_AGENT_TYPE, STORE_DIR } from '../src/config.js';
import { isValidGroupFolder } from '../src/group-folder.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
@@ -31,7 +31,7 @@ function parseArgs(args: string[]): RegisterArgs {
name: '',
trigger: '',
folder: '',
channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel
channel: 'discord',
requiresTrigger: true,
isMain: false,
assistantName: 'Andy',
@@ -91,10 +91,18 @@ export async function run(args: string[]): Promise<void> {
process.exit(4);
}
if (parsed.channel !== 'discord') {
emitStatus('REGISTER_CHANNEL', {
STATUS: 'failed',
ERROR: 'unsupported_channel',
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)
// Ensure data and store directories exist for fresh installs.
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
fs.mkdirSync(STORE_DIR, { recursive: true });
@@ -106,22 +114,40 @@ export async function run(args: string[]): Promise<void> {
const db = new Database(dbPath);
// Ensure schema exists
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
)`);
try {
db.exec(
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
);
} catch {
/* column already exists */
}
try {
db.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
} catch {
/* column already exists */
}
const isMainInt = parsed.isMain ? 1 : 0;
db.prepare(
`INSERT OR REPLACE INTO registered_groups
(jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
(jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`,
).run(
parsed.jid,
parsed.name,
@@ -130,6 +156,7 @@ export async function run(args: string[]): Promise<void> {
timestamp,
requiresTriggerInt,
isMainInt,
SERVICE_AGENT_TYPE,
);
db.close();

View File

@@ -1,6 +1,5 @@
/**
* Step: runners Build agent runners (no container needed).
* Agents run as direct host processes.
* Step: runners Build the Claude and Codex runner packages.
*/
import { execSync } from 'child_process';
import fs from 'fs';
@@ -29,14 +28,14 @@ export async function run(_args: string[]): Promise<void> {
// Verify runner entry points exist
const agentRunner = path.join(
projectRoot,
'container',
'runners',
'agent-runner',
'dist',
'index.js',
);
const codexRunner = path.join(
projectRoot,
'container',
'runners',
'codex-runner',
'dist',
'index.js',

View File

@@ -6,7 +6,6 @@
*/
import { execSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import path from 'path';
import Database from 'better-sqlite3';
@@ -14,18 +13,12 @@ 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 { getPlatform, getServiceManager, isRoot } from './platform.js';
import { emitStatus } from './status.js';
export async function run(_args: string[]): Promise<void> {
const projectRoot = process.cwd();
const platform = getPlatform();
const homeDir = os.homedir();
logger.info('Starting verification');
@@ -93,31 +86,10 @@ export async function run(_args: string[]): Promise<void> {
}
// 3. Check channel auth (detect configured channels by credentials)
const envVars = readEnvFile([
'TELEGRAM_BOT_TOKEN',
'SLACK_BOT_TOKEN',
'SLACK_APP_TOKEN',
'DISCORD_BOT_TOKEN',
]);
const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
const channelAuth: Record<string, string> = {};
// 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';
}
@@ -141,16 +113,6 @@ export async function run(_args: string[]): Promise<void> {
}
}
// 5. 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' &&
@@ -168,7 +130,6 @@ export async function run(_args: string[]): Promise<void> {
CONFIGURED_CHANNELS: configuredChannels.join(','),
CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
LOG: 'logs/setup.log',
});

View File

@@ -18,6 +18,11 @@ import {
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
import { logger } from './logger.js';
import { readEnvFile } from './env.js';
import { isPairedRoomJid } from './db.js';
import {
readPairedRoomPrompt,
readPlatformPrompt,
} from './platform-prompts.js';
import { RegisteredGroup } from './types.js';
// Sentinel markers for robust output parsing (must match agent-runner)
@@ -29,6 +34,7 @@ export interface AgentInput {
sessionId?: string;
groupFolder: string;
chatJid: string;
runId?: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
@@ -49,6 +55,7 @@ export interface AgentOutput {
function prepareGroupEnvironment(
group: RegisteredGroup,
isMain: boolean,
chatJid: string,
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
const projectRoot = process.cwd();
const groupDir = resolveGroupFolderPath(group.folder);
@@ -82,14 +89,14 @@ function prepareGroupEnvironment(
}
// Sync skills into each group's .claude/ session dir
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) container/skills/
// Sources: 1) user's global ~/.claude/skills 2) project workDir/.claude/skills 3) runners/skills/
const workDirClaude = group.workDir
? path.join(group.workDir, '.claude')
: null;
const skillSources = [
path.join(os.homedir(), '.claude', 'skills'),
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
path.join(projectRoot, 'container', 'skills'),
path.join(projectRoot, 'runners', 'skills'),
];
const skillsDst = path.join(groupSessionsDir, 'skills');
for (const src of skillSources) {
@@ -114,21 +121,36 @@ function prepareGroupEnvironment(
// Global memory directory (for non-main groups)
const globalDir = path.join(GROUPS_DIR, 'global');
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
const isPairedRoom = isPairedRoomJid(chatJid);
// Additional mount directories (validated)
const extraDirs: string[] = [];
if (group.agentConfig?.additionalMounts) {
for (const mount of group.agentConfig.additionalMounts) {
if (fs.existsSync(mount.hostPath)) {
extraDirs.push(mount.hostPath);
}
}
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
const claudePairedRoomPrompt = isPairedRoom
? readPairedRoomPrompt('claude-code', projectRoot)
: undefined;
const globalClaudeMemory =
!isMain && fs.existsSync(globalClaudeMdPath)
? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim()
: undefined;
const sessionClaudeMd = [
claudePlatformPrompt,
claudePairedRoomPrompt,
globalClaudeMemory,
]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
const sessionClaudeMdPath = path.join(groupSessionsDir, 'CLAUDE.md');
if (sessionClaudeMd) {
fs.writeFileSync(sessionClaudeMdPath, sessionClaudeMd + '\n');
} else if (fs.existsSync(sessionClaudeMdPath)) {
fs.unlinkSync(sessionClaudeMdPath);
}
// Determine runner directory
const agentType = group.agentType || 'claude-code';
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
const runnerDir = path.join(projectRoot, 'container', runnerDirName);
const runnerDir = path.join(projectRoot, 'runners', runnerDirName);
// Build environment variables for the runner process
const envVars = readEnvFile([
@@ -170,7 +192,6 @@ function prepareGroupEnvironment(
NANOCLAW_GROUP_DIR: groupDir,
NANOCLAW_IPC_DIR: groupIpcDir,
NANOCLAW_GLOBAL_DIR: globalDir,
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
// Working directory override (agent uses this as cwd instead of group dir)
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
// MCP server context
@@ -214,18 +235,32 @@ function prepareGroupEnvironment(
const authSrc = path.join(hostCodexDir, 'auth.json');
const authDst = path.join(sessionCodexDir, 'auth.json');
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
for (const file of ['config.toml', 'config.json', 'instructions.md']) {
for (const file of ['config.toml', 'config.json']) {
const src = path.join(hostCodexDir, file);
const dst = path.join(sessionCodexDir, file);
if (fs.existsSync(src)) {
fs.copyFileSync(src, dst);
}
}
const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md');
const codexPlatformPrompt = readPlatformPrompt('codex', projectRoot);
const codexPairedRoomPrompt = isPairedRoom
? readPairedRoomPrompt('codex', projectRoot)
: undefined;
const sessionAgents = [codexPlatformPrompt, codexPairedRoomPrompt]
.filter((value): value is string => Boolean(value))
.join('\n\n---\n\n')
.trim();
if (sessionAgents) {
fs.writeFileSync(sessionAgentsPath, sessionAgents + '\n');
} else if (fs.existsSync(sessionAgentsPath)) {
fs.unlinkSync(sessionAgentsPath);
}
// Sync skills into Codex session dir
// SSOT: ~/.claude/skills/ (shared with Claude Code) + container/skills/
// SSOT: ~/.claude/skills/ (shared with Claude Code) + runners/skills/
const codexSkillSources = [
path.join(os.homedir(), '.claude', 'skills'),
path.join(projectRoot, 'container', 'skills'),
path.join(projectRoot, 'runners', 'skills'),
];
const codexSkillsDst = path.join(sessionCodexDir, 'skills');
for (const src of codexSkillSources) {
@@ -245,7 +280,7 @@ function prepareGroupEnvironment(
// Inject nanoclaw MCP server into Codex config.toml
const mcpServerPath = path.join(
projectRoot,
'container',
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
@@ -323,17 +358,22 @@ export async function runAgentProcess(
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
group,
input.isMain,
input.chatJid,
);
if (input.runId) {
env.NANOCLAW_RUN_ID = input.runId;
}
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
const processName = `nanoclaw-${safeName}-${Date.now()}`;
const processSuffix = input.runId || `${Date.now()}`;
const processName = `nanoclaw-${safeName}-${processSuffix}`;
// Check if runner is built
const distEntry = path.join(runnerDir, 'dist', 'index.js');
if (!fs.existsSync(distEntry)) {
logger.error(
{ runnerDir },
'Runner not built. Run: cd container/agent-runner && npm install && npm run build',
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
'Runner not built. Run: cd runners/agent-runner && npm install && npm run build',
);
return {
status: 'error',
@@ -345,6 +385,9 @@ export async function runAgentProcess(
logger.info(
{
group: group.name,
chatJid: input.chatJid,
groupFolder: input.groupFolder,
runId: input.runId,
processName,
agentType: group.agentType || 'claude-code',
isMain: input.isMain,
@@ -386,7 +429,12 @@ export async function runAgentProcess(
stdout += chunk.slice(0, remaining);
stdoutTruncated = true;
logger.warn(
{ group: group.name, size: stdout.length },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
size: stdout.length,
},
'Agent stdout truncated due to size limit',
);
} else {
@@ -416,7 +464,12 @@ export async function runAgentProcess(
outputChain = outputChain.then(() => onOutput(parsed));
} catch (err) {
logger.warn(
{ group: group.name, error: err },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse streamed output chunk',
);
}
@@ -424,22 +477,6 @@ export async function runAgentProcess(
}
});
proc.stderr.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (line) logger.debug({ agent: group.folder }, line);
}
if (stderrTruncated) return;
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
} else {
stderr += chunk;
}
});
let timedOut = false;
let hadStreamingOutput = false;
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
@@ -448,7 +485,12 @@ export async function runAgentProcess(
const killOnTimeout = () => {
timedOut = true;
logger.error(
{ group: group.name, processName },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
},
'Agent timeout, sending SIGTERM',
);
proc.kill('SIGTERM');
@@ -465,6 +507,35 @@ export async function runAgentProcess(
timeout = setTimeout(killOnTimeout, timeoutMs);
};
proc.stderr.on('data', (data) => {
const chunk = data.toString();
const lines = chunk.trim().split('\n');
for (const line of lines) {
if (!line) continue;
if (line.includes('Turn in progress')) {
logger.info(
{ group: group.name, chatJid: input.chatJid, runId: input.runId },
line.replace(/^\[.*?\]\s*/, ''),
);
} else {
logger.debug(
{ agent: group.folder, chatJid: input.chatJid, runId: input.runId },
line,
);
}
}
// Stderr activity means agent is alive — reset timeout
resetTimeout();
if (stderrTruncated) return;
const remaining = AGENT_MAX_OUTPUT_SIZE - stderr.length;
if (chunk.length > remaining) {
stderr += chunk.slice(0, remaining);
stderrTruncated = true;
} else {
stderr += chunk;
}
});
proc.on('close', (code) => {
clearTimeout(timeout);
const duration = Date.now() - startTime;
@@ -472,11 +543,13 @@ export async function runAgentProcess(
if (timedOut) {
const ts = new Date().toISOString().replace(/[:.]/g, '-');
fs.writeFileSync(
path.join(logsDir, `agent-${ts}.log`),
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
[
`=== Agent Run Log (TIMEOUT) ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`RunId: ${input.runId || 'n/a'}`,
`Process: ${processName}`,
`Duration: ${duration}ms`,
`Exit Code: ${code}`,
@@ -486,7 +559,14 @@ export async function runAgentProcess(
if (hadStreamingOutput) {
logger.info(
{ group: group.name, processName, duration, code },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
duration,
code,
},
'Agent timed out after output (idle cleanup)',
);
outputChain.then(() => {
@@ -504,7 +584,10 @@ export async function runAgentProcess(
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const logFile = path.join(logsDir, `agent-${timestamp}.log`);
const logFile = path.join(
logsDir,
`agent-${input.runId || 'adhoc'}-${timestamp}.log`,
);
const isVerbose =
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
@@ -512,6 +595,9 @@ export async function runAgentProcess(
`=== Agent Run Log ===`,
`Timestamp: ${new Date().toISOString()}`,
`Group: ${group.name}`,
`ChatJid: ${input.chatJid}`,
`GroupFolder: ${input.groupFolder}`,
`RunId: ${input.runId || 'n/a'}`,
`IsMain: ${input.isMain}`,
`AgentType: ${group.agentType || 'claude-code'}`,
`Duration: ${duration}ms`,
@@ -542,7 +628,14 @@ export async function runAgentProcess(
if (code !== 0) {
logger.error(
{ group: group.name, code, duration, logFile },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
code,
duration,
logFile,
},
'Agent exited with error',
);
resolve({
@@ -556,7 +649,13 @@ export async function runAgentProcess(
if (onOutput) {
outputChain.then(() => {
logger.info(
{ group: group.name, duration, newSessionId },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
newSessionId,
},
'Agent completed (streaming mode)',
);
resolve({ status: 'success', result: null, newSessionId });
@@ -579,13 +678,24 @@ export async function runAgentProcess(
}
const output: AgentOutput = JSON.parse(jsonLine);
logger.info(
{ group: group.name, duration, status: output.status },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
duration,
status: output.status,
},
'Agent completed',
);
resolve(output);
} catch (err) {
logger.error(
{ group: group.name, error: err },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
error: err,
},
'Failed to parse agent output',
);
resolve({
@@ -599,7 +709,13 @@ export async function runAgentProcess(
proc.on('error', (err) => {
clearTimeout(timeout);
logger.error(
{ group: group.name, processName, error: err },
{
group: group.name,
chatJid: input.chatJid,
runId: input.runId,
processName,
error: err,
},
'Agent spawn error',
);
resolve({
@@ -644,7 +760,7 @@ export function writeGroupsSnapshot(
groupFolder: string,
isMain: boolean,
groups: AvailableGroup[],
registeredJids: Set<string>,
_registeredJids?: Set<string>,
): void {
const groupIpcDir = resolveGroupIpcPath(groupFolder);
fs.mkdirSync(groupIpcDir, { recursive: true });

View File

@@ -1,13 +1,4 @@
// Channel self-registration barrel file.
// Each import triggers the channel module's registerChannel() call.
// discord
import './discord.js';
// gmail
// slack
// telegram
// whatsapp

View File

@@ -1,15 +1,34 @@
import os from 'os';
import path from 'path';
import type { AgentType } from './types.js';
import { readEnvFile } from './env.js';
const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER']);
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'SERVICE_ID',
'SERVICE_AGENT_TYPE',
'SESSION_COMMAND_ALLOWED_SENDERS',
'USAGE_DASHBOARD',
]);
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';
const ASSISTANT_SLUG = ASSISTANT_NAME.trim().toLowerCase();
const rawServiceAgentType =
process.env.SERVICE_AGENT_TYPE || envConfig.SERVICE_AGENT_TYPE;
export const SERVICE_ID =
process.env.SERVICE_ID || envConfig.SERVICE_ID || ASSISTANT_SLUG;
export const SERVICE_AGENT_TYPE: AgentType =
rawServiceAgentType === 'codex' || rawServiceAgentType === 'claude-code'
? rawServiceAgentType
: ASSISTANT_SLUG === 'codex'
? 'codex'
: 'claude-code';
export const POLL_INTERVAL = 2000;
export const SCHEDULER_POLL_INTERVAL = 60000;
@@ -62,8 +81,23 @@ export const TRIGGER_PATTERN = new RegExp(
export const STATUS_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
export const USAGE_DASHBOARD_ENABLED =
(process.env.USAGE_DASHBOARD || envConfig.USAGE_DASHBOARD) === 'true';
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
const SESSION_COMMAND_ALLOWED_SENDERS = new Set(
(process.env.SESSION_COMMAND_ALLOWED_SENDERS ||
envConfig.SESSION_COMMAND_ALLOWED_SENDERS ||
'')
.split(',')
.map((value) => value.trim())
.filter(Boolean),
);
export function isSessionCommandSenderAllowed(sender: string): boolean {
return SESSION_COMMAND_ALLOWED_SENDERS.has(sender);
}

View File

@@ -455,25 +455,25 @@ describe('message query LIMIT', () => {
describe('registered group isMain', () => {
it('persists isMain=true through set/get round-trip', () => {
setRegisteredGroup('main@s.whatsapp.net', {
setRegisteredGroup('dc:main', {
name: 'Main Chat',
folder: 'whatsapp_main',
folder: 'discord_main',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
isMain: true,
});
const groups = getAllRegisteredGroups();
const group = groups['main@s.whatsapp.net'];
const group = groups['dc:main'];
expect(group).toBeDefined();
expect(group.isMain).toBe(true);
expect(group.folder).toBe('whatsapp_main');
expect(group.folder).toBe('discord_main');
});
it('omits isMain for non-main groups', () => {
setRegisteredGroup('group@g.us', {
name: 'Family Chat',
folder: 'whatsapp_family-chat',
folder: 'discord_family-chat',
trigger: '@Andy',
added_at: '2024-01-01T00:00:00.000Z',
});

364
src/db.ts
View File

@@ -2,11 +2,18 @@ import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js';
import {
ASSISTANT_NAME,
DATA_DIR,
SERVICE_AGENT_TYPE,
SERVICE_ID,
STORE_DIR,
} from './config.js';
import { isValidGroupFolder } from './group-folder.js';
import { logger } from './logger.js';
import {
NewMessage,
AgentType,
RegisteredGroup,
ScheduledTask,
TaskRunLog,
@@ -70,17 +77,24 @@ function createSchema(database: Database.Database): void {
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
group_folder TEXT PRIMARY KEY,
session_id TEXT NOT NULL
group_folder TEXT NOT NULL,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
session_id TEXT NOT NULL,
PRIMARY KEY (group_folder, agent_type)
);
CREATE TABLE IF NOT EXISTS registered_groups (
jid TEXT PRIMARY KEY,
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL UNIQUE,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
requires_trigger INTEGER DEFAULT 1
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
`);
@@ -106,33 +120,137 @@ function createSchema(database: Database.Database): void {
/* 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`,
// Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder.
const registeredGroupsSql = (
database
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'registered_groups'`,
)
.get() as { sql?: string } | undefined
)?.sql;
if (
registeredGroupsSql &&
!registeredGroupsSql.includes('PRIMARY KEY (jid, agent_type)')
) {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasIsMain = registeredGroupCols.some((col) => col.name === 'is_main');
const hasAgentType = registeredGroupCols.some(
(col) => col.name === 'agent_type',
);
const hasWorkDir = registeredGroupCols.some((col) => col.name === 'work_dir');
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
database.exec(`
CREATE TABLE registered_groups_new (
jid TEXT NOT NULL,
name TEXT NOT NULL,
folder TEXT NOT NULL,
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
agent_config TEXT,
requires_trigger INTEGER DEFAULT 1,
is_main INTEGER DEFAULT 0,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
work_dir TEXT,
PRIMARY KEY (jid, agent_type),
UNIQUE (folder, agent_type)
);
`);
database.exec(`
INSERT INTO registered_groups_new (
jid,
name,
folder,
trigger_pattern,
added_at,
agent_config,
requires_trigger,
is_main,
agent_type,
work_dir
)
SELECT
jid,
name,
folder,
trigger_pattern,
added_at,
${
hasAgentConfig
? 'agent_config'
: hasContainerConfig
? 'container_config'
: 'NULL'
},
requires_trigger,
${hasIsMain ? 'COALESCE(is_main, 0)' : "CASE WHEN folder = 'main' THEN 1 ELSE 0 END"},
${hasAgentType ? "COALESCE(agent_type, 'claude-code')" : "'claude-code'"},
${hasWorkDir ? 'work_dir' : 'NULL'}
FROM registered_groups;
`);
database.exec(`
DROP TABLE registered_groups;
ALTER TABLE registered_groups_new RENAME TO registered_groups;
`);
} else {
// Backfill: existing rows with folder = 'main' are the main group
database.exec(
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
`UPDATE registered_groups SET is_main = 1 WHERE folder = 'main' AND COALESCE(is_main, 0) = 0`,
);
} catch {
/* column already exists */
}
// Add agent_type column if it doesn't exist (migration for Codex support)
try {
const registeredGroupCols = database
.prepare('PRAGMA table_info(registered_groups)')
.all() as Array<{ name: string }>;
const hasAgentConfig = registeredGroupCols.some(
(col) => col.name === 'agent_config',
);
const hasContainerConfig = registeredGroupCols.some(
(col) => col.name === 'container_config',
);
if (!hasAgentConfig) {
database.exec(`ALTER TABLE registered_groups ADD COLUMN agent_config TEXT`);
}
if (hasContainerConfig) {
database.exec(
`ALTER TABLE registered_groups ADD COLUMN agent_type TEXT DEFAULT 'claude-code'`,
`UPDATE registered_groups
SET agent_config = COALESCE(agent_config, container_config)
WHERE container_config IS NOT NULL`,
);
} catch {
/* column already exists */
}
// Add work_dir column if it doesn't exist (migration for per-group working directory)
try {
database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
} catch {
/* column already exists */
// Migrate sessions table to composite PK (group_folder, agent_type)
const sessionCols = database
.prepare('PRAGMA table_info(sessions)')
.all() as Array<{ name: string }>;
if (!sessionCols.some((col) => col.name === 'agent_type')) {
database.exec(`
CREATE TABLE sessions_new (
group_folder TEXT NOT NULL,
agent_type TEXT NOT NULL DEFAULT 'claude-code',
session_id TEXT NOT NULL,
PRIMARY KEY (group_folder, agent_type)
);
`);
database
.prepare(
`INSERT INTO sessions_new (group_folder, agent_type, session_id)
SELECT group_folder, ?, session_id FROM sessions`,
)
.run(SERVICE_AGENT_TYPE);
database.exec(`
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
`);
}
// Add channel and is_group columns if they don't exist (migration for existing DBs)
@@ -162,6 +280,8 @@ export function initDatabase(): void {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('busy_timeout = 5000');
createSchema(db);
// Migrate from JSON files if they exist
@@ -214,20 +334,6 @@ export function storeChatMetadata(
}
}
/**
* 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;
@@ -251,27 +357,6 @@ export function getAllChats(): ChatInfo[] {
.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.
@@ -291,33 +376,6 @@ export function storeMessage(msg: NewMessage): void {
);
}
/**
* 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,
@@ -327,9 +385,9 @@ export function getNewMessages(
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.
// Filter legacy prefixed outbound messages as a backstop for rows written
// before explicit bot flags existed. Self-message filtering is channel-specific
// and happens in message-runtime so cross-bot collaboration still works.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
@@ -360,9 +418,9 @@ export function getMessagesSince(
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.
// Filter legacy prefixed outbound messages as a backstop for rows written
// before explicit bot flags existed. Self-message filtering is channel-specific
// and happens in message-runtime so cross-bot collaboration still works.
const sql = `
SELECT * FROM (
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
@@ -527,37 +585,65 @@ export function logTaskRun(log: TaskRunLog): void {
// --- Router state accessors ---
export function getRouterState(key: string): string | undefined {
const prefixedKey = `${SERVICE_ID}:${key}`;
const row = db
.prepare('SELECT value FROM router_state WHERE key = ?')
.get(prefixedKey) as { value: string } | undefined;
if (row) return row.value;
// Lazy migration: read unprefixed key and migrate to prefixed
const old = db
.prepare('SELECT value FROM router_state WHERE key = ?')
.get(key) as { value: string } | undefined;
return row?.value;
if (old) {
db.prepare(
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
).run(prefixedKey, old.value);
db.prepare('DELETE FROM router_state WHERE key = ?').run(key);
return old.value;
}
return undefined;
}
export function setRouterState(key: string, value: string): void {
const prefixedKey = `${SERVICE_ID}:${key}`;
db.prepare(
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
).run(key, value);
).run(prefixedKey, 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;
.prepare(
'SELECT session_id FROM sessions WHERE group_folder = ? AND agent_type = ?',
)
.get(groupFolder, SERVICE_AGENT_TYPE) 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);
'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)',
).run(groupFolder, SERVICE_AGENT_TYPE, sessionId);
}
export function deleteSession(groupFolder: string): void {
db.prepare(
'DELETE FROM sessions WHERE group_folder = ? AND agent_type = ?',
).run(groupFolder, SERVICE_AGENT_TYPE);
}
export function getAllSessions(): Record<string, string> {
const rows = db
.prepare('SELECT group_folder, session_id FROM sessions')
.all() as Array<{ group_folder: string; session_id: string }>;
.prepare(
'SELECT group_folder, session_id FROM sessions WHERE agent_type = ?',
)
.all(SERVICE_AGENT_TYPE) as Array<{
group_folder: string;
session_id: string;
}>;
const result: Record<string, string> = {};
for (const row of rows) {
result[row.group_folder] = row.session_id;
@@ -574,15 +660,15 @@ export function getRegisteredGroup(
.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;
agent_type: string | null;
jid: string;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}
| undefined;
@@ -600,8 +686,8 @@ export function getRegisteredGroup(
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
agentConfig: row.container_config
? JSON.parse(row.container_config)
agentConfig: row.agent_config
? JSON.parse(row.agent_config)
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
@@ -616,7 +702,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
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, agent_type, work_dir)
`INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, agent_config, requires_trigger, is_main, agent_type, work_dir)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
@@ -632,17 +718,25 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
);
}
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
export function getAllRegisteredGroups(
agentTypeFilter?: string,
): Record<string, RegisteredGroup> {
const rows = (
agentTypeFilter
? db
.prepare('SELECT * FROM registered_groups WHERE agent_type = ?')
.all(agentTypeFilter)
: 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;
agent_type: string | null;
name: string;
folder: string;
trigger_pattern: string;
added_at: string;
agent_config: string | null;
requires_trigger: number | null;
is_main: number | null;
agent_type: string | null;
work_dir: string | null;
}>;
const result: Record<string, RegisteredGroup> = {};
@@ -659,8 +753,8 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
folder: row.folder,
trigger: row.trigger_pattern,
added_at: row.added_at,
agentConfig: row.container_config
? JSON.parse(row.container_config)
agentConfig: row.agent_config
? JSON.parse(row.agent_config)
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
@@ -672,6 +766,28 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
return result;
}
export function getRegisteredAgentTypesForJid(jid: string): AgentType[] {
if (!db) return [];
const rows = db
.prepare('SELECT agent_type FROM registered_groups WHERE jid = ?')
.all(jid) as Array<{ agent_type: string | null }>;
const types = new Set<AgentType>();
for (const row of rows) {
const agentType = row.agent_type as AgentType | null;
if (agentType === 'claude-code' || agentType === 'codex') {
types.add(agentType);
}
}
return [...types];
}
export function isPairedRoomJid(jid: string): boolean {
const types = getRegisteredAgentTypesForJid(jid);
return types.includes('claude-code') && types.includes('codex');
}
// --- JSON migration ---
function migrateJsonState(): void {

View File

@@ -2,6 +2,7 @@ import path from 'path';
import { describe, expect, it } from 'vitest';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import {
isValidGroupFolder,
resolveGroupFolderPath,
@@ -24,16 +25,12 @@ describe('group folder validation', () => {
it('resolves safe paths under groups directory', () => {
const resolved = resolveGroupFolderPath('family-chat');
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(
true,
);
expect(resolved).toBe(path.resolve(GROUPS_DIR, 'family-chat'));
});
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);
expect(resolved).toBe(path.resolve(DATA_DIR, 'ipc', 'family-chat'));
});
it('throws for unsafe folder names', () => {

View File

@@ -7,6 +7,7 @@ import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
POLL_INTERVAL,
isSessionCommandSenderAllowed,
STATUS_CHANNEL_ID,
STATUS_UPDATE_INTERVAL,
TIMEZONE,
@@ -37,6 +38,7 @@ import {
initDatabase,
setRegisteredGroup,
setRouterState,
deleteSession,
setSession,
storeChatMetadata,
storeMessage,
@@ -94,6 +96,11 @@ function saveState(): void {
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
}
function clearSession(groupFolder: string): void {
delete sessions[groupFolder];
deleteSession(groupFolder);
}
function registerGroup(jid: string, group: RegisteredGroup): void {
let groupDir: string;
try {
@@ -182,11 +189,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
runAgent: (prompt, onOutput) =>
runAgent(group, prompt, chatJid, onOutput),
closeStdin: () => queue.closeStdin(chatJid),
clearSession: () => clearSession(group.folder),
advanceCursor: (ts) => {
lastAgentTimestamp[chatJid] = ts;
saveState();
},
formatMessages,
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
canSenderInteract: (msg) => {
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
@@ -921,6 +930,7 @@ async function startMessageLoop(): Promise<void> {
isSessionCommandAllowed(
isMainGroup,
loopCmdMsg.is_from_me === true,
isSessionCommandSenderAllowed(loopCmdMsg.sender),
)
) {
queue.closeStdin(chatJid);

60
src/platform-prompts.ts Normal file
View File

@@ -0,0 +1,60 @@
import fs from 'fs';
import path from 'path';
import type { AgentType } from './types.js';
const PLATFORM_PROMPT_FILES: Record<AgentType, string> = {
'claude-code': 'claude-platform.md',
codex: 'codex-platform.md',
};
const PAIRED_ROOM_PROMPT_FILES: Record<AgentType, string> = {
'claude-code': 'claude-paired-room.md',
codex: 'codex-paired-room.md',
};
export function getPlatformPromptsDir(projectRoot = process.cwd()): string {
return path.join(projectRoot, 'prompts');
}
export function getPlatformPromptPath(
agentType: AgentType,
projectRoot = process.cwd(),
): string {
return path.join(
getPlatformPromptsDir(projectRoot),
PLATFORM_PROMPT_FILES[agentType],
);
}
export function readPlatformPrompt(
agentType: AgentType,
projectRoot = process.cwd(),
): string | undefined {
const promptPath = getPlatformPromptPath(agentType, projectRoot);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}
export function getPairedRoomPromptPath(
agentType: AgentType,
projectRoot = process.cwd(),
): string {
return path.join(
getPlatformPromptsDir(projectRoot),
PAIRED_ROOM_PROMPT_FILES[agentType],
);
}
export function readPairedRoomPrompt(
agentType: AgentType,
projectRoot = process.cwd(),
): string | undefined {
const promptPath = getPairedRoomPromptPath(agentType, projectRoot);
if (!fs.existsSync(promptPath)) return undefined;
const prompt = fs.readFileSync(promptPath, 'utf-8').trim();
return prompt || undefined;
}

View File

@@ -82,8 +82,10 @@ function makeDeps(
setTyping: vi.fn().mockResolvedValue(undefined),
runAgent: vi.fn().mockResolvedValue('success'),
closeStdin: vi.fn(),
clearSession: vi.fn(),
advanceCursor: vi.fn(),
formatMessages: vi.fn().mockReturnValue('<formatted>'),
isAdminSender: vi.fn().mockReturnValue(false),
canSenderInteract: vi.fn().mockReturnValue(true),
...overrides,
};

View File

@@ -1,5 +1,14 @@
import type { NewMessage } from './types.js';
import { logger } from './logger.js';
import { formatOutbound } from './router.js';
const SESSION_COMMAND_CONTROL_PATTERNS = [
/^Current session cleared\. The next message will start a new conversation\.$/,
/^Session commands require admin access\.$/,
/^Failed to process messages before \/compact\. Try again\.$/,
/^\/compact failed\. The session is unchanged\.$/,
/^Conversation compacted\.$/,
];
/**
* Extract a session slash command from a message, stripping the trigger prefix if present.
@@ -12,18 +21,27 @@ export function extractSessionCommand(
let text = content.trim();
text = text.replace(triggerPattern, '').trim();
if (text === '/compact') return '/compact';
if (text === '/clear') return '/clear';
return null;
}
/**
* Check if a session command sender is authorized.
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
* Allowed: main group (any sender), or trusted/admin sender in any group.
*/
export function isSessionCommandAllowed(
isMainGroup: boolean,
isFromMe: boolean,
isAdminSender: boolean = false,
): boolean {
return isMainGroup || isFromMe;
return isMainGroup || isFromMe || isAdminSender;
}
export function isSessionCommandControlMessage(content: string): boolean {
const trimmed = content.trim();
return SESSION_COMMAND_CONTROL_PATTERNS.some((pattern) =>
pattern.test(trimmed),
);
}
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
@@ -41,8 +59,10 @@ export interface SessionCommandDeps {
onOutput: (result: AgentResult) => Promise<void>,
) => Promise<'success' | 'error'>;
closeStdin: () => void;
clearSession: () => void;
advanceCursor: (timestamp: string) => void;
formatMessages: (msgs: NewMessage[], timezone: string) => string;
isAdminSender: (msg: NewMessage) => boolean;
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
canSenderInteract: (msg: NewMessage) => boolean;
}
@@ -50,7 +70,7 @@ export interface SessionCommandDeps {
function resultToText(result: string | object | null | undefined): string {
if (!result) return '';
const raw = typeof result === 'string' ? result : JSON.stringify(result);
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
return formatOutbound(raw);
}
/**
@@ -63,6 +83,7 @@ export async function handleSessionCommand(opts: {
missedMessages: NewMessage[];
isMainGroup: boolean;
groupName: string;
runId?: string;
triggerPattern: RegExp;
timezone: string;
deps: SessionCommandDeps;
@@ -71,6 +92,7 @@ export async function handleSessionCommand(opts: {
missedMessages,
isMainGroup,
groupName,
runId,
triggerPattern,
timezone,
deps,
@@ -85,7 +107,13 @@ export async function handleSessionCommand(opts: {
if (!command || !cmdMsg) return { handled: false };
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
if (
!isSessionCommandAllowed(
isMainGroup,
cmdMsg.is_from_me === true,
deps.isAdminSender(cmdMsg),
)
) {
// DENIED: send denial if the sender would normally be allowed to interact,
// then silently consume the command by advancing the cursor past it.
// Trade-off: other messages in the same batch are also consumed (cursor is
@@ -98,7 +126,17 @@ export async function handleSessionCommand(opts: {
}
// AUTHORIZED: process pre-compact messages first, then run the command
logger.info({ group: groupName, command }, 'Session command');
logger.info({ group: groupName, runId, command }, 'Session command');
if (command === '/clear') {
deps.closeStdin();
deps.clearSession();
deps.advanceCursor(cmdMsg.timestamp);
await deps.sendMessage(
'Current session cleared. The next message will start a new conversation.',
);
return { handled: true, success: true };
}
const cmdIndex = missedMessages.indexOf(cmdMsg);
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
@@ -125,7 +163,7 @@ export async function handleSessionCommand(opts: {
if (preResult === 'error' || hadPreError) {
logger.warn(
{ group: groupName },
{ group: groupName, runId },
'Pre-compact processing failed, aborting session command',
);
await deps.sendMessage(

View File

@@ -1,11 +1,4 @@
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
}
export interface AgentConfig {
additionalMounts?: AdditionalMount[];
timeout?: number; // Default: 300000 (5 minutes)
// Per-group model/effort overrides (take precedence over global env vars)
codexModel?: string;
@@ -78,6 +71,8 @@ export interface Channel {
sendMessage(jid: string, text: string): Promise<void>;
isConnected(): boolean;
ownsJid(jid: string): boolean;
// Optional: whether a stored inbound message was authored by this channel's own bot/user.
isOwnMessage?(msg: NewMessage): boolean;
disconnect(): Promise<void>;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise<void>;