refactor: remove legacy container and non-discord remnants
This commit is contained in:
@@ -1,135 +1,46 @@
|
|||||||
---
|
---
|
||||||
name: add-compact
|
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
|
# 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
|
1. `/compact`가 정확히 명령으로 인식되는지
|
||||||
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
|
2. 메인 그룹 또는 admin/trusted sender만 허용되는지
|
||||||
```
|
3. `/compact` 전에 들어온 메시지를 먼저 세션에 반영하는지
|
||||||
|
4. compaction 후 세션이 이어지고 `newSessionId`가 갱신되는지
|
||||||
If already applied, skip to Phase 3 (Verify).
|
5. 대화 아카이브가 `groups/<folder>/conversations/`에 남는지
|
||||||
|
|
||||||
## Phase 2: Apply Code Changes
|
## 포팅할 때 원칙
|
||||||
|
|
||||||
Merge the skill branch:
|
- 예전 skill branch를 다시 머지하지 말고 현재 트리의 관련 파일을 기준으로 옮깁니다
|
||||||
|
- Claude 러너와 Codex 러너 양쪽을 같이 봅니다
|
||||||
```bash
|
- `/clear`와 `/compact`의 의미를 섞지 않습니다
|
||||||
git fetch upstream skill/compact
|
|
||||||
git merge upstream/skill/compact
|
## 검증
|
||||||
```
|
|
||||||
|
|
||||||
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
|
|
||||||
|
|
||||||
This adds:
|
|
||||||
- `src/session-commands.ts` (extract and authorize session commands)
|
|
||||||
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
|
|
||||||
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
|
|
||||||
- Slash command handling in `container/agent-runner/src/index.ts`
|
|
||||||
|
|
||||||
### Validate
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run typecheck
|
||||||
npm test
|
npm test
|
||||||
|
npm run build:runners
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild container
|
그 다음 디스코드에서 확인합니다.
|
||||||
|
|
||||||
```bash
|
- 메인 채널에서 `/compact`
|
||||||
./container/build.sh
|
- 비메인 채널에서 일반 사용자 `/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.
|
|
||||||
|
|||||||
@@ -1,212 +1,84 @@
|
|||||||
---
|
---
|
||||||
name: add-discord
|
name: add-discord
|
||||||
description: Add Discord bot channel integration to NanoClaw.
|
description: Finish Discord bot setup and registration for NanoClaw.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Add Discord Channel
|
# 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
|
```bash
|
||||||
git remote -v
|
DISCORD_BOT_TOKEN=<token>
|
||||||
```
|
```
|
||||||
|
|
||||||
If `discord` is missing, add it:
|
선택:
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
git fetch discord main
|
npm run setup -- --step register -- \
|
||||||
git merge discord/main
|
--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
|
```bash
|
||||||
npm install
|
npm run setup -- --step register -- \
|
||||||
npm run build
|
--jid dc:<channel-id> \
|
||||||
npx vitest run src/channels/discord.test.ts
|
--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.
|
## 5. 빌드와 검증
|
||||||
|
|
||||||
## 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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
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
|
```bash
|
||||||
tail -f logs/nanoclaw.log
|
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
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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.
|
|
||||||
@@ -1,153 +1,84 @@
|
|||||||
---
|
---
|
||||||
name: add-ollama-tool
|
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
|
```bash
|
||||||
ollama list
|
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
|
```bash
|
||||||
git remote -v
|
ollama pull gemma3:1b
|
||||||
|
ollama pull llama3.2
|
||||||
```
|
```
|
||||||
|
|
||||||
If `upstream` is missing, add it:
|
## 수정 지점
|
||||||
|
|
||||||
```bash
|
### 1. 환경 변수 전달
|
||||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
|
||||||
```
|
`src/agent-runner.ts`
|
||||||
|
|
||||||
### Merge the skill branch
|
- `.env`에서 `OLLAMA_HOST`를 읽도록 `readEnvFile([...])` 목록에 추가합니다.
|
||||||
|
- Claude 러너 child env에 `OLLAMA_HOST`를 명시적으로 넣습니다.
|
||||||
```bash
|
|
||||||
git fetch upstream skill/ollama-tool
|
기본값을 쓰면 생략 가능하지만, 원격 Ollama나 다른 포트를 쓸 때는 필요합니다.
|
||||||
git merge upstream/skill/ollama-tool
|
|
||||||
```
|
## 2. MCP 서버 추가
|
||||||
|
|
||||||
This merges in:
|
`runners/agent-runner/src/index.ts`
|
||||||
- `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
|
|
||||||
- `scripts/ollama-watch.sh` (macOS notification watcher)
|
- 호스트에서 동작하는 stdio MCP 서버 파일을 추가합니다.
|
||||||
- Ollama MCP config in `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
|
- 예: `runners/agent-runner/src/ollama-mcp-stdio.ts`
|
||||||
- `[OLLAMA]` log surfacing in `src/container-runner.ts`
|
- `query({ options: { mcpServers, allowedTools } })` 쪽에 Ollama 서버를 등록합니다.
|
||||||
- `OLLAMA_HOST` in `.env.example`
|
- `allowedTools`에 `mcp__ollama__*` 또는 실제 툴 prefix를 추가합니다.
|
||||||
|
|
||||||
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
|
- Claude가 Ollama를 CLI가 아니라 MCP 도구로 보게 만들 것
|
||||||
|
- 툴 이름이 `allowedTools`에 포함될 것
|
||||||
Existing groups have a cached copy of the agent-runner source. Copy the new files:
|
|
||||||
|
## 3. 프롬프트 문서화
|
||||||
```bash
|
|
||||||
for dir in data/sessions/*/agent-runner-src; do
|
`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md`
|
||||||
cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
|
|
||||||
cp container/agent-runner/src/index.ts "$dir/"
|
- 언제 Ollama를 쓰는지
|
||||||
done
|
- 어떤 모델을 우선 쓰는지
|
||||||
```
|
- 빠른 요약/분류/초안 작업에 먼저 쓰도록 할지
|
||||||
|
|
||||||
### Validate code changes
|
이 부분을 적어두지 않으면 에이전트가 도구를 잘 안 고릅니다.
|
||||||
|
|
||||||
|
## 4. 빌드와 검증
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run build:runners
|
||||||
npm run build
|
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
|
- MCP 서버 등록이 빠졌거나
|
||||||
OLLAMA_HOST=http://your-ollama-host:11434
|
- `allowedTools`에 툴 prefix가 없거나
|
||||||
```
|
- 프롬프트 문서에 사용 규칙이 없습니다
|
||||||
|
|
||||||
### Restart the service
|
### 연결 실패
|
||||||
|
|
||||||
```bash
|
- `ollama list`가 호스트에서 되는지 먼저 확인합니다
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
- 원격 호스트면 `.env`의 `OLLAMA_HOST`를 확인합니다
|
||||||
# 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: ..."
|
|
||||||
|
|||||||
@@ -1,290 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: add-parallel
|
||||||
|
description: Add Parallel AI MCP tools to the current host-process Claude runner.
|
||||||
|
---
|
||||||
|
|
||||||
# Add Parallel AI Integration
|
# 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)
|
`PARALLEL_API_KEY`를 `.env`에 넣습니다.
|
||||||
- **Deep Research** - Comprehensive analysis using Parallel Task API (asks permission)
|
|
||||||
- **Non-blocking Design** - Uses NanoClaw scheduler for result polling (no container blocking)
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
User must have:
|
|
||||||
1. Parallel AI API key from https://platform.parallel.ai
|
|
||||||
2. NanoClaw already set up and running
|
|
||||||
3. Docker installed and running
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
Run all steps automatically. Only pause for user input when explicitly needed.
|
|
||||||
|
|
||||||
### 1. Get Parallel AI API Key
|
|
||||||
|
|
||||||
Use `AskUserQuestion: Do you have a Parallel AI API key, or should I help you get one?`
|
|
||||||
|
|
||||||
**If they have one:**
|
|
||||||
Collect it now.
|
|
||||||
|
|
||||||
**If they need one:**
|
|
||||||
Tell them:
|
|
||||||
> 1. Go to https://platform.parallel.ai
|
|
||||||
> 2. Sign up or log in
|
|
||||||
> 3. Navigate to API Keys section
|
|
||||||
> 4. Create a new API key
|
|
||||||
> 5. Copy the key and paste it here
|
|
||||||
|
|
||||||
Wait for the API key.
|
|
||||||
|
|
||||||
### 2. Add API Key to Environment
|
|
||||||
|
|
||||||
Add `PARALLEL_API_KEY` to `.env`:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check if .env exists, create if not
|
PARALLEL_API_KEY=...
|
||||||
if [ ! -f .env ]; then
|
|
||||||
touch .env
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add PARALLEL_API_KEY if not already present
|
|
||||||
if ! grep -q "PARALLEL_API_KEY=" .env; then
|
|
||||||
echo "PARALLEL_API_KEY=${API_KEY_FROM_USER}" >> .env
|
|
||||||
echo "✓ Added PARALLEL_API_KEY to .env"
|
|
||||||
else
|
|
||||||
# Update existing key
|
|
||||||
sed -i.bak "s/^PARALLEL_API_KEY=.*/PARALLEL_API_KEY=${API_KEY_FROM_USER}/" .env
|
|
||||||
echo "✓ Updated PARALLEL_API_KEY in .env"
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify:
|
## 2. 환경 변수 전달
|
||||||
```bash
|
|
||||||
grep "PARALLEL_API_KEY" .env | head -c 50
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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:
|
## 3. MCP 서버 등록
|
||||||
```typescript
|
|
||||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace with:
|
`runners/agent-runner/src/index.ts`
|
||||||
```typescript
|
|
||||||
const allowedVars = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY', 'PARALLEL_API_KEY'];
|
|
||||||
```
|
|
||||||
|
|
||||||
### 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):
|
- `parallel-search`: 빠른 검색, 저비용, 기본 허용
|
||||||
```typescript
|
- `parallel-task`: 긴 조사 작업, 비용/시간이 더 큼, 명시적 승인 후 사용
|
||||||
const mcpServers: Record<string, any> = {
|
|
||||||
nanoclaw: ipcMcp
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Add Parallel AI MCP servers after the nanoclaw server:
|
## 4. 프롬프트 규칙 추가
|
||||||
```typescript
|
|
||||||
const mcpServers: Record<string, any> = {
|
|
||||||
nanoclaw: ipcMcp
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add Parallel AI MCP servers if API key is available
|
`groups/global/CLAUDE.md` 또는 대상 그룹의 `CLAUDE.md`
|
||||||
const parallelApiKey = process.env.PARALLEL_API_KEY;
|
|
||||||
if (parallelApiKey) {
|
|
||||||
mcpServers['parallel-search'] = {
|
|
||||||
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
|
|
||||||
url: 'https://search-mcp.parallel.ai/mcp',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${parallelApiKey}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
mcpServers['parallel-task'] = {
|
|
||||||
type: 'http', // REQUIRED: Must specify type for HTTP MCP servers
|
|
||||||
url: 'https://task-mcp.parallel.ai/mcp',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${parallelApiKey}`
|
|
||||||
}
|
|
||||||
};
|
|
||||||
log('Parallel AI MCP servers configured');
|
|
||||||
} else {
|
|
||||||
log('PARALLEL_API_KEY not set, skipping Parallel AI integration');
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update the `allowedTools` array to include Parallel MCP tools (around line 242-248):
|
아래 원칙을 문서화합니다.
|
||||||
```typescript
|
|
||||||
allowedTools: [
|
|
||||||
'Bash',
|
|
||||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
|
||||||
'WebSearch', 'WebFetch',
|
|
||||||
'mcp__nanoclaw__*',
|
|
||||||
'mcp__parallel-search__*',
|
|
||||||
'mcp__parallel-task__*'
|
|
||||||
],
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Add Usage Instructions to CLAUDE.md
|
- 빠른 사실 조회나 최신 정보 확인은 `parallel-search`
|
||||||
|
- 긴 조사나 깊은 분석은 `parallel-task`
|
||||||
|
- `parallel-task`는 항상 먼저 사용자 허가를 받을 것
|
||||||
|
- 오래 걸리는 작업은 답변을 붙잡고 있지 말고 `mcp__nanoclaw__schedule_task`로 후속 체크를 맡길 것
|
||||||
|
|
||||||
Add Parallel AI usage instructions to `groups/main/CLAUDE.md`:
|
## 5. 빌드와 검증
|
||||||
|
|
||||||
Find the "## What You Can Do" section and add after the existing bullet points:
|
|
||||||
```markdown
|
|
||||||
- Use Parallel AI for web research and deep learning tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add a new section after "## What You Can Do":
|
|
||||||
```markdown
|
|
||||||
## Web Research Tools
|
|
||||||
|
|
||||||
You have access to two Parallel AI research tools:
|
|
||||||
|
|
||||||
### Quick Web Search (`mcp__parallel-search__search`)
|
|
||||||
**When to use:** Freely use for factual lookups, current events, definitions, recent information, or verifying facts.
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- "Who invented the transistor?"
|
|
||||||
- "What's the latest news about quantum computing?"
|
|
||||||
- "When was the UN founded?"
|
|
||||||
- "What are the top programming languages in 2026?"
|
|
||||||
|
|
||||||
**Speed:** Fast (2-5 seconds)
|
|
||||||
**Cost:** Low
|
|
||||||
**Permission:** Not needed - use whenever it helps answer the question
|
|
||||||
|
|
||||||
### Deep Research (`mcp__parallel-task__create_task_run`)
|
|
||||||
**When to use:** Comprehensive analysis, learning about complex topics, comparing concepts, historical overviews, or structured research.
|
|
||||||
|
|
||||||
**Examples:**
|
|
||||||
- "Explain the development of quantum mechanics from 1900-1930"
|
|
||||||
- "Compare the literary styles of Hemingway and Faulkner"
|
|
||||||
- "Research the evolution of jazz from bebop to fusion"
|
|
||||||
- "Analyze the causes of the French Revolution"
|
|
||||||
|
|
||||||
**Speed:** Slower (1-20 minutes depending on depth)
|
|
||||||
**Cost:** Higher (varies by processor tier)
|
|
||||||
**Permission:** ALWAYS use `AskUserQuestion` before using this tool
|
|
||||||
|
|
||||||
**How to ask permission:**
|
|
||||||
```
|
|
||||||
AskUserQuestion: I can do deep research on [topic] using Parallel's Task API. This will take 2-5 minutes and provide comprehensive analysis with citations. Should I proceed?
|
|
||||||
```
|
|
||||||
|
|
||||||
**After permission - DO NOT BLOCK! Use scheduler instead:**
|
|
||||||
|
|
||||||
1. Create the task using `mcp__parallel-task__create_task_run`
|
|
||||||
2. Get the `run_id` from the response
|
|
||||||
3. Create a polling scheduled task using `mcp__nanoclaw__schedule_task`:
|
|
||||||
```
|
|
||||||
Prompt: "Check Parallel AI task run [run_id] and send results when ready.
|
|
||||||
|
|
||||||
1. Use the Parallel Task MCP to check the task status
|
|
||||||
2. If status is 'completed', extract the results
|
|
||||||
3. Send results to user with mcp__nanoclaw__send_message
|
|
||||||
4. Use mcp__nanoclaw__complete_scheduled_task to mark this task as done
|
|
||||||
|
|
||||||
If status is still 'running' or 'pending', do nothing (task will run again in 30s).
|
|
||||||
If status is 'failed', send error message and complete the task."
|
|
||||||
|
|
||||||
Schedule: interval every 30 seconds
|
|
||||||
Context mode: isolated
|
|
||||||
```
|
|
||||||
4. Send acknowledgment with tracking link
|
|
||||||
5. Exit immediately - scheduler handles the rest
|
|
||||||
|
|
||||||
### Choosing Between Them
|
|
||||||
|
|
||||||
**Use Search when:**
|
|
||||||
- Question needs a quick fact or recent information
|
|
||||||
- Simple definition or clarification
|
|
||||||
- Verifying specific details
|
|
||||||
- Current events or news
|
|
||||||
|
|
||||||
**Use Deep Research (with permission) when:**
|
|
||||||
- User wants to learn about a complex topic
|
|
||||||
- Question requires analysis or comparison
|
|
||||||
- Historical context or evolution of concepts
|
|
||||||
- Structured, comprehensive understanding needed
|
|
||||||
- User explicitly asks to "research" or "explain in depth"
|
|
||||||
|
|
||||||
**Default behavior:** Prefer search for most questions. Only suggest deep research when the topic genuinely requires comprehensive analysis.
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Rebuild Container
|
|
||||||
|
|
||||||
Build the container with updated agent runner:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./container/build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the build:
|
|
||||||
```bash
|
|
||||||
echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Container OK"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7. Restart Service
|
|
||||||
|
|
||||||
Rebuild the main app and restart:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run build:runners
|
||||||
npm run build
|
npm run build
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
npm run setup -- --step service
|
||||||
# Linux: systemctl --user restart nanoclaw
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait 3 seconds for service to start, then verify:
|
디스코드 테스트 예시:
|
||||||
```bash
|
|
||||||
sleep 3
|
|
||||||
launchctl list | grep nanoclaw # macOS
|
|
||||||
# Linux: systemctl --user status nanoclaw
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Test Integration
|
> `최신 AI 뉴스 찾아줘`
|
||||||
|
|
||||||
Tell the user to test:
|
> `AI 에이전트 역사 자세히 조사해줘`
|
||||||
> Send a message to your assistant: `@[YourAssistantName] what's the latest news about AI?`
|
|
||||||
>
|
|
||||||
> The assistant should use Parallel Search API to find current information.
|
|
||||||
>
|
|
||||||
> Then try: `@[YourAssistantName] can you research the history of artificial intelligence?`
|
|
||||||
>
|
|
||||||
> The assistant should ask for permission before using the Task API.
|
|
||||||
|
|
||||||
Check logs to verify MCP servers loaded:
|
두 번째 요청에서는 비용/시간 안내 후 허가를 먼저 묻게 만드는 게 맞습니다.
|
||||||
```bash
|
|
||||||
tail -20 logs/nanoclaw.log
|
|
||||||
```
|
|
||||||
|
|
||||||
Look for: `Parallel AI MCP servers configured`
|
## 문제 확인
|
||||||
|
|
||||||
## Troubleshooting
|
### MCP가 안 뜸
|
||||||
|
|
||||||
**Container hangs or times out:**
|
- `PARALLEL_API_KEY`가 child env까지 전달되는지 확인합니다
|
||||||
- Check that `type: 'http'` is specified in MCP server config
|
- `allowedTools`에 Parallel prefix가 빠지지 않았는지 봅니다
|
||||||
- Verify API key is correct in .env
|
|
||||||
- Check container logs: `cat groups/main/logs/container-*.log | tail -50`
|
|
||||||
|
|
||||||
**MCP servers not loading:**
|
### 긴 작업이 응답을 오래 붙잡음
|
||||||
- Ensure PARALLEL_API_KEY is in .env
|
|
||||||
- Verify container-runner.ts includes PARALLEL_API_KEY in allowedVars
|
|
||||||
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
|
||||||
|
|
||||||
**Task polling not working:**
|
- `parallel-task` 결과를 기다리며 블로킹하지 말고 스케줄러로 넘기도록 프롬프트와 구현을 같이 수정합니다
|
||||||
- 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)
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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)
|
|
||||||
@@ -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)
|
|
||||||
@@ -1,90 +1,60 @@
|
|||||||
---
|
---
|
||||||
name: add-voice-transcription
|
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
|
# 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
|
`.env`에 아래 중 하나 이상을 넣습니다.
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git 2>/dev/null
|
GROQ_API_KEY=gsk_... # 권장. 빠르고 무료 티어가 있음
|
||||||
git fetch whatsapp skill/voice-transcription
|
OPENAI_API_KEY=sk-... # fallback
|
||||||
git merge whatsapp/skill/voice-transcription
|
|
||||||
npm install --legacy-peer-deps
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 2: Configure
|
Groq를 쓰면 `whisper-large-v3-turbo`, OpenAI를 쓰면 `whisper-1` 경로를 탑니다.
|
||||||
|
|
||||||
### Get API key
|
## 2. 재시작
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
systemctl --user restart nanoclaw nanoclaw-codex # Linux
|
npm run setup -- --step service
|
||||||
# macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
```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)
|
- `Audio transcribed + cached`
|
||||||
- `no transcription API key` — neither `GROQ_API_KEY` nor `OPENAI_API_KEY` set
|
- `provider: "groq"` 또는 `provider: "openai"`
|
||||||
- `groq Whisper 4xx` — check key validity
|
- 동일 첨부 재처리 시 cache hit
|
||||||
|
|
||||||
## Troubleshooting
|
## 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`를 같이 봅니다.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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` |
|
|
||||||
@@ -1,115 +1,70 @@
|
|||||||
---
|
---
|
||||||
name: customize
|
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
|
# 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:
|
1. 요청을 `행동 변경`, `명령 추가`, `도구 연동`, `설정/배포`, `프롬프트 수정` 중 어디에 속하는지 먼저 정리합니다.
|
||||||
```bash
|
2. 영향 범위를 좁힙니다.
|
||||||
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
|
3. 관련 파일만 수정합니다.
|
||||||
```
|
4. 가능하면 바로 검증합니다.
|
||||||
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
|
|
||||||
|
|
||||||
## Key Files
|
## 자주 보는 수정 지점
|
||||||
|
|
||||||
| File | Purpose |
|
- `src/channels/discord.ts` - 디스코드 메시지 파싱, 멘션 규칙, 첨부파일 처리, 음성 전사
|
||||||
|------|---------|
|
- `src/index.ts` - 오케스트레이션, 세션 명령, 라우팅, 상태 갱신
|
||||||
| `src/index.ts` | Orchestrator: state, message loop, agent invocation |
|
- `src/session-commands.ts` - `/compact`, `/clear` 같은 세션 명령
|
||||||
| `src/channels/whatsapp.ts` | WhatsApp connection, auth, send/receive |
|
- `src/db.ts` - 등록 그룹, 세션, 스케줄, 마이그레이션
|
||||||
| `src/ipc.ts` | IPC watcher and task processing |
|
- `src/config.ts` - 서비스 식별값, 디렉터리, 타임아웃, 기능 플래그
|
||||||
| `src/router.ts` | Message formatting and outbound routing |
|
- `src/agent-runner.ts` - 에이전트 실행과 환경 변수 전달
|
||||||
| `src/types.ts` | TypeScript interfaces (includes Channel) |
|
- `runners/agent-runner/src/index.ts` - Claude Code 쪽 허용 도구와 MCP
|
||||||
| `src/config.ts` | Assistant name, trigger pattern, directories |
|
- `runners/codex-runner/src/index.ts` - Codex 쪽 실행 경로
|
||||||
| `src/db.ts` | Database initialization and queries |
|
- `setup/register.ts` - 디스코드 채널 등록
|
||||||
| `src/whatsapp-auth.ts` | Standalone WhatsApp authentication script |
|
- `groups/global/CLAUDE.md` 및 각 그룹의 `CLAUDE.md` - 프롬프트/운영 규칙
|
||||||
| `groups/CLAUDE.md` | Global memory/persona |
|
|
||||||
|
|
||||||
## Common Customization Patterns
|
## 요청별 가이드
|
||||||
|
|
||||||
### Adding a New Input Channel (e.g., Telegram, Slack, Email)
|
### 동작을 바꾸고 싶을 때
|
||||||
|
|
||||||
Questions to ask:
|
- 응답 조건, 멘션 처리, 첨부파일 처리: `src/channels/discord.ts`
|
||||||
- Which channel? (Telegram, Slack, Discord, email, SMS, etc.)
|
- 세션 명령, 상태머신, 런루프: `src/index.ts`, `src/session-commands.ts`
|
||||||
- Same trigger word or different?
|
- 페르소나/응답 스타일: `groups/global/CLAUDE.md`
|
||||||
- Same memory hierarchy or separate?
|
|
||||||
- Should messages from this channel go to existing groups or new ones?
|
|
||||||
|
|
||||||
Implementation pattern:
|
### 도구나 MCP를 붙일 때
|
||||||
1. Create `src/channels/{name}.ts` implementing the `Channel` interface from `src/types.ts` (see `src/channels/whatsapp.ts` for reference)
|
|
||||||
2. Add the channel instance to `main()` in `src/index.ts` and wire callbacks (`onMessage`, `onChatMetadata`)
|
|
||||||
3. Messages are stored via the `onMessage` callback; routing is automatic via `ownsJid()`
|
|
||||||
|
|
||||||
### Adding a New MCP Integration
|
- 러너에 실제 도구 허용과 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)
|
- 슬래시/예약 명령이면 `src/session-commands.ts`와 `src/index.ts` 경로를 봅니다.
|
||||||
2. Document available tools in `groups/CLAUDE.md`
|
- 자연어 지시만으로 충분하면 프롬프트 문서만 수정합니다.
|
||||||
|
|
||||||
### Changing Assistant Behavior
|
### 등록/배포 흐름을 바꿀 때
|
||||||
|
|
||||||
Questions to ask:
|
- 설치/검증 단계는 `setup/*`
|
||||||
- What aspect? (name, trigger, persona, response style)
|
- 서비스 동작은 `setup/service.ts`
|
||||||
- Apply to all groups or specific ones?
|
- 그룹 등록 형식은 `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
|
```bash
|
||||||
# Rebuild and restart
|
npm run typecheck
|
||||||
npm run build
|
npm test
|
||||||
# macOS:
|
|
||||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
|
||||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
|
||||||
# Linux:
|
|
||||||
# systemctl --user restart nanoclaw
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example Interaction
|
러너나 실행 경로를 건드렸으면 추가로 확인합니다.
|
||||||
|
|
||||||
User: "Add Telegram as an input channel"
|
```bash
|
||||||
|
npm run build:runners
|
||||||
1. Ask: "Should Telegram use the same @Andy trigger, or a different one?"
|
npm run setup -- --step verify
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,349 +1,91 @@
|
|||||||
---
|
---
|
||||||
name: debug
|
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
|
## 빠른 점검 순서
|
||||||
|
|
||||||
```
|
1. 타입과 테스트부터 확인
|
||||||
Host (macOS) Container (Linux VM)
|
```bash
|
||||||
─────────────────────────────────────────────────────────────
|
npm run typecheck
|
||||||
src/container-runner.ts container/agent-runner/
|
npm test
|
||||||
│ │
|
```
|
||||||
│ spawns container │ runs Claude Agent SDK
|
2. 서비스 상태 확인
|
||||||
│ with volume mounts │ with MCP servers
|
```bash
|
||||||
│ │
|
npm run setup -- --step verify
|
||||||
├── data/env/env ──────────────> /workspace/env-dir/env
|
```
|
||||||
├── groups/{folder} ───────────> /workspace/group
|
3. 런타임 로그 확인
|
||||||
├── data/ipc/{folder} ────────> /workspace/ipc
|
```bash
|
||||||
├── data/sessions/{folder}/.claude/ ──> /home/node/.claude/ (isolated per-group)
|
tail -f logs/nanoclaw.log
|
||||||
└── (main only) project root ──> /workspace/project
|
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
|
```bash
|
||||||
# For development
|
grep -n '^DISCORD_BOT_TOKEN=' .env
|
||||||
LOG_LEVEL=debug npm run dev
|
npm run setup -- --step verify
|
||||||
|
|
||||||
# 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Debug level shows:
|
- `DISCORD_BOT_TOKEN`이 없으면 채널이 안 붙습니다.
|
||||||
- Full mount configurations
|
- `REGISTERED_GROUPS=0`이면 채널 등록이 안 된 상태입니다.
|
||||||
- Container command arguments
|
- 메인 채널이 아니면 디스코드 멘션이나 트리거 조건을 먼저 확인합니다.
|
||||||
- Real-time container stderr
|
|
||||||
|
|
||||||
## Common Issues
|
### 에이전트가 실행 직후 죽음
|
||||||
|
|
||||||
### 1. "Claude Code process exited with code 1"
|
|
||||||
|
|
||||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
|
||||||
|
|
||||||
Common causes:
|
|
||||||
|
|
||||||
#### Missing Authentication
|
|
||||||
```
|
|
||||||
Invalid API key · Please run /login
|
|
||||||
```
|
|
||||||
**Fix:** Ensure `.env` file exists with either OAuth token or API key:
|
|
||||||
```bash
|
|
||||||
cat .env # Should show one of:
|
|
||||||
# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... (subscription)
|
|
||||||
# ANTHROPIC_API_KEY=sk-ant-api03-... (pay-per-use)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Root User Restriction
|
|
||||||
```
|
|
||||||
--dangerously-skip-permissions cannot be used with root/sudo privileges
|
|
||||||
```
|
|
||||||
**Fix:** Container must run as non-root user. Check Dockerfile has `USER node`.
|
|
||||||
|
|
||||||
### 2. Environment Variables Not Passing
|
|
||||||
|
|
||||||
**Runtime note:** Environment variables passed via `-e` may be lost when using `-i` (interactive/piped stdin).
|
|
||||||
|
|
||||||
**Workaround:** The system extracts only authentication variables (`CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`) from `.env` and mounts them for sourcing inside the container. Other env vars are not exposed.
|
|
||||||
|
|
||||||
To verify env vars are reaching the container:
|
|
||||||
```bash
|
|
||||||
echo '{}' | docker run -i \
|
|
||||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
|
||||||
--entrypoint /bin/bash nanoclaw-agent:latest \
|
|
||||||
-c 'export $(cat /workspace/env-dir/env | xargs); echo "OAuth: ${#CLAUDE_CODE_OAUTH_TOKEN} chars, API: ${#ANTHROPIC_API_KEY} chars"'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Mount Issues
|
|
||||||
|
|
||||||
**Container mount notes:**
|
|
||||||
- Docker supports both `-v` and `--mount` syntax
|
|
||||||
- Use `:ro` suffix for readonly mounts:
|
|
||||||
```bash
|
|
||||||
# Readonly
|
|
||||||
-v /path:/container/path:ro
|
|
||||||
|
|
||||||
# Read-write
|
|
||||||
-v /path:/container/path
|
|
||||||
```
|
|
||||||
|
|
||||||
To check what's mounted inside a container:
|
|
||||||
```bash
|
|
||||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c 'ls -la /workspace/'
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected structure:
|
|
||||||
```
|
|
||||||
/workspace/
|
|
||||||
├── env-dir/env # Environment file (CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY)
|
|
||||||
├── group/ # Current group folder (cwd)
|
|
||||||
├── project/ # Project root (main channel only)
|
|
||||||
├── global/ # Global CLAUDE.md (non-main only)
|
|
||||||
├── ipc/ # Inter-process communication
|
|
||||||
│ ├── messages/ # Outgoing WhatsApp messages
|
|
||||||
│ ├── tasks/ # Scheduled task commands
|
|
||||||
│ ├── current_tasks.json # Read-only: scheduled tasks visible to this group
|
|
||||||
│ └── available_groups.json # Read-only: WhatsApp groups for activation (main only)
|
|
||||||
└── extra/ # Additional custom mounts
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Permission Issues
|
|
||||||
|
|
||||||
The container runs as user `node` (uid 1000). Check ownership:
|
|
||||||
```bash
|
|
||||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
|
|
||||||
whoami
|
|
||||||
ls -la /workspace/
|
|
||||||
ls -la /app/
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
All of `/workspace/` and `/app/` should be owned by `node`.
|
|
||||||
|
|
||||||
### 5. Session Not Resuming / "Claude Code process exited with code 1"
|
|
||||||
|
|
||||||
If sessions aren't being resumed (new session ID every time), or Claude Code exits with code 1 when resuming:
|
|
||||||
|
|
||||||
**Root cause:** The SDK looks for sessions at `$HOME/.claude/projects/`. Inside the container, `HOME=/home/node`, so it looks at `/home/node/.claude/projects/`.
|
|
||||||
|
|
||||||
**Check the mount path:**
|
|
||||||
```bash
|
|
||||||
# In container-runner.ts, verify mount is to /home/node/.claude/, NOT /root/.claude/
|
|
||||||
grep -A3 "Claude sessions" src/container-runner.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
**Verify sessions are accessible:**
|
|
||||||
```bash
|
|
||||||
docker run --rm --entrypoint /bin/bash \
|
|
||||||
-v ~/.claude:/home/node/.claude \
|
|
||||||
nanoclaw-agent:latest -c '
|
|
||||||
echo "HOME=$HOME"
|
|
||||||
ls -la $HOME/.claude/projects/ 2>&1 | head -5
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fix:** Ensure `container-runner.ts` mounts to `/home/node/.claude/`:
|
|
||||||
```typescript
|
|
||||||
mounts.push({
|
|
||||||
hostPath: claudeDir,
|
|
||||||
containerPath: '/home/node/.claude', // NOT /root/.claude
|
|
||||||
readonly: false
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. MCP Server Failures
|
|
||||||
|
|
||||||
If an MCP server fails to start, the agent may exit. Check the container logs for MCP initialization errors.
|
|
||||||
|
|
||||||
## Manual Container Testing
|
|
||||||
|
|
||||||
### Test the full agent flow:
|
|
||||||
```bash
|
|
||||||
# Set up env file
|
|
||||||
mkdir -p data/env groups/test
|
|
||||||
cp .env data/env/env
|
|
||||||
|
|
||||||
# Run test query
|
|
||||||
echo '{"prompt":"What is 2+2?","groupFolder":"test","chatJid":"test@g.us","isMain":false}' | \
|
|
||||||
docker run -i \
|
|
||||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
|
||||||
-v $(pwd)/groups/test:/workspace/group \
|
|
||||||
-v $(pwd)/data/ipc:/workspace/ipc \
|
|
||||||
nanoclaw-agent:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Claude Code directly:
|
|
||||||
```bash
|
|
||||||
docker run --rm --entrypoint /bin/bash \
|
|
||||||
-v $(pwd)/data/env:/workspace/env-dir:ro \
|
|
||||||
nanoclaw-agent:latest -c '
|
|
||||||
export $(cat /workspace/env-dir/env | xargs)
|
|
||||||
claude -p "Say hello" --dangerously-skip-permissions --allowedTools ""
|
|
||||||
'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Interactive shell in container:
|
|
||||||
```bash
|
|
||||||
docker run --rm -it --entrypoint /bin/bash nanoclaw-agent:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## SDK Options Reference
|
|
||||||
|
|
||||||
The agent-runner uses these Claude Agent SDK options:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
query({
|
|
||||||
prompt: input.prompt,
|
|
||||||
options: {
|
|
||||||
cwd: '/workspace/group',
|
|
||||||
allowedTools: ['Bash', 'Read', 'Write', ...],
|
|
||||||
permissionMode: 'bypassPermissions',
|
|
||||||
allowDangerouslySkipPermissions: true, // Required with bypassPermissions
|
|
||||||
settingSources: ['project'],
|
|
||||||
mcpServers: { ... }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** `allowDangerouslySkipPermissions: true` is required when using `permissionMode: 'bypassPermissions'`. Without it, Claude Code exits with code 1.
|
|
||||||
|
|
||||||
## Rebuilding After Changes
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Rebuild main app
|
grep -nE '^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|OPENAI_API_KEY|CODEX_OPENAI_API_KEY)=' .env
|
||||||
npm run build
|
ls -t groups/*/logs/agent-*.log | head -3
|
||||||
|
|
||||||
# Rebuild container (use --no-cache for clean rebuild)
|
|
||||||
./container/build.sh
|
|
||||||
|
|
||||||
# Or force full rebuild
|
|
||||||
docker builder prune -af
|
|
||||||
./container/build.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Checking Container Image
|
- Claude 계열은 `CLAUDE_CODE_OAUTH_TOKEN` 또는 `ANTHROPIC_API_KEY`가 필요합니다.
|
||||||
|
- Codex 계열은 `OPENAI_API_KEY` 또는 `CODEX_OPENAI_API_KEY`가 필요합니다.
|
||||||
|
- 러너 로그에 인증 실패나 CLI 실행 오류가 바로 찍힙니다.
|
||||||
|
|
||||||
|
### 음성 전사가 안 됨
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List images
|
grep -nE '^(GROQ_API_KEY|OPENAI_API_KEY)=' .env
|
||||||
docker images
|
tail -f logs/nanoclaw.log | grep -iE 'transcri|audio|whisper|groq'
|
||||||
|
|
||||||
# Check what's in the image
|
|
||||||
docker run --rm --entrypoint /bin/bash nanoclaw-agent:latest -c '
|
|
||||||
echo "=== Node version ==="
|
|
||||||
node --version
|
|
||||||
|
|
||||||
echo "=== Claude Code version ==="
|
|
||||||
claude --version
|
|
||||||
|
|
||||||
echo "=== Installed packages ==="
|
|
||||||
ls /app/node_modules/
|
|
||||||
'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Session Persistence
|
- 기본 우선순위는 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
|
```bash
|
||||||
# Clear all sessions for all groups
|
sqlite3 store/messages.db "select jid, folder, requires_trigger, is_main, agent_type from registered_groups;"
|
||||||
rm -rf data/sessions/
|
|
||||||
|
|
||||||
# Clear sessions for a specific group
|
|
||||||
rm -rf data/sessions/{groupFolder}/.claude/
|
|
||||||
|
|
||||||
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
|
|
||||||
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To verify session resumption is working, check the logs for the same session ID across messages:
|
- `jid`는 `dc:<channel_id>` 형식이어야 합니다.
|
||||||
```bash
|
- `folder`는 `discord_main` 또는 `discord_<name>` 형태를 유지합니다.
|
||||||
grep "Session initialized" logs/nanoclaw.log | tail -5
|
- 세션 명령 문제면 `src/session-commands.ts`와 `src/index.ts` 호출부를 같이 봅니다.
|
||||||
# Should show the SAME session ID for consecutive messages in the same group
|
|
||||||
```
|
|
||||||
|
|
||||||
## IPC Debugging
|
## 원칙
|
||||||
|
|
||||||
The container communicates back to the host via files in `/workspace/ipc/`:
|
- 채널 문제와 에이전트 문제를 섞지 말고 분리해서 봅니다.
|
||||||
|
- `.env`, DB 등록, 서비스, 러너 빌드 중 하나라도 틀리면 상위 증상이 비슷하게 보입니다.
|
||||||
```bash
|
- 컨테이너 전제 문서는 무시하고 `runners/*`와 `setup/*` 기준으로 확인합니다.
|
||||||
# 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"
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,203 +1,114 @@
|
|||||||
---
|
---
|
||||||
name: setup
|
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
|
# 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.
|
## 1. 부트스트랩
|
||||||
|
|
||||||
**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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claude plugin marketplace add qwibitai/nanoclaw-skills
|
bash setup.sh
|
||||||
claude plugin marketplace update nanoclaw-skills
|
|
||||||
claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## 2. 현재 상태 확인
|
||||||
|
|
||||||
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
|
|
||||||
- WhatsApp (authenticates via QR code or pairing code)
|
|
||||||
- Telegram (authenticates via bot token from @BotFather)
|
|
||||||
- Slack (authenticates via Slack app with Socket Mode)
|
|
||||||
- Discord (authenticates via Discord bot token)
|
|
||||||
|
|
||||||
**Delegate to each selected channel's own skill.** Each channel skill handles its own code installation, authentication, registration, and JID resolution. This avoids duplicating channel-specific logic and ensures JIDs are always correct.
|
|
||||||
|
|
||||||
For each selected channel, invoke its skill:
|
|
||||||
|
|
||||||
- **WhatsApp:** Invoke `/add-whatsapp`
|
|
||||||
- **Telegram:** Invoke `/add-telegram`
|
|
||||||
- **Slack:** Invoke `/add-slack`
|
|
||||||
- **Discord:** Invoke `/add-discord`
|
|
||||||
|
|
||||||
Each skill will:
|
|
||||||
1. Install the channel code (via `git merge` of the skill branch)
|
|
||||||
2. Collect credentials/tokens and write to `.env`
|
|
||||||
3. Authenticate (WhatsApp QR/pairing, or verify token-based connection)
|
|
||||||
4. Register the chat with the correct JID format
|
|
||||||
5. Build and verify
|
|
||||||
|
|
||||||
**After all channel skills complete**, install dependencies and rebuild — channel merges may introduce new packages:
|
|
||||||
|
|
||||||
```bash
|
```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`
|
`.env`에 최소한 아래 값이 있어야 합니다.
|
||||||
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
|
|
||||||
|
|
||||||
## 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:**
|
```bash
|
||||||
- Read `logs/setup.log` for the error.
|
npm run setup -- --step runners
|
||||||
- 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.
|
|
||||||
|
|
||||||
## 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:**
|
실패하면 보통 `npm run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다.
|
||||||
- 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`
|
|
||||||
|
|
||||||
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`
|
||||||
|
|||||||
@@ -1,235 +1,100 @@
|
|||||||
---
|
---
|
||||||
name: update-nanoclaw
|
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.
|
```bash
|
||||||
|
git status --porcelain
|
||||||
**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
|
git remote -v
|
||||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
|
git fetch upstream --prune
|
||||||
- **Source** (`src/`): may conflict if you modified the same files
|
|
||||||
- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
|
|
||||||
|
|
||||||
**Update paths** (you pick one):
|
|
||||||
- `merge` (default): `git merge upstream/<branch>`. Resolves all conflicts in one pass.
|
|
||||||
- `cherry-pick`: `git cherry-pick <hashes>`. Pull in only the commits you want.
|
|
||||||
- `rebase`: `git rebase upstream/<branch>`. Linear history, but conflicts resolve per-commit.
|
|
||||||
- `abort`: just view the changelog, change nothing.
|
|
||||||
|
|
||||||
**Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point.
|
|
||||||
|
|
||||||
**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
|
|
||||||
|
|
||||||
**Validation**: runs `npm run build` and `npm test`.
|
|
||||||
|
|
||||||
**Breaking changes check**: after validation, reads CHANGELOG.md for any `[BREAKING]` entries introduced by the update. If found, shows each breaking change and offers to run the recommended skill to migrate.
|
|
||||||
|
|
||||||
## Rollback
|
|
||||||
|
|
||||||
The backup tag is printed at the end of each run:
|
|
||||||
```
|
|
||||||
git reset --hard pre-update-<hash>-<timestamp>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Backup branch `backup/pre-update-<hash>-<timestamp>` also exists.
|
- 변경 파일이 남아 있으면 먼저 정리하게 합니다
|
||||||
|
- `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.
|
```bash
|
||||||
|
HASH=$(git rev-parse --short HEAD)
|
||||||
---
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
git branch backup/pre-update-$HASH-$TIMESTAMP
|
||||||
# Goal
|
git tag pre-update-$HASH-$TIMESTAMP
|
||||||
Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens.
|
|
||||||
|
|
||||||
# Operating principles
|
|
||||||
- Never proceed with a dirty working tree.
|
|
||||||
- Always create a rollback point (backup branch + tag) before touching anything.
|
|
||||||
- Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers.
|
|
||||||
- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
|
|
||||||
- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
|
|
||||||
|
|
||||||
# Step 0: Preflight (stop early if unsafe)
|
|
||||||
Run:
|
|
||||||
- `git status --porcelain`
|
|
||||||
If output is non-empty:
|
|
||||||
- Tell the user to commit or stash first, then stop.
|
|
||||||
|
|
||||||
Confirm remotes:
|
|
||||||
- `git remote -v`
|
|
||||||
If `upstream` is missing:
|
|
||||||
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
|
|
||||||
- Add it: `git remote add upstream <user-provided-url>`
|
|
||||||
- Then: `git fetch upstream --prune`
|
|
||||||
|
|
||||||
Determine the upstream branch name:
|
|
||||||
- `git branch -r | grep upstream/`
|
|
||||||
- If `upstream/main` exists, use `main`.
|
|
||||||
- If only `upstream/master` exists, use `master`.
|
|
||||||
- Otherwise, ask the user which branch to use.
|
|
||||||
- Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead.
|
|
||||||
|
|
||||||
Fetch:
|
|
||||||
- `git fetch upstream --prune`
|
|
||||||
|
|
||||||
# Step 1: Create a safety net
|
|
||||||
Capture current state:
|
|
||||||
- `HASH=$(git rev-parse --short HEAD)`
|
|
||||||
- `TIMESTAMP=$(date +%Y%m%d-%H%M%S)`
|
|
||||||
|
|
||||||
Create backup branch and tag (using timestamp to avoid collisions on retry):
|
|
||||||
- `git branch backup/pre-update-$HASH-$TIMESTAMP`
|
|
||||||
- `git tag pre-update-$HASH-$TIMESTAMP`
|
|
||||||
|
|
||||||
Save the tag name for later reference in the summary and rollback instructions.
|
|
||||||
|
|
||||||
# Step 2: Preview what upstream changed (no edits yet)
|
|
||||||
Compute common base:
|
|
||||||
- `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
|
|
||||||
|
|
||||||
Show upstream commits since BASE:
|
|
||||||
- `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
|
|
||||||
|
|
||||||
Show local commits since BASE (custom drift):
|
|
||||||
- `git log --oneline $BASE..HEAD`
|
|
||||||
|
|
||||||
Show file-level impact from upstream:
|
|
||||||
- `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH`
|
|
||||||
|
|
||||||
Bucket the upstream changed files:
|
|
||||||
- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
|
|
||||||
- **Source** (`src/`): may conflict if user modified the same files
|
|
||||||
- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
|
|
||||||
- **Other**: docs, tests, misc
|
|
||||||
|
|
||||||
Present these buckets to the user and ask them to choose one path using AskUserQuestion:
|
|
||||||
- A) **Full update**: merge all upstream changes
|
|
||||||
- B) **Selective update**: cherry-pick specific upstream commits
|
|
||||||
- C) **Abort**: they only wanted the preview
|
|
||||||
- D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit)
|
|
||||||
|
|
||||||
If Abort: stop here.
|
|
||||||
|
|
||||||
# Step 3: Conflict preview (before committing anything)
|
|
||||||
If Full update or Rebase:
|
|
||||||
- Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes:
|
|
||||||
```
|
|
||||||
git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort
|
|
||||||
```
|
|
||||||
- If conflicts were listed: show them and ask user if they want to proceed.
|
|
||||||
- If no conflicts: tell user it is clean and proceed.
|
|
||||||
|
|
||||||
# Step 4A: Full update (MERGE, default)
|
|
||||||
Run:
|
|
||||||
- `git merge upstream/$UPSTREAM_BRANCH --no-edit`
|
|
||||||
|
|
||||||
If conflicts occur:
|
|
||||||
- Run `git status` and identify conflicted files.
|
|
||||||
- For each conflicted file:
|
|
||||||
- Open the file.
|
|
||||||
- Resolve only conflict markers.
|
|
||||||
- Preserve intentional local customizations.
|
|
||||||
- Incorporate upstream fixes/improvements.
|
|
||||||
- Do not refactor surrounding code.
|
|
||||||
- `git add <file>`
|
|
||||||
- When all resolved:
|
|
||||||
- If merge did not auto-commit: `git commit --no-edit`
|
|
||||||
|
|
||||||
# Step 4B: Selective update (CHERRY-PICK)
|
|
||||||
If user chose Selective:
|
|
||||||
- Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
|
|
||||||
- Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
|
|
||||||
- Ask user which commit hashes they want.
|
|
||||||
- Apply: `git cherry-pick <hash1> <hash2> ...`
|
|
||||||
|
|
||||||
If conflicts during cherry-pick:
|
|
||||||
- Resolve only conflict markers, then:
|
|
||||||
- `git add <file>`
|
|
||||||
- `git cherry-pick --continue`
|
|
||||||
If user wants to stop:
|
|
||||||
- `git cherry-pick --abort`
|
|
||||||
|
|
||||||
# Step 4C: Rebase (only if user explicitly chose option D)
|
|
||||||
Run:
|
|
||||||
- `git rebase upstream/$UPSTREAM_BRANCH`
|
|
||||||
|
|
||||||
If conflicts:
|
|
||||||
- Resolve conflict markers only, then:
|
|
||||||
- `git add <file>`
|
|
||||||
- `git rebase --continue`
|
|
||||||
If it gets messy (more than 3 rounds of conflicts):
|
|
||||||
- `git rebase --abort`
|
|
||||||
- Recommend merge instead.
|
|
||||||
|
|
||||||
# Step 5: Validation
|
|
||||||
Run:
|
|
||||||
- `npm run build`
|
|
||||||
- `npm test` (do not fail the flow if tests are not configured)
|
|
||||||
|
|
||||||
If build fails:
|
|
||||||
- Show the error.
|
|
||||||
- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
|
|
||||||
- Do not refactor unrelated code.
|
|
||||||
- If unclear, ask the user before making changes.
|
|
||||||
|
|
||||||
# Step 6: Breaking changes check
|
|
||||||
After validation succeeds, check if the update introduced any breaking changes.
|
|
||||||
|
|
||||||
Determine which CHANGELOG entries are new by diffing against the backup tag:
|
|
||||||
- `git diff <backup-tag-from-step-1>..HEAD -- CHANGELOG.md`
|
|
||||||
|
|
||||||
Parse the diff output for lines starting with `+[BREAKING]`. Each such line is one breaking change entry. The format is:
|
|
||||||
```
|
|
||||||
[BREAKING] <description>. Run `/<skill-name>` to <action>.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
If no `[BREAKING]` lines are found:
|
## 3. 미리 보기
|
||||||
- Skip this step silently. Proceed to Step 7 (skill updates check).
|
|
||||||
|
|
||||||
If one or more `[BREAKING]` lines are found:
|
```bash
|
||||||
- Display a warning header to the user: "This update includes breaking changes that may require action:"
|
BASE=$(git merge-base HEAD upstream/main)
|
||||||
- For each breaking change, display the full description.
|
git log --oneline $BASE..upstream/main
|
||||||
- Collect all skill names referenced in the breaking change entries (the `/<skill-name>` part).
|
git log --oneline $BASE..HEAD
|
||||||
- Use AskUserQuestion to ask the user which migration skills they want to run now. Options:
|
git diff --name-only $BASE..upstream/main
|
||||||
- One option per referenced skill (e.g., "Run /add-whatsapp to re-add WhatsApp channel")
|
```
|
||||||
- "Skip — I'll handle these manually"
|
|
||||||
- Set `multiSelect: true` so the user can pick multiple skills if there are several breaking changes.
|
|
||||||
- For each skill the user selects, invoke it using the Skill tool.
|
|
||||||
- After all selected skills complete (or if user chose Skip), proceed to Step 7 (skill updates check).
|
|
||||||
|
|
||||||
# Step 7: Check for skill updates
|
파일은 대략 이렇게 나눠서 봅니다.
|
||||||
After the summary, check if skills are distributed as branches in this repo:
|
|
||||||
- `git branch -r --list 'upstream/skill/*'`
|
|
||||||
|
|
||||||
If any `upstream/skill/*` branches exist:
|
- `.claude/skills/` - 스킬 문서/도우미
|
||||||
- Use AskUserQuestion to ask: "Upstream has skill branches. Would you like to check for skill updates?"
|
- `src/` - 런타임과 채널 처리
|
||||||
- Option 1: "Yes, check for updates" (description: "Runs /update-skills to check for and apply skill branch updates")
|
- `runners/` - Claude/Codex 실행 경로
|
||||||
- Option 2: "No, skip" (description: "You can run /update-skills later any time")
|
- `setup/` - 설치, 등록, 검증, 서비스
|
||||||
- If user selects yes, invoke `/update-skills` using the Skill tool.
|
- `README.md`, `groups/` - 운영 문서와 프롬프트
|
||||||
- After the skill completes (or if user selected no), proceed to Step 8.
|
|
||||||
|
|
||||||
# Step 8: Summary + rollback instructions
|
## 4. 적용
|
||||||
Show:
|
|
||||||
- Backup tag: the tag name created in Step 1
|
|
||||||
- New HEAD: `git rev-parse --short HEAD`
|
|
||||||
- Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH`
|
|
||||||
- Conflicts resolved (list files, if any)
|
|
||||||
- Breaking changes applied (list skills run, if any)
|
|
||||||
- Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD`
|
|
||||||
|
|
||||||
Tell the user:
|
기본은 merge 입니다.
|
||||||
- To rollback: `git reset --hard <backup-tag-from-step-1>`
|
|
||||||
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
|
```bash
|
||||||
- Restart the service to apply changes:
|
git merge upstream/main --no-edit
|
||||||
- If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
|
```
|
||||||
- If running manually: restart `npm run dev`
|
|
||||||
|
일부 커밋만 가져올 거면 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
|
||||||
|
|
||||||
|
롤백 안내는 태그 기준으로 알려줍니다.
|
||||||
|
|||||||
@@ -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`.
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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
11
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
|||||||
.npm-cache/
|
.npm-cache/
|
||||||
# Build output
|
# Build output
|
||||||
dist/
|
dist/
|
||||||
|
runners/*/dist/
|
||||||
|
|
||||||
# Local data & auth
|
# Local data & auth
|
||||||
store/
|
store/
|
||||||
@@ -24,12 +25,22 @@ groups/global/*
|
|||||||
# Secrets
|
# Secrets
|
||||||
*.keys.json
|
*.keys.json
|
||||||
.env
|
.env
|
||||||
|
.env.codex
|
||||||
|
|
||||||
# Temp files
|
# Temp files
|
||||||
.tmp-*
|
.tmp-*
|
||||||
|
tmp/
|
||||||
|
backups/
|
||||||
|
dist-backups/
|
||||||
|
.deploy-backups/
|
||||||
|
cache/
|
||||||
|
runners/*/node_modules/
|
||||||
|
runners/codex-runner-backups/
|
||||||
|
*.bak-*
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
46
CLAUDE.md
46
CLAUDE.md
@@ -4,7 +4,7 @@ Dual-agent AI assistant (Claude Code + Codex) over Discord. Based on [qwibitai/n
|
|||||||
|
|
||||||
## Quick Context
|
## 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
|
## 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/config.ts` | Trigger pattern, paths, intervals |
|
||||||
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
| `src/task-scheduler.ts` | Runs scheduled tasks |
|
||||||
| `src/db.ts` | SQLite operations |
|
| `src/db.ts` | SQLite operations |
|
||||||
| `container/agent-runner/` | Claude Code runner (Agent SDK) |
|
| `runners/agent-runner/` | Claude Code runner (Agent SDK) |
|
||||||
| `container/codex-runner/` | Codex runner (app-server JSON-RPC, streaming, turn/steer) |
|
| `runners/codex-runner/` | Codex runner (SDK, `codex exec` wrapper) |
|
||||||
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
| `groups/{name}/CLAUDE.md` | Per-group memory (isolated) |
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
@@ -54,19 +54,39 @@ Deploy to server: `scp dist/*.js clone-ej@100.64.185.108:~/nanoclaw/dist/`
|
|||||||
|
|
||||||
## Dual-Service Architecture
|
## Dual-Service Architecture
|
||||||
|
|
||||||
- `nanoclaw.service` — Claude Code bot (`@claude`), uses `store/`, `data/`, `groups/`
|
- `nanoclaw.service` — Claude Code bot (`@claude`), `SERVICE_ID=claude`, `SERVICE_AGENT_TYPE=claude-code`
|
||||||
- `nanoclaw-codex.service` — Codex bot (`@codex`), uses `store-codex/`, `data-codex/`, `groups-codex/`
|
- `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 (`NANOCLAW_STORE_DIR`, etc.)
|
- Both share the same codebase (`dist/index.js`), differentiated by env vars
|
||||||
- Channel registration is per-service DB (`registered_groups` table)
|
- 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`):
|
Unified DB + directories (both services share `store/`, `groups/`, `data/`):
|
||||||
- `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
|
| **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`
|
- 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
|
## Voice Transcription
|
||||||
|
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -60,7 +60,7 @@ nanoclaw/
|
|||||||
│ └── channels/
|
│ └── channels/
|
||||||
│ ├── registry.ts # Channel self-registration system
|
│ ├── registry.ts # Channel self-registration system
|
||||||
│ └── discord.ts # Discord: mentions, images, typing, file attachments
|
│ └── discord.ts # Discord: mentions, images, typing, file attachments
|
||||||
├── container/
|
├── runners/
|
||||||
│ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input)
|
│ ├── agent-runner/ # Claude Code runner (Agent SDK, multimodal input)
|
||||||
│ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue)
|
│ ├── codex-runner/ # Codex runner (app-server JSON-RPC, auto-continue)
|
||||||
│ └── skills/ # Shared agent skills (browser, etc.)
|
│ └── skills/ # Shared agent skills (browser, etc.)
|
||||||
@@ -77,7 +77,7 @@ nanoclaw/
|
|||||||
|
|
||||||
### Codex App-Server Integration
|
### 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
|
- **Session persistence**: Thread IDs stored in DB, sessions saved as JSONL on disk
|
||||||
- **Streaming**: `item/agentMessage/delta` notifications for real-time text
|
- **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:
|
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
|
- Codex sessions: Same sources, synced to per-group `.codex/` directories
|
||||||
- Skills auto-register as slash commands (`/name`) in Claude Code and `$name` in Codex
|
- 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
|
### GroupQueue
|
||||||
|
|
||||||
`src/group-queue.ts` manages agent execution with:
|
`src/group-queue.ts` manages agent execution with:
|
||||||
@@ -257,7 +248,7 @@ Fields:
|
|||||||
| `agent_type` | `claude-code`, `codex`, or `both` |
|
| `agent_type` | `claude-code`, `codex`, or `both` |
|
||||||
| `trigger_pattern` | Regex for activation (e.g., `@claude`) |
|
| `trigger_pattern` | Regex for activation (e.g., `@claude`) |
|
||||||
| `work_dir` | Optional working directory override |
|
| `work_dir` | Optional working directory override |
|
||||||
| `container_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
|
| `agent_config` | Optional JSON (e.g., `{"codexEffort":"high"}`) |
|
||||||
|
|
||||||
### macOS (launchd)
|
### macOS (launchd)
|
||||||
|
|
||||||
|
|||||||
47
container/codex-runner/package-lock.json
generated
47
container/codex-runner/package-lock.json
generated
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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).
|
|
||||||
@@ -1,643 +0,0 @@
|
|||||||
# Claude Agent SDK Deep Dive
|
|
||||||
|
|
||||||
Findings from reverse-engineering `@anthropic-ai/claude-agent-sdk` v0.2.29–0.2.34 to understand how `query()` works, why agent teams subagents were being killed, and how to fix it. Supplemented with official SDK reference docs.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Agent Runner (our code)
|
|
||||||
└── query() → SDK (sdk.mjs)
|
|
||||||
└── spawns CLI subprocess (cli.js)
|
|
||||||
└── Claude API calls, tool execution
|
|
||||||
└── Task tool → spawns subagent subprocesses
|
|
||||||
```
|
|
||||||
|
|
||||||
The SDK spawns `cli.js` as a child process with `--output-format stream-json --input-format stream-json --print --verbose` flags. Communication happens via JSON-lines on stdin/stdout.
|
|
||||||
|
|
||||||
`query()` returns a `Query` object extending `AsyncGenerator<SDKMessage, void>`. Internally:
|
|
||||||
|
|
||||||
- SDK spawns CLI as a child process, communicates via stdin/stdout JSON lines
|
|
||||||
- SDK's `readMessages()` reads from CLI stdout, enqueues into internal stream
|
|
||||||
- `readSdkMessages()` async generator yields from that stream
|
|
||||||
- `[Symbol.asyncIterator]` returns `readSdkMessages()`
|
|
||||||
- Iterator returns `done: true` only when CLI closes stdout
|
|
||||||
|
|
||||||
Both V1 (`query()`) and V2 (`createSession`/`send`/`stream`) use the exact same three-layer architecture:
|
|
||||||
|
|
||||||
```
|
|
||||||
SDK (sdk.mjs) CLI Process (cli.js)
|
|
||||||
-------------- --------------------
|
|
||||||
XX Transport ------> stdin reader (bd1)
|
|
||||||
(spawn cli.js) |
|
|
||||||
$X Query <------ stdout writer
|
|
||||||
(JSON-lines) |
|
|
||||||
EZ() recursive generator
|
|
||||||
|
|
|
||||||
Anthropic Messages API
|
|
||||||
```
|
|
||||||
|
|
||||||
## The Core Agent Loop (EZ)
|
|
||||||
|
|
||||||
Inside the CLI, the agentic loop is a **recursive async generator called `EZ()`**, not an iterative while loop:
|
|
||||||
|
|
||||||
```
|
|
||||||
EZ({ messages, systemPrompt, canUseTool, maxTurns, turnCount=1, ... })
|
|
||||||
```
|
|
||||||
|
|
||||||
Each invocation = one API call to Claude (one "turn").
|
|
||||||
|
|
||||||
### Flow per turn:
|
|
||||||
|
|
||||||
1. **Prepare messages** — trim context, run compaction if needed
|
|
||||||
2. **Call the Anthropic API** (via `mW1` streaming function)
|
|
||||||
3. **Extract tool_use blocks** from the response
|
|
||||||
4. **Branch:**
|
|
||||||
- If **no tool_use blocks** → stop (run stop hooks, return)
|
|
||||||
- If **tool_use blocks present** → execute tools, increment turnCount, recurse
|
|
||||||
|
|
||||||
All complex logic — the agent loop, tool execution, background tasks, teammate orchestration — runs inside the CLI subprocess. `query()` is a thin transport wrapper.
|
|
||||||
|
|
||||||
## query() Options
|
|
||||||
|
|
||||||
Full `Options` type from the official docs:
|
|
||||||
|
|
||||||
| Property | Type | Default | Description |
|
|
||||||
|----------|------|---------|-------------|
|
|
||||||
| `abortController` | `AbortController` | `new AbortController()` | Controller for cancelling operations |
|
|
||||||
| `additionalDirectories` | `string[]` | `[]` | Additional directories Claude can access |
|
|
||||||
| `agents` | `Record<string, AgentDefinition>` | `undefined` | Programmatically define subagents (not agent teams — no orchestration) |
|
|
||||||
| `allowDangerouslySkipPermissions` | `boolean` | `false` | Required when using `permissionMode: 'bypassPermissions'` |
|
|
||||||
| `allowedTools` | `string[]` | All tools | List of allowed tool names |
|
|
||||||
| `betas` | `SdkBeta[]` | `[]` | Beta features (e.g., `['context-1m-2025-08-07']` for 1M context) |
|
|
||||||
| `canUseTool` | `CanUseTool` | `undefined` | Custom permission function for tool usage |
|
|
||||||
| `continue` | `boolean` | `false` | Continue the most recent conversation |
|
|
||||||
| `cwd` | `string` | `process.cwd()` | Current working directory |
|
|
||||||
| `disallowedTools` | `string[]` | `[]` | List of disallowed tool names |
|
|
||||||
| `enableFileCheckpointing` | `boolean` | `false` | Enable file change tracking for rewinding |
|
|
||||||
| `env` | `Dict<string>` | `process.env` | Environment variables |
|
|
||||||
| `executable` | `'bun' \| 'deno' \| 'node'` | Auto-detected | JavaScript runtime |
|
|
||||||
| `fallbackModel` | `string` | `undefined` | Model to use if primary fails |
|
|
||||||
| `forkSession` | `boolean` | `false` | When resuming, fork to a new session ID instead of continuing original |
|
|
||||||
| `hooks` | `Partial<Record<HookEvent, HookCallbackMatcher[]>>` | `{}` | Hook callbacks for events |
|
|
||||||
| `includePartialMessages` | `boolean` | `false` | Include partial message events (streaming) |
|
|
||||||
| `maxBudgetUsd` | `number` | `undefined` | Maximum budget in USD for the query |
|
|
||||||
| `maxThinkingTokens` | `number` | `undefined` | Maximum tokens for thinking process |
|
|
||||||
| `maxTurns` | `number` | `undefined` | Maximum conversation turns |
|
|
||||||
| `mcpServers` | `Record<string, McpServerConfig>` | `{}` | MCP server configurations |
|
|
||||||
| `model` | `string` | Default from CLI | Claude model to use |
|
|
||||||
| `outputFormat` | `{ type: 'json_schema', schema: JSONSchema }` | `undefined` | Structured output format |
|
|
||||||
| `pathToClaudeCodeExecutable` | `string` | Uses built-in | Path to Claude Code executable |
|
|
||||||
| `permissionMode` | `PermissionMode` | `'default'` | Permission mode |
|
|
||||||
| `plugins` | `SdkPluginConfig[]` | `[]` | Load custom plugins from local paths |
|
|
||||||
| `resume` | `string` | `undefined` | Session ID to resume |
|
|
||||||
| `resumeSessionAt` | `string` | `undefined` | Resume session at a specific message UUID |
|
|
||||||
| `sandbox` | `SandboxSettings` | `undefined` | Sandbox behavior configuration |
|
|
||||||
| `settingSources` | `SettingSource[]` | `[]` (none) | Which filesystem settings to load. Must include `'project'` to load CLAUDE.md |
|
|
||||||
| `stderr` | `(data: string) => void` | `undefined` | Callback for stderr output |
|
|
||||||
| `systemPrompt` | `string \| { type: 'preset'; preset: 'claude_code'; append?: string }` | `undefined` | System prompt. Use preset to get Claude Code's prompt, with optional `append` |
|
|
||||||
| `tools` | `string[] \| { type: 'preset'; preset: 'claude_code' }` | `undefined` | Tool configuration |
|
|
||||||
|
|
||||||
### PermissionMode
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan';
|
|
||||||
```
|
|
||||||
|
|
||||||
### SettingSource
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SettingSource = 'user' | 'project' | 'local';
|
|
||||||
// 'user' → ~/.claude/settings.json
|
|
||||||
// 'project' → .claude/settings.json (version controlled)
|
|
||||||
// 'local' → .claude/settings.local.json (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
When omitted, SDK loads NO filesystem settings (isolation by default). Precedence: local > project > user. Programmatic options always override filesystem settings.
|
|
||||||
|
|
||||||
### AgentDefinition
|
|
||||||
|
|
||||||
Programmatic subagents (NOT agent teams — these are simpler, no inter-agent coordination):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type AgentDefinition = {
|
|
||||||
description: string; // When to use this agent
|
|
||||||
tools?: string[]; // Allowed tools (inherits all if omitted)
|
|
||||||
prompt: string; // Agent's system prompt
|
|
||||||
model?: 'sonnet' | 'opus' | 'haiku' | 'inherit';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### McpServerConfig
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type McpServerConfig =
|
|
||||||
| { type?: 'stdio'; command: string; args?: string[]; env?: Record<string, string> }
|
|
||||||
| { type: 'sse'; url: string; headers?: Record<string, string> }
|
|
||||||
| { type: 'http'; url: string; headers?: Record<string, string> }
|
|
||||||
| { type: 'sdk'; name: string; instance: McpServer } // in-process
|
|
||||||
```
|
|
||||||
|
|
||||||
### SdkBeta
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SdkBeta = 'context-1m-2025-08-07';
|
|
||||||
// Enables 1M token context window for Opus 4.6, Sonnet 4.5, Sonnet 4
|
|
||||||
```
|
|
||||||
|
|
||||||
### CanUseTool
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type CanUseTool = (
|
|
||||||
toolName: string,
|
|
||||||
input: ToolInput,
|
|
||||||
options: { signal: AbortSignal; suggestions?: PermissionUpdate[] }
|
|
||||||
) => Promise<PermissionResult>;
|
|
||||||
|
|
||||||
type PermissionResult =
|
|
||||||
| { behavior: 'allow'; updatedInput: ToolInput; updatedPermissions?: PermissionUpdate[] }
|
|
||||||
| { behavior: 'deny'; message: string; interrupt?: boolean };
|
|
||||||
```
|
|
||||||
|
|
||||||
## SDKMessage Types
|
|
||||||
|
|
||||||
`query()` can yield 16 message types. The official docs show a simplified union of 7, but `sdk.d.ts` has the full set:
|
|
||||||
|
|
||||||
| Type | Subtype | Purpose |
|
|
||||||
|------|---------|---------|
|
|
||||||
| `system` | `init` | Session initialized, contains session_id, tools, model |
|
|
||||||
| `system` | `task_notification` | Background agent completed/failed/stopped |
|
|
||||||
| `system` | `compact_boundary` | Conversation was compacted |
|
|
||||||
| `system` | `status` | Status change (e.g. compacting) |
|
|
||||||
| `system` | `hook_started` | Hook execution started |
|
|
||||||
| `system` | `hook_progress` | Hook progress output |
|
|
||||||
| `system` | `hook_response` | Hook completed |
|
|
||||||
| `system` | `files_persisted` | Files saved |
|
|
||||||
| `assistant` | — | Claude's response (text + tool calls) |
|
|
||||||
| `user` | — | User message (internal) |
|
|
||||||
| `user` (replay) | — | Replayed user message on resume |
|
|
||||||
| `result` | `success` / `error_*` | Final result of a prompt processing round |
|
|
||||||
| `stream_event` | — | Partial streaming (when includePartialMessages) |
|
|
||||||
| `tool_progress` | — | Long-running tool progress |
|
|
||||||
| `auth_status` | — | Authentication state changes |
|
|
||||||
| `tool_use_summary` | — | Summary of preceding tool uses |
|
|
||||||
|
|
||||||
### SDKTaskNotificationMessage (sdk.d.ts:1507)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SDKTaskNotificationMessage = {
|
|
||||||
type: 'system';
|
|
||||||
subtype: 'task_notification';
|
|
||||||
task_id: string;
|
|
||||||
status: 'completed' | 'failed' | 'stopped';
|
|
||||||
output_file: string;
|
|
||||||
summary: string;
|
|
||||||
uuid: UUID;
|
|
||||||
session_id: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### SDKResultMessage (sdk.d.ts:1375)
|
|
||||||
|
|
||||||
Two variants with shared fields:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Shared fields on both variants:
|
|
||||||
// uuid, session_id, duration_ms, duration_api_ms, is_error, num_turns,
|
|
||||||
// total_cost_usd, usage: NonNullableUsage, modelUsage, permission_denials
|
|
||||||
|
|
||||||
// Success:
|
|
||||||
type SDKResultSuccess = {
|
|
||||||
type: 'result';
|
|
||||||
subtype: 'success';
|
|
||||||
result: string;
|
|
||||||
structured_output?: unknown;
|
|
||||||
// ...shared fields
|
|
||||||
};
|
|
||||||
|
|
||||||
// Error:
|
|
||||||
type SDKResultError = {
|
|
||||||
type: 'result';
|
|
||||||
subtype: 'error_during_execution' | 'error_max_turns' | 'error_max_budget_usd' | 'error_max_structured_output_retries';
|
|
||||||
errors: string[];
|
|
||||||
// ...shared fields
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Useful fields on result: `total_cost_usd`, `duration_ms`, `num_turns`, `modelUsage` (per-model breakdown with `costUSD`, `inputTokens`, `outputTokens`, `contextWindow`).
|
|
||||||
|
|
||||||
### SDKAssistantMessage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SDKAssistantMessage = {
|
|
||||||
type: 'assistant';
|
|
||||||
uuid: UUID;
|
|
||||||
session_id: string;
|
|
||||||
message: APIAssistantMessage; // From Anthropic SDK
|
|
||||||
parent_tool_use_id: string | null; // Non-null when from subagent
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### SDKSystemMessage (init)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SDKSystemMessage = {
|
|
||||||
type: 'system';
|
|
||||||
subtype: 'init';
|
|
||||||
uuid: UUID;
|
|
||||||
session_id: string;
|
|
||||||
apiKeySource: ApiKeySource;
|
|
||||||
cwd: string;
|
|
||||||
tools: string[];
|
|
||||||
mcp_servers: { name: string; status: string }[];
|
|
||||||
model: string;
|
|
||||||
permissionMode: PermissionMode;
|
|
||||||
slash_commands: string[];
|
|
||||||
output_style: string;
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Turn Behavior: When the Agent Stops vs Continues
|
|
||||||
|
|
||||||
### When the Agent STOPS (no more API calls)
|
|
||||||
|
|
||||||
**1. No tool_use blocks in response (THE PRIMARY CASE)**
|
|
||||||
|
|
||||||
Claude responded with text only — it decided it has completed the task. The API's `stop_reason` will be `"end_turn"`. The SDK does NOT make this decision — it's entirely driven by Claude's model output.
|
|
||||||
|
|
||||||
**2. Max turns exceeded** — Results in `SDKResultError` with `subtype: "error_max_turns"`.
|
|
||||||
|
|
||||||
**3. Abort signal** — User interruption via `abortController`.
|
|
||||||
|
|
||||||
**4. Budget exceeded** — `totalCost >= maxBudgetUsd` → `"error_max_budget_usd"`.
|
|
||||||
|
|
||||||
**5. Stop hook prevents continuation** — Hook returns `{preventContinuation: true}`.
|
|
||||||
|
|
||||||
### When the Agent CONTINUES (makes another API call)
|
|
||||||
|
|
||||||
**1. Response contains tool_use blocks (THE PRIMARY CASE)** — Execute tools, increment turnCount, recurse into EZ.
|
|
||||||
|
|
||||||
**2. max_output_tokens recovery** — Up to 3 retries with a "break your work into smaller pieces" context message.
|
|
||||||
|
|
||||||
**3. Stop hook blocking errors** — Errors fed back as context messages, loop continues.
|
|
||||||
|
|
||||||
**4. Model fallback** — Retry with fallback model (one-time).
|
|
||||||
|
|
||||||
### Decision Table
|
|
||||||
|
|
||||||
| Condition | Action | Result Type |
|
|
||||||
|-----------|--------|-------------|
|
|
||||||
| Response has `tool_use` blocks | Execute tools, recurse into `EZ` | continues |
|
|
||||||
| Response has NO `tool_use` blocks | Run stop hooks, return | `success` |
|
|
||||||
| `turnCount > maxTurns` | Yield max_turns_reached | `error_max_turns` |
|
|
||||||
| `totalCost >= maxBudgetUsd` | Yield budget error | `error_max_budget_usd` |
|
|
||||||
| `abortController.signal.aborted` | Yield interrupted msg | depends on context |
|
|
||||||
| `stop_reason === "max_tokens"` (output) | Retry up to 3x with recovery prompt | continues |
|
|
||||||
| Stop hook `preventContinuation` | Return immediately | `success` |
|
|
||||||
| Stop hook blocking error | Feed error back, recurse | continues |
|
|
||||||
| Model fallback error | Retry with fallback model (one-time) | continues |
|
|
||||||
|
|
||||||
## Subagent Execution Modes
|
|
||||||
|
|
||||||
### Case 1: Synchronous Subagents (`run_in_background: false`) — BLOCKS
|
|
||||||
|
|
||||||
Parent agent calls Task tool → `VR()` runs `EZ()` for subagent → parent waits for full result → tool result returned to parent → parent continues.
|
|
||||||
|
|
||||||
The subagent runs the full recursive EZ loop. The parent's tool execution is suspended via `await`. There is a mid-execution "promotion" mechanism: a synchronous subagent can be promoted to background via `Promise.race()` against a `backgroundSignal` promise.
|
|
||||||
|
|
||||||
### Case 2: Background Tasks (`run_in_background: true`) — DOES NOT WAIT
|
|
||||||
|
|
||||||
- **Bash tool:** Command spawned, tool returns immediately with empty result + `backgroundTaskId`
|
|
||||||
- **Task/Agent tool:** Subagent launched in fire-and-forget wrapper (`g01()`), tool returns immediately with `status: "async_launched"` + `outputFile` path
|
|
||||||
|
|
||||||
Zero "wait for background tasks" logic before emitting the `type: "result"` message. When a background task completes, an `SDKTaskNotificationMessage` is emitted separately.
|
|
||||||
|
|
||||||
### Case 3: Agent Teams (TeammateTool / SendMessage) — RESULT FIRST, THEN POLLING
|
|
||||||
|
|
||||||
The team leader runs its normal EZ loop, which includes spawning teammates. When the leader's EZ loop finishes, `type: "result"` is emitted. Then the leader enters a post-result polling loop:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
while (true) {
|
|
||||||
// Check if no active teammates AND no running tasks → break
|
|
||||||
// Check for unread messages from teammates → re-inject as new prompt, restart EZ loop
|
|
||||||
// If stdin closed with active teammates → inject shutdown prompt
|
|
||||||
// Poll every 500ms
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
From the SDK consumer's perspective: you receive the initial `type: "result"`, but the AsyncGenerator may continue yielding more messages as the team leader processes teammate responses and re-enters the agent loop. The generator only truly finishes when all teammates have shut down.
|
|
||||||
|
|
||||||
## The isSingleUserTurn Problem
|
|
||||||
|
|
||||||
From sdk.mjs:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
QK = typeof X === "string" // isSingleUserTurn = true when prompt is a string
|
|
||||||
```
|
|
||||||
|
|
||||||
When `isSingleUserTurn` is true and the first `result` message arrives:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
if (this.isSingleUserTurn) {
|
|
||||||
this.transport.endInput(); // closes stdin to CLI
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This triggers a chain reaction:
|
|
||||||
|
|
||||||
1. SDK closes CLI stdin
|
|
||||||
2. CLI detects stdin close
|
|
||||||
3. Polling loop sees `D = true` (stdin closed) with active teammates
|
|
||||||
4. Injects shutdown prompt → leader sends `shutdown_request` to all teammates
|
|
||||||
5. **Teammates get killed mid-research**
|
|
||||||
|
|
||||||
The shutdown prompt (found via `BGq` variable in minified cli.js):
|
|
||||||
|
|
||||||
```
|
|
||||||
You are running in non-interactive mode and cannot return a response
|
|
||||||
to the user until your team is shut down.
|
|
||||||
|
|
||||||
You MUST shut down your team before preparing your final response:
|
|
||||||
1. Use requestShutdown to ask each team member to shut down gracefully
|
|
||||||
2. Wait for shutdown approvals
|
|
||||||
3. Use the cleanup operation to clean up the team
|
|
||||||
4. Only then provide your final response to the user
|
|
||||||
```
|
|
||||||
|
|
||||||
### The practical problem
|
|
||||||
|
|
||||||
With V1 `query()` + string prompt + agent teams:
|
|
||||||
|
|
||||||
1. Leader spawns teammates, they start researching
|
|
||||||
2. Leader's EZ loop ends ("I've dispatched the team, they're working on it")
|
|
||||||
3. `type: "result"` emitted
|
|
||||||
4. SDK sees `isSingleUserTurn = true` → closes stdin immediately
|
|
||||||
5. Polling loop detects stdin closed + active teammates → injects shutdown prompt
|
|
||||||
6. Leader sends `shutdown_request` to all teammates
|
|
||||||
7. **Teammates could be 10 seconds into a 5-minute research task and they get told to stop**
|
|
||||||
|
|
||||||
## The Fix: Streaming Input Mode
|
|
||||||
|
|
||||||
Instead of passing a string prompt (which sets `isSingleUserTurn = true`), pass an `AsyncIterable<SDKUserMessage>`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Before (broken for agent teams):
|
|
||||||
query({ prompt: "do something" })
|
|
||||||
|
|
||||||
// After (keeps CLI alive):
|
|
||||||
query({ prompt: asyncIterableOfMessages })
|
|
||||||
```
|
|
||||||
|
|
||||||
When prompt is an `AsyncIterable`:
|
|
||||||
- `isSingleUserTurn = false`
|
|
||||||
- SDK does NOT close stdin after first result
|
|
||||||
- CLI stays alive, continues processing
|
|
||||||
- Background agents keep running
|
|
||||||
- `task_notification` messages flow through the iterator
|
|
||||||
- We control when to end the iterable
|
|
||||||
|
|
||||||
### Additional Benefit: Streaming New Messages
|
|
||||||
|
|
||||||
With the async iterable approach, we can push new incoming WhatsApp messages into the iterable while the agent is still working. Instead of queuing messages until the container exits and spawning a new container, we stream them directly into the running session.
|
|
||||||
|
|
||||||
### Intended Lifecycle with Agent Teams
|
|
||||||
|
|
||||||
With the async iterable fix (`isSingleUserTurn = false`), stdin stays open so the CLI never hits the teammate check or shutdown prompt injection:
|
|
||||||
|
|
||||||
```
|
|
||||||
1. system/init → session initialized
|
|
||||||
2. assistant/user → Claude reasoning, tool calls, tool results
|
|
||||||
3. ... → more assistant/user turns (spawning subagents, etc.)
|
|
||||||
4. result #1 → lead agent's first response (capture)
|
|
||||||
5. task_notification(s) → background agents complete/fail/stop
|
|
||||||
6. assistant/user → lead agent continues (processing subagent results)
|
|
||||||
7. result #2 → lead agent's follow-up response (capture)
|
|
||||||
8. [iterator done] → CLI closed stdout, all done
|
|
||||||
```
|
|
||||||
|
|
||||||
All results are meaningful — capture every one, not just the first.
|
|
||||||
|
|
||||||
## V1 vs V2 API
|
|
||||||
|
|
||||||
### V1: `query()` — One-shot async generator
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const q = query({ prompt: "...", options: {...} });
|
|
||||||
for await (const msg of q) { /* process events */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
- When `prompt` is a string: `isSingleUserTurn = true` → stdin auto-closes after first result
|
|
||||||
- For multi-turn: must pass an `AsyncIterable<SDKUserMessage>` and manage coordination yourself
|
|
||||||
|
|
||||||
### V2: `createSession()` + `send()` / `stream()` — Persistent session
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
await using session = unstable_v2_createSession({ model: "..." });
|
|
||||||
await session.send("first message");
|
|
||||||
for await (const msg of session.stream()) { /* events */ }
|
|
||||||
await session.send("follow-up");
|
|
||||||
for await (const msg of session.stream()) { /* events */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
- `isSingleUserTurn = false` always → stdin stays open
|
|
||||||
- `send()` enqueues into an async queue (`QX`)
|
|
||||||
- `stream()` yields from the same message generator, stopping on `result` type
|
|
||||||
- Multi-turn is natural — just alternate `send()` / `stream()`
|
|
||||||
- V2 does NOT call V1 `query()` internally — both independently create Transport + Query
|
|
||||||
|
|
||||||
### Comparison Table
|
|
||||||
|
|
||||||
| Aspect | V1 | V2 |
|
|
||||||
|--------|----|----|
|
|
||||||
| `isSingleUserTurn` | `true` for string prompt | always `false` |
|
|
||||||
| Multi-turn | Requires managing `AsyncIterable` | Just call `send()`/`stream()` |
|
|
||||||
| stdin lifecycle | Auto-closes after first result | Stays open until `close()` |
|
|
||||||
| Agentic loop | Identical `EZ()` | Identical `EZ()` |
|
|
||||||
| Stop conditions | Same | Same |
|
|
||||||
| Session persistence | Must pass `resume` to new `query()` | Built-in via session object |
|
|
||||||
| API stability | Stable | Unstable preview (`unstable_v2_*` prefix) |
|
|
||||||
|
|
||||||
**Key finding: Zero difference in turn behavior.** Both use the same CLI process, the same `EZ()` recursive generator, and the same decision logic.
|
|
||||||
|
|
||||||
## Hook Events
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type HookEvent =
|
|
||||||
| 'PreToolUse' // Before tool execution
|
|
||||||
| 'PostToolUse' // After successful tool execution
|
|
||||||
| 'PostToolUseFailure' // After failed tool execution
|
|
||||||
| 'Notification' // Notification messages
|
|
||||||
| 'UserPromptSubmit' // User prompt submitted
|
|
||||||
| 'SessionStart' // Session started (startup/resume/clear/compact)
|
|
||||||
| 'SessionEnd' // Session ended
|
|
||||||
| 'Stop' // Agent stopping
|
|
||||||
| 'SubagentStart' // Subagent spawned
|
|
||||||
| 'SubagentStop' // Subagent stopped
|
|
||||||
| 'PreCompact' // Before conversation compaction
|
|
||||||
| 'PermissionRequest'; // Permission being requested
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Configuration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface HookCallbackMatcher {
|
|
||||||
matcher?: string; // Optional tool name matcher
|
|
||||||
hooks: HookCallback[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type HookCallback = (
|
|
||||||
input: HookInput,
|
|
||||||
toolUseID: string | undefined,
|
|
||||||
options: { signal: AbortSignal }
|
|
||||||
) => Promise<HookJSONOutput>;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Return Values
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type HookJSONOutput = AsyncHookJSONOutput | SyncHookJSONOutput;
|
|
||||||
|
|
||||||
type AsyncHookJSONOutput = { async: true; asyncTimeout?: number };
|
|
||||||
|
|
||||||
type SyncHookJSONOutput = {
|
|
||||||
continue?: boolean;
|
|
||||||
suppressOutput?: boolean;
|
|
||||||
stopReason?: string;
|
|
||||||
decision?: 'approve' | 'block';
|
|
||||||
systemMessage?: string;
|
|
||||||
reason?: string;
|
|
||||||
hookSpecificOutput?:
|
|
||||||
| { hookEventName: 'PreToolUse'; permissionDecision?: 'allow' | 'deny' | 'ask'; updatedInput?: Record<string, unknown> }
|
|
||||||
| { hookEventName: 'UserPromptSubmit'; additionalContext?: string }
|
|
||||||
| { hookEventName: 'SessionStart'; additionalContext?: string }
|
|
||||||
| { hookEventName: 'PostToolUse'; additionalContext?: string };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### Subagent Hooks (from sdk.d.ts)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SubagentStartHookInput = BaseHookInput & {
|
|
||||||
hook_event_name: 'SubagentStart';
|
|
||||||
agent_id: string;
|
|
||||||
agent_type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubagentStopHookInput = BaseHookInput & {
|
|
||||||
hook_event_name: 'SubagentStop';
|
|
||||||
stop_hook_active: boolean;
|
|
||||||
agent_id: string;
|
|
||||||
agent_transcript_path: string;
|
|
||||||
agent_type: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// BaseHookInput = { session_id, transcript_path, cwd, permission_mode? }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Query Interface Methods
|
|
||||||
|
|
||||||
The `Query` object (sdk.d.ts:931). Official docs list these public methods:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Query extends AsyncGenerator<SDKMessage, void> {
|
|
||||||
interrupt(): Promise<void>; // Stop current execution (streaming input mode only)
|
|
||||||
rewindFiles(userMessageUuid: string): Promise<void>; // Restore files to state at message (needs enableFileCheckpointing)
|
|
||||||
setPermissionMode(mode: PermissionMode): Promise<void>; // Change permissions (streaming input mode only)
|
|
||||||
setModel(model?: string): Promise<void>; // Change model (streaming input mode only)
|
|
||||||
setMaxThinkingTokens(max: number | null): Promise<void>; // Change thinking tokens (streaming input mode only)
|
|
||||||
supportedCommands(): Promise<SlashCommand[]>; // Available slash commands
|
|
||||||
supportedModels(): Promise<ModelInfo[]>; // Available models
|
|
||||||
mcpServerStatus(): Promise<McpServerStatus[]>; // MCP server connection status
|
|
||||||
accountInfo(): Promise<AccountInfo>; // Authenticated user info
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Found in sdk.d.ts but NOT in official docs (may be internal):
|
|
||||||
- `streamInput(stream)` — stream additional user messages
|
|
||||||
- `close()` — forcefully end the query
|
|
||||||
- `setMcpServers(servers)` — dynamically add/remove MCP servers
|
|
||||||
|
|
||||||
## Sandbox Configuration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
type SandboxSettings = {
|
|
||||||
enabled?: boolean;
|
|
||||||
autoAllowBashIfSandboxed?: boolean;
|
|
||||||
excludedCommands?: string[];
|
|
||||||
allowUnsandboxedCommands?: boolean;
|
|
||||||
network?: {
|
|
||||||
allowLocalBinding?: boolean;
|
|
||||||
allowUnixSockets?: string[];
|
|
||||||
allowAllUnixSockets?: boolean;
|
|
||||||
httpProxyPort?: number;
|
|
||||||
socksProxyPort?: number;
|
|
||||||
};
|
|
||||||
ignoreViolations?: {
|
|
||||||
file?: string[];
|
|
||||||
network?: string[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
When `allowUnsandboxedCommands` is true, the model can set `dangerouslyDisableSandbox: true` in Bash tool input, which falls back to the `canUseTool` permission handler.
|
|
||||||
|
|
||||||
## MCP Server Helpers
|
|
||||||
|
|
||||||
### tool()
|
|
||||||
|
|
||||||
Creates type-safe MCP tool definitions with Zod schemas:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function tool<Schema extends ZodRawShape>(
|
|
||||||
name: string,
|
|
||||||
description: string,
|
|
||||||
inputSchema: Schema,
|
|
||||||
handler: (args: z.infer<ZodObject<Schema>>, extra: unknown) => Promise<CallToolResult>
|
|
||||||
): SdkMcpToolDefinition<Schema>
|
|
||||||
```
|
|
||||||
|
|
||||||
### createSdkMcpServer()
|
|
||||||
|
|
||||||
Creates an in-process MCP server (we use stdio instead for subagent inheritance):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
function createSdkMcpServer(options: {
|
|
||||||
name: string;
|
|
||||||
version?: string;
|
|
||||||
tools?: Array<SdkMcpToolDefinition<any>>;
|
|
||||||
}): McpSdkServerConfigWithInstance
|
|
||||||
```
|
|
||||||
|
|
||||||
## Internals Reference
|
|
||||||
|
|
||||||
### Key minified identifiers (sdk.mjs)
|
|
||||||
|
|
||||||
| Minified | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `s_` | V1 `query()` export |
|
|
||||||
| `e_` | `unstable_v2_createSession` |
|
|
||||||
| `Xx` | `unstable_v2_resumeSession` |
|
|
||||||
| `Qx` | `unstable_v2_prompt` |
|
|
||||||
| `U9` | V2 Session class (`send`/`stream`/`close`) |
|
|
||||||
| `XX` | ProcessTransport (spawns cli.js) |
|
|
||||||
| `$X` | Query class (JSON-line routing, async iterable) |
|
|
||||||
| `QX` | AsyncQueue (input stream buffer) |
|
|
||||||
|
|
||||||
### Key minified identifiers (cli.js)
|
|
||||||
|
|
||||||
| Minified | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `EZ` | Core recursive agentic loop (async generator) |
|
|
||||||
| `_t4` | Stop hook handler (runs when no tool_use blocks) |
|
|
||||||
| `PU1` | Streaming tool executor (parallel during API response) |
|
|
||||||
| `TP6` | Standard tool executor (after API response) |
|
|
||||||
| `GU1` | Individual tool executor |
|
|
||||||
| `lTq` | SDK session runner (calls EZ directly) |
|
|
||||||
| `bd1` | stdin reader (JSON-lines from transport) |
|
|
||||||
| `mW1` | Anthropic API streaming caller |
|
|
||||||
|
|
||||||
## Key Files
|
|
||||||
|
|
||||||
- `sdk.d.ts` — All type definitions (1777 lines)
|
|
||||||
- `sdk-tools.d.ts` — Tool input schemas
|
|
||||||
- `sdk.mjs` — SDK runtime (minified, 376KB)
|
|
||||||
- `cli.js` — CLI executable (minified, runs as subprocess)
|
|
||||||
122
docs/SECURITY.md
122
docs/SECURITY.md
@@ -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 │
|
|
||||||
└──────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
785
docs/SPEC.md
785
docs/SPEC.md
@@ -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
@@ -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.
|
|
||||||
@@ -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`.
|
|
||||||
@@ -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
|
Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`.
|
||||||
- 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**.
|
|
||||||
|
|||||||
@@ -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
|
Keep platform-wide operating rules in `prompts/claude-platform.md`.
|
||||||
|
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"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",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
|
|||||||
20
prompts/claude-paired-room.md
Normal file
20
prompts/claude-paired-room.md
Normal 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.
|
||||||
46
prompts/claude-platform.md
Normal file
46
prompts/claude-platform.md
Normal 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
|
||||||
18
prompts/codex-paired-room.md
Normal file
18
prompts/codex-paired-room.md
Normal 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
28
prompts/codex-platform.md
Normal 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
|
||||||
@@ -724,7 +724,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -929,7 +928,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz",
|
||||||
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
|
"integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
@@ -1509,7 +1507,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* NanoClaw Agent Runner
|
* 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:
|
* Input protocol:
|
||||||
* Stdin: Full ContainerInput JSON (read until EOF, like before)
|
* Stdin: Full RunnerInput JSON (read until EOF, like before)
|
||||||
* IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
|
* IPC: Follow-up messages written as JSON files to $NANOCLAW_IPC_DIR/input/
|
||||||
* Files: {type:"message", text:"..."}.json — polled and consumed
|
* Files: {type:"message", text:"..."}.json — polled and consumed
|
||||||
* Sentinel: /workspace/ipc/input/_close — signals session end
|
* Sentinel: /workspace/ipc/input/_close — signals session end
|
||||||
*
|
*
|
||||||
@@ -59,11 +59,9 @@ interface SDKUserMessage {
|
|||||||
session_id: string;
|
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 GROUP_DIR = process.env.NANOCLAW_GROUP_DIR || '/workspace/group';
|
||||||
const IPC_DIR = process.env.NANOCLAW_IPC_DIR || '/workspace/ipc';
|
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)
|
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
|
||||||
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
|
const WORK_DIR = process.env.NANOCLAW_WORK_DIR || '';
|
||||||
|
|
||||||
@@ -450,13 +448,6 @@ async function runQuery(
|
|||||||
let messageCount = 0;
|
let messageCount = 0;
|
||||||
let resultCount = 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
|
// Discover additional directories
|
||||||
const extraDirs: string[] = [];
|
const extraDirs: string[] = [];
|
||||||
|
|
||||||
@@ -465,17 +456,6 @@ async function runQuery(
|
|||||||
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
|
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
|
||||||
extraDirs.push(GROUP_DIR);
|
extraDirs.push(GROUP_DIR);
|
||||||
log(`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`);
|
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) {
|
if (extraDirs.length > 0) {
|
||||||
log(`Additional directories: ${extraDirs.join(', ')}`);
|
log(`Additional directories: ${extraDirs.join(', ')}`);
|
||||||
@@ -507,9 +487,6 @@ async function runQuery(
|
|||||||
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
|
||||||
resume: sessionId,
|
resume: sessionId,
|
||||||
resumeSessionAt: resumeAt,
|
resumeSessionAt: resumeAt,
|
||||||
systemPrompt: globalClaudeMd
|
|
||||||
? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
|
|
||||||
: undefined,
|
|
||||||
allowedTools: [
|
allowedTools: [
|
||||||
'Bash',
|
'Bash',
|
||||||
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
'Read', 'Write', 'Edit', 'Glob', 'Grep',
|
||||||
184
runners/codex-runner/package-lock.json
generated
Normal file
184
runners/codex-runner/package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"@openai/codex-sdk": "^0.115.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
444
runners/codex-runner/src/app-server-client.ts
Normal file
444
runners/codex-runner/src/app-server-client.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
174
runners/codex-runner/src/app-server-state.ts
Normal file
174
runners/codex-runner/src/app-server-state.ts
Normal 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;
|
||||||
|
}
|
||||||
631
runners/codex-runner/src/index.ts
Normal file
631
runners/codex-runner/src/index.ts
Normal 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();
|
||||||
@@ -6,7 +6,7 @@ import Database from 'better-sqlite3';
|
|||||||
/**
|
/**
|
||||||
* Tests for the environment check step.
|
* Tests for the environment check step.
|
||||||
*
|
*
|
||||||
* Verifies: config detection, Docker/AC detection, DB queries.
|
* Verifies: config detection, platform helpers, DB queries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
describe('environment detection', () => {
|
describe('environment detection', () => {
|
||||||
@@ -28,7 +28,7 @@ describe('registered groups DB query', () => {
|
|||||||
folder TEXT NOT NULL UNIQUE,
|
folder TEXT NOT NULL UNIQUE,
|
||||||
trigger_pattern TEXT NOT NULL,
|
trigger_pattern TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
container_config TEXT,
|
agent_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1
|
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 () => {
|
it('commandExists returns boolean', async () => {
|
||||||
const { commandExists } = await import('./platform.js');
|
const { commandExists } = await import('./platform.js');
|
||||||
expect(typeof commandExists('docker')).toBe('boolean');
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|||||||
174
setup/groups.ts
174
setup/groups.ts
@@ -1,10 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Step: groups — Fetch group metadata from messaging platforms, write to DB.
|
* Step: groups — List known Discord groups from the local chat metadata store.
|
||||||
* WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
|
* Discord channel names are discovered at runtime, so the sync path is a no-op.
|
||||||
* Other channels discover group names at runtime — this step auto-skips for them.
|
|
||||||
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
|
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
@@ -28,7 +25,6 @@ function parseArgs(args: string[]): { list: boolean; limit: number } {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function run(args: string[]): Promise<void> {
|
export async function run(args: string[]): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
|
||||||
const { list, limit } = parseArgs(args);
|
const { list, limit } = parseArgs(args);
|
||||||
|
|
||||||
if (list) {
|
if (list) {
|
||||||
@@ -36,7 +32,7 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await syncGroups(projectRoot);
|
await syncGroups();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listGroups(limit: number): Promise<void> {
|
async function listGroups(limit: number): Promise<void> {
|
||||||
@@ -51,9 +47,9 @@ async function listGroups(limit: number): Promise<void> {
|
|||||||
const rows = db
|
const rows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT jid, name FROM chats
|
`SELECT jid, name FROM chats
|
||||||
WHERE jid LIKE '%@g.us' AND jid <> '__group_sync__' AND name <> jid
|
WHERE jid LIKE 'dc:%' AND is_group = 1 AND jid <> '__group_sync__' AND name <> jid
|
||||||
ORDER BY last_message_time DESC
|
ORDER BY last_message_time DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
)
|
)
|
||||||
.all(limit) as Array<{ jid: string; name: string }>;
|
.all(limit) as Array<{ jid: string; name: string }>;
|
||||||
db.close();
|
db.close();
|
||||||
@@ -63,167 +59,33 @@ async function listGroups(limit: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncGroups(projectRoot: string): Promise<void> {
|
async function syncGroups(): 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;
|
|
||||||
const dbPath = path.join(STORE_DIR, 'messages.db');
|
const dbPath = path.join(STORE_DIR, 'messages.db');
|
||||||
|
let groupsInDb = 0;
|
||||||
|
|
||||||
if (fs.existsSync(dbPath)) {
|
if (fs.existsSync(dbPath)) {
|
||||||
try {
|
try {
|
||||||
const db = new Database(dbPath, { readonly: true });
|
const db = new Database(dbPath, { readonly: true });
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.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 };
|
.get() as { count: number };
|
||||||
groupsInDb = row.count;
|
groupsInDb = row.count;
|
||||||
db.close();
|
db.close();
|
||||||
} catch {
|
} catch (err) {
|
||||||
// DB may not exist yet
|
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', {
|
emitStatus('SYNC_GROUPS', {
|
||||||
BUILD: buildOk ? 'success' : 'failed',
|
BUILD: 'skipped',
|
||||||
SYNC: syncOk ? 'success' : 'failed',
|
SYNC: 'skipped',
|
||||||
GROUPS_IN_DB: groupsInDb,
|
GROUPS_IN_DB: groupsInDb,
|
||||||
STATUS: status,
|
REASON: 'discord_runtime_sync',
|
||||||
|
STATUS: 'success',
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (status === 'failed') process.exit(1);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,9 @@ const STEPS: Record<
|
|||||||
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
||||||
> = {
|
> = {
|
||||||
environment: () => import('./environment.js'),
|
environment: () => import('./environment.js'),
|
||||||
runners: () => import('./container.js'),
|
runners: () => import('./runners.js'),
|
||||||
groups: () => import('./groups.js'),
|
groups: () => import('./groups.js'),
|
||||||
register: () => import('./register.js'),
|
register: () => import('./register.js'),
|
||||||
mounts: () => import('./mounts.js'),
|
|
||||||
service: () => import('./service.js'),
|
service: () => import('./service.js'),
|
||||||
verify: () => import('./verify.js'),
|
verify: () => import('./verify.js'),
|
||||||
};
|
};
|
||||||
|
|||||||
115
setup/mounts.ts
115
setup/mounts.ts
@@ -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',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -17,7 +17,7 @@ function createTestDb(): Database.Database {
|
|||||||
folder TEXT NOT NULL UNIQUE,
|
folder TEXT NOT NULL UNIQUE,
|
||||||
trigger_pattern TEXT NOT NULL,
|
trigger_pattern TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
container_config TEXT,
|
agent_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1,
|
requires_trigger INTEGER DEFAULT 1,
|
||||||
is_main INTEGER DEFAULT 0
|
is_main INTEGER DEFAULT 0
|
||||||
)`);
|
)`);
|
||||||
@@ -34,7 +34,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
it('registers a group with parameterized query', () => {
|
it('registers a group with parameterized query', () => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
).run(
|
).run(
|
||||||
'123@g.us',
|
'123@g.us',
|
||||||
@@ -67,7 +67,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
).run(
|
).run(
|
||||||
'456@g.us',
|
'456@g.us',
|
||||||
@@ -92,7 +92,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
).run(maliciousJid, 'Evil', 'evil', '@Andy', '2024-01-01T00:00:00.000Z', 1);
|
||||||
|
|
||||||
@@ -113,10 +113,10 @@ describe('parameterized SQL registration', () => {
|
|||||||
it('handles requiresTrigger=false', () => {
|
it('handles requiresTrigger=false', () => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
).run(
|
).run(
|
||||||
'789@s.whatsapp.net',
|
'dc:789',
|
||||||
'Personal',
|
'Personal',
|
||||||
'main',
|
'main',
|
||||||
'@Andy',
|
'@Andy',
|
||||||
@@ -126,7 +126,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT requires_trigger FROM registered_groups WHERE jid = ?')
|
.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);
|
expect(row.requires_trigger).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -134,12 +134,12 @@ describe('parameterized SQL registration', () => {
|
|||||||
it('stores is_main flag', () => {
|
it('stores is_main flag', () => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
'789@s.whatsapp.net',
|
'dc:789',
|
||||||
'Personal',
|
'Personal',
|
||||||
'whatsapp_main',
|
'discord_main',
|
||||||
'@Andy',
|
'@Andy',
|
||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
0,
|
0,
|
||||||
@@ -148,7 +148,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
|
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
|
.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);
|
expect(row.is_main).toBe(1);
|
||||||
});
|
});
|
||||||
@@ -156,12 +156,12 @@ describe('parameterized SQL registration', () => {
|
|||||||
it('defaults is_main to 0', () => {
|
it('defaults is_main to 0', () => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
).run(
|
).run(
|
||||||
'123@g.us',
|
'123@g.us',
|
||||||
'Some Group',
|
'Some Group',
|
||||||
'whatsapp_some-group',
|
'discord_some-group',
|
||||||
'@Andy',
|
'@Andy',
|
||||||
'2024-01-01T00:00:00.000Z',
|
'2024-01-01T00:00:00.000Z',
|
||||||
1,
|
1,
|
||||||
@@ -177,7 +177,7 @@ describe('parameterized SQL registration', () => {
|
|||||||
it('upserts on conflict', () => {
|
it('upserts on conflict', () => {
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Step: register — Write channel registration config, create group folders.
|
* 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.
|
* Uses parameterized SQL queries to prevent injection.
|
||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -9,7 +9,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import Database from 'better-sqlite3';
|
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 { isValidGroupFolder } from '../src/group-folder.js';
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
@@ -31,7 +31,7 @@ function parseArgs(args: string[]): RegisterArgs {
|
|||||||
name: '',
|
name: '',
|
||||||
trigger: '',
|
trigger: '',
|
||||||
folder: '',
|
folder: '',
|
||||||
channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel
|
channel: 'discord',
|
||||||
requiresTrigger: true,
|
requiresTrigger: true,
|
||||||
isMain: false,
|
isMain: false,
|
||||||
assistantName: 'Andy',
|
assistantName: 'Andy',
|
||||||
@@ -91,10 +91,18 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
process.exit(4);
|
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');
|
logger.info(parsed, 'Registering channel');
|
||||||
|
|
||||||
// Ensure data and store directories exist (store/ may not exist on
|
// Ensure data and store directories exist for fresh installs.
|
||||||
// fresh installs that skip WhatsApp auth, which normally creates it)
|
|
||||||
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
|
||||||
fs.mkdirSync(STORE_DIR, { 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);
|
const db = new Database(dbPath);
|
||||||
// Ensure schema exists
|
// Ensure schema exists
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
|
||||||
jid TEXT PRIMARY KEY,
|
jid TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
folder TEXT NOT NULL UNIQUE,
|
folder TEXT NOT NULL,
|
||||||
trigger_pattern TEXT NOT NULL,
|
trigger_pattern TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
container_config TEXT,
|
agent_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1,
|
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;
|
const isMainInt = parsed.isMain ? 1 : 0;
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT OR REPLACE INTO registered_groups
|
`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, agent_type, work_dir)
|
||||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, NULL)`,
|
||||||
).run(
|
).run(
|
||||||
parsed.jid,
|
parsed.jid,
|
||||||
parsed.name,
|
parsed.name,
|
||||||
@@ -130,6 +156,7 @@ export async function run(args: string[]): Promise<void> {
|
|||||||
timestamp,
|
timestamp,
|
||||||
requiresTriggerInt,
|
requiresTriggerInt,
|
||||||
isMainInt,
|
isMainInt,
|
||||||
|
SERVICE_AGENT_TYPE,
|
||||||
);
|
);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Step: runners — Build agent runners (no container needed).
|
* Step: runners — Build the Claude and Codex runner packages.
|
||||||
* Agents run as direct host processes.
|
|
||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -29,14 +28,14 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
// Verify runner entry points exist
|
// Verify runner entry points exist
|
||||||
const agentRunner = path.join(
|
const agentRunner = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'runners',
|
||||||
'agent-runner',
|
'agent-runner',
|
||||||
'dist',
|
'dist',
|
||||||
'index.js',
|
'index.js',
|
||||||
);
|
);
|
||||||
const codexRunner = path.join(
|
const codexRunner = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'runners',
|
||||||
'codex-runner',
|
'codex-runner',
|
||||||
'dist',
|
'dist',
|
||||||
'index.js',
|
'index.js',
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
*/
|
*/
|
||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
@@ -14,18 +13,12 @@ import Database from 'better-sqlite3';
|
|||||||
import { STORE_DIR } from '../src/config.js';
|
import { STORE_DIR } from '../src/config.js';
|
||||||
import { readEnvFile } from '../src/env.js';
|
import { readEnvFile } from '../src/env.js';
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
import {
|
import { getPlatform, getServiceManager, isRoot } from './platform.js';
|
||||||
getPlatform,
|
|
||||||
getServiceManager,
|
|
||||||
hasSystemd,
|
|
||||||
isRoot,
|
|
||||||
} from './platform.js';
|
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
|
|
||||||
export async function run(_args: string[]): Promise<void> {
|
export async function run(_args: string[]): Promise<void> {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const platform = getPlatform();
|
const platform = getPlatform();
|
||||||
const homeDir = os.homedir();
|
|
||||||
|
|
||||||
logger.info('Starting verification');
|
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)
|
// 3. Check channel auth (detect configured channels by credentials)
|
||||||
const envVars = readEnvFile([
|
const envVars = readEnvFile(['DISCORD_BOT_TOKEN']);
|
||||||
'TELEGRAM_BOT_TOKEN',
|
|
||||||
'SLACK_BOT_TOKEN',
|
|
||||||
'SLACK_APP_TOKEN',
|
|
||||||
'DISCORD_BOT_TOKEN',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const channelAuth: Record<string, string> = {};
|
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) {
|
if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
|
||||||
channelAuth.discord = 'configured';
|
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
|
// Determine overall status
|
||||||
const status =
|
const status =
|
||||||
service === 'running' &&
|
service === 'running' &&
|
||||||
@@ -168,7 +130,6 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
CONFIGURED_CHANNELS: configuredChannels.join(','),
|
CONFIGURED_CHANNELS: configuredChannels.join(','),
|
||||||
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
CHANNEL_AUTH: JSON.stringify(channelAuth),
|
||||||
REGISTERED_GROUPS: registeredGroups,
|
REGISTERED_GROUPS: registeredGroups,
|
||||||
MOUNT_ALLOWLIST: mountAllowlist,
|
|
||||||
STATUS: status,
|
STATUS: status,
|
||||||
LOG: 'logs/setup.log',
|
LOG: 'logs/setup.log',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import {
|
|||||||
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { readEnvFile } from './env.js';
|
import { readEnvFile } from './env.js';
|
||||||
|
import { isPairedRoomJid } from './db.js';
|
||||||
|
import {
|
||||||
|
readPairedRoomPrompt,
|
||||||
|
readPlatformPrompt,
|
||||||
|
} from './platform-prompts.js';
|
||||||
import { RegisteredGroup } from './types.js';
|
import { RegisteredGroup } from './types.js';
|
||||||
|
|
||||||
// Sentinel markers for robust output parsing (must match agent-runner)
|
// Sentinel markers for robust output parsing (must match agent-runner)
|
||||||
@@ -29,6 +34,7 @@ export interface AgentInput {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
groupFolder: string;
|
groupFolder: string;
|
||||||
chatJid: string;
|
chatJid: string;
|
||||||
|
runId?: string;
|
||||||
isMain: boolean;
|
isMain: boolean;
|
||||||
isScheduledTask?: boolean;
|
isScheduledTask?: boolean;
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
@@ -49,6 +55,7 @@ export interface AgentOutput {
|
|||||||
function prepareGroupEnvironment(
|
function prepareGroupEnvironment(
|
||||||
group: RegisteredGroup,
|
group: RegisteredGroup,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
|
chatJid: string,
|
||||||
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
|
): { env: Record<string, string>; groupDir: string; runnerDir: string } {
|
||||||
const projectRoot = process.cwd();
|
const projectRoot = process.cwd();
|
||||||
const groupDir = resolveGroupFolderPath(group.folder);
|
const groupDir = resolveGroupFolderPath(group.folder);
|
||||||
@@ -82,14 +89,14 @@ function prepareGroupEnvironment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sync skills into each group's .claude/ session dir
|
// 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
|
const workDirClaude = group.workDir
|
||||||
? path.join(group.workDir, '.claude')
|
? path.join(group.workDir, '.claude')
|
||||||
: null;
|
: null;
|
||||||
const skillSources = [
|
const skillSources = [
|
||||||
path.join(os.homedir(), '.claude', 'skills'),
|
path.join(os.homedir(), '.claude', 'skills'),
|
||||||
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []),
|
||||||
path.join(projectRoot, 'container', 'skills'),
|
path.join(projectRoot, 'runners', 'skills'),
|
||||||
];
|
];
|
||||||
const skillsDst = path.join(groupSessionsDir, 'skills');
|
const skillsDst = path.join(groupSessionsDir, 'skills');
|
||||||
for (const src of skillSources) {
|
for (const src of skillSources) {
|
||||||
@@ -114,21 +121,36 @@ function prepareGroupEnvironment(
|
|||||||
|
|
||||||
// Global memory directory (for non-main groups)
|
// Global memory directory (for non-main groups)
|
||||||
const globalDir = path.join(GROUPS_DIR, 'global');
|
const globalDir = path.join(GROUPS_DIR, 'global');
|
||||||
|
const globalClaudeMdPath = path.join(globalDir, 'CLAUDE.md');
|
||||||
|
const isPairedRoom = isPairedRoomJid(chatJid);
|
||||||
|
|
||||||
// Additional mount directories (validated)
|
const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot);
|
||||||
const extraDirs: string[] = [];
|
const claudePairedRoomPrompt = isPairedRoom
|
||||||
if (group.agentConfig?.additionalMounts) {
|
? readPairedRoomPrompt('claude-code', projectRoot)
|
||||||
for (const mount of group.agentConfig.additionalMounts) {
|
: undefined;
|
||||||
if (fs.existsSync(mount.hostPath)) {
|
const globalClaudeMemory =
|
||||||
extraDirs.push(mount.hostPath);
|
!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
|
// Determine runner directory
|
||||||
const agentType = group.agentType || 'claude-code';
|
const agentType = group.agentType || 'claude-code';
|
||||||
const runnerDirName = agentType === 'codex' ? 'codex-runner' : 'agent-runner';
|
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
|
// Build environment variables for the runner process
|
||||||
const envVars = readEnvFile([
|
const envVars = readEnvFile([
|
||||||
@@ -170,7 +192,6 @@ function prepareGroupEnvironment(
|
|||||||
NANOCLAW_GROUP_DIR: groupDir,
|
NANOCLAW_GROUP_DIR: groupDir,
|
||||||
NANOCLAW_IPC_DIR: groupIpcDir,
|
NANOCLAW_IPC_DIR: groupIpcDir,
|
||||||
NANOCLAW_GLOBAL_DIR: globalDir,
|
NANOCLAW_GLOBAL_DIR: globalDir,
|
||||||
NANOCLAW_EXTRA_DIR: extraDirs.length > 0 ? extraDirs[0] : '',
|
|
||||||
// Working directory override (agent uses this as cwd instead of group dir)
|
// Working directory override (agent uses this as cwd instead of group dir)
|
||||||
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
|
...(group.workDir ? { NANOCLAW_WORK_DIR: group.workDir } : {}),
|
||||||
// MCP server context
|
// MCP server context
|
||||||
@@ -214,18 +235,32 @@ function prepareGroupEnvironment(
|
|||||||
const authSrc = path.join(hostCodexDir, 'auth.json');
|
const authSrc = path.join(hostCodexDir, 'auth.json');
|
||||||
const authDst = path.join(sessionCodexDir, 'auth.json');
|
const authDst = path.join(sessionCodexDir, 'auth.json');
|
||||||
if (fs.existsSync(authSrc)) fs.copyFileSync(authSrc, authDst);
|
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 src = path.join(hostCodexDir, file);
|
||||||
const dst = path.join(sessionCodexDir, file);
|
const dst = path.join(sessionCodexDir, file);
|
||||||
if (fs.existsSync(src)) {
|
if (fs.existsSync(src)) {
|
||||||
fs.copyFileSync(src, dst);
|
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
|
// 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 = [
|
const codexSkillSources = [
|
||||||
path.join(os.homedir(), '.claude', 'skills'),
|
path.join(os.homedir(), '.claude', 'skills'),
|
||||||
path.join(projectRoot, 'container', 'skills'),
|
path.join(projectRoot, 'runners', 'skills'),
|
||||||
];
|
];
|
||||||
const codexSkillsDst = path.join(sessionCodexDir, 'skills');
|
const codexSkillsDst = path.join(sessionCodexDir, 'skills');
|
||||||
for (const src of codexSkillSources) {
|
for (const src of codexSkillSources) {
|
||||||
@@ -245,7 +280,7 @@ function prepareGroupEnvironment(
|
|||||||
// Inject nanoclaw MCP server into Codex config.toml
|
// Inject nanoclaw MCP server into Codex config.toml
|
||||||
const mcpServerPath = path.join(
|
const mcpServerPath = path.join(
|
||||||
projectRoot,
|
projectRoot,
|
||||||
'container',
|
'runners',
|
||||||
'agent-runner',
|
'agent-runner',
|
||||||
'dist',
|
'dist',
|
||||||
'ipc-mcp-stdio.js',
|
'ipc-mcp-stdio.js',
|
||||||
@@ -323,17 +358,22 @@ export async function runAgentProcess(
|
|||||||
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
const { env, groupDir, runnerDir } = prepareGroupEnvironment(
|
||||||
group,
|
group,
|
||||||
input.isMain,
|
input.isMain,
|
||||||
|
input.chatJid,
|
||||||
);
|
);
|
||||||
|
if (input.runId) {
|
||||||
|
env.NANOCLAW_RUN_ID = input.runId;
|
||||||
|
}
|
||||||
|
|
||||||
const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
|
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
|
// Check if runner is built
|
||||||
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
const distEntry = path.join(runnerDir, 'dist', 'index.js');
|
||||||
if (!fs.existsSync(distEntry)) {
|
if (!fs.existsSync(distEntry)) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ runnerDir },
|
{ runnerDir, chatJid: input.chatJid, runId: input.runId },
|
||||||
'Runner not built. Run: cd container/agent-runner && npm install && npm run build',
|
'Runner not built. Run: cd runners/agent-runner && npm install && npm run build',
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
@@ -345,6 +385,9 @@ export async function runAgentProcess(
|
|||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
group: group.name,
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
groupFolder: input.groupFolder,
|
||||||
|
runId: input.runId,
|
||||||
processName,
|
processName,
|
||||||
agentType: group.agentType || 'claude-code',
|
agentType: group.agentType || 'claude-code',
|
||||||
isMain: input.isMain,
|
isMain: input.isMain,
|
||||||
@@ -386,7 +429,12 @@ export async function runAgentProcess(
|
|||||||
stdout += chunk.slice(0, remaining);
|
stdout += chunk.slice(0, remaining);
|
||||||
stdoutTruncated = true;
|
stdoutTruncated = true;
|
||||||
logger.warn(
|
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',
|
'Agent stdout truncated due to size limit',
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -416,7 +464,12 @@ export async function runAgentProcess(
|
|||||||
outputChain = outputChain.then(() => onOutput(parsed));
|
outputChain = outputChain.then(() => onOutput(parsed));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: group.name, error: err },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
error: err,
|
||||||
|
},
|
||||||
'Failed to parse streamed output chunk',
|
'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 timedOut = false;
|
||||||
let hadStreamingOutput = false;
|
let hadStreamingOutput = false;
|
||||||
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
|
const configTimeout = group.agentConfig?.timeout || AGENT_TIMEOUT;
|
||||||
@@ -448,7 +485,12 @@ export async function runAgentProcess(
|
|||||||
const killOnTimeout = () => {
|
const killOnTimeout = () => {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, processName },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
processName,
|
||||||
|
},
|
||||||
'Agent timeout, sending SIGTERM',
|
'Agent timeout, sending SIGTERM',
|
||||||
);
|
);
|
||||||
proc.kill('SIGTERM');
|
proc.kill('SIGTERM');
|
||||||
@@ -465,6 +507,35 @@ export async function runAgentProcess(
|
|||||||
timeout = setTimeout(killOnTimeout, timeoutMs);
|
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) => {
|
proc.on('close', (code) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
const duration = Date.now() - startTime;
|
const duration = Date.now() - startTime;
|
||||||
@@ -472,11 +543,13 @@ export async function runAgentProcess(
|
|||||||
if (timedOut) {
|
if (timedOut) {
|
||||||
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(logsDir, `agent-${ts}.log`),
|
path.join(logsDir, `agent-${input.runId || 'adhoc'}-${ts}.log`),
|
||||||
[
|
[
|
||||||
`=== Agent Run Log (TIMEOUT) ===`,
|
`=== Agent Run Log (TIMEOUT) ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
|
`ChatJid: ${input.chatJid}`,
|
||||||
|
`RunId: ${input.runId || 'n/a'}`,
|
||||||
`Process: ${processName}`,
|
`Process: ${processName}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
`Exit Code: ${code}`,
|
`Exit Code: ${code}`,
|
||||||
@@ -486,7 +559,14 @@ export async function runAgentProcess(
|
|||||||
|
|
||||||
if (hadStreamingOutput) {
|
if (hadStreamingOutput) {
|
||||||
logger.info(
|
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)',
|
'Agent timed out after output (idle cleanup)',
|
||||||
);
|
);
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
@@ -504,7 +584,10 @@ export async function runAgentProcess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
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 =
|
const isVerbose =
|
||||||
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
|
||||||
|
|
||||||
@@ -512,6 +595,9 @@ export async function runAgentProcess(
|
|||||||
`=== Agent Run Log ===`,
|
`=== Agent Run Log ===`,
|
||||||
`Timestamp: ${new Date().toISOString()}`,
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
`Group: ${group.name}`,
|
`Group: ${group.name}`,
|
||||||
|
`ChatJid: ${input.chatJid}`,
|
||||||
|
`GroupFolder: ${input.groupFolder}`,
|
||||||
|
`RunId: ${input.runId || 'n/a'}`,
|
||||||
`IsMain: ${input.isMain}`,
|
`IsMain: ${input.isMain}`,
|
||||||
`AgentType: ${group.agentType || 'claude-code'}`,
|
`AgentType: ${group.agentType || 'claude-code'}`,
|
||||||
`Duration: ${duration}ms`,
|
`Duration: ${duration}ms`,
|
||||||
@@ -542,7 +628,14 @@ export async function runAgentProcess(
|
|||||||
|
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, code, duration, logFile },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
code,
|
||||||
|
duration,
|
||||||
|
logFile,
|
||||||
|
},
|
||||||
'Agent exited with error',
|
'Agent exited with error',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -556,7 +649,13 @@ export async function runAgentProcess(
|
|||||||
if (onOutput) {
|
if (onOutput) {
|
||||||
outputChain.then(() => {
|
outputChain.then(() => {
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, duration, newSessionId },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
duration,
|
||||||
|
newSessionId,
|
||||||
|
},
|
||||||
'Agent completed (streaming mode)',
|
'Agent completed (streaming mode)',
|
||||||
);
|
);
|
||||||
resolve({ status: 'success', result: null, newSessionId });
|
resolve({ status: 'success', result: null, newSessionId });
|
||||||
@@ -579,13 +678,24 @@ export async function runAgentProcess(
|
|||||||
}
|
}
|
||||||
const output: AgentOutput = JSON.parse(jsonLine);
|
const output: AgentOutput = JSON.parse(jsonLine);
|
||||||
logger.info(
|
logger.info(
|
||||||
{ group: group.name, duration, status: output.status },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
duration,
|
||||||
|
status: output.status,
|
||||||
|
},
|
||||||
'Agent completed',
|
'Agent completed',
|
||||||
);
|
);
|
||||||
resolve(output);
|
resolve(output);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, error: err },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
error: err,
|
||||||
|
},
|
||||||
'Failed to parse agent output',
|
'Failed to parse agent output',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -599,7 +709,13 @@ export async function runAgentProcess(
|
|||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
logger.error(
|
logger.error(
|
||||||
{ group: group.name, processName, error: err },
|
{
|
||||||
|
group: group.name,
|
||||||
|
chatJid: input.chatJid,
|
||||||
|
runId: input.runId,
|
||||||
|
processName,
|
||||||
|
error: err,
|
||||||
|
},
|
||||||
'Agent spawn error',
|
'Agent spawn error',
|
||||||
);
|
);
|
||||||
resolve({
|
resolve({
|
||||||
@@ -644,7 +760,7 @@ export function writeGroupsSnapshot(
|
|||||||
groupFolder: string,
|
groupFolder: string,
|
||||||
isMain: boolean,
|
isMain: boolean,
|
||||||
groups: AvailableGroup[],
|
groups: AvailableGroup[],
|
||||||
registeredJids: Set<string>,
|
_registeredJids?: Set<string>,
|
||||||
): void {
|
): void {
|
||||||
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
const groupIpcDir = resolveGroupIpcPath(groupFolder);
|
||||||
fs.mkdirSync(groupIpcDir, { recursive: true });
|
fs.mkdirSync(groupIpcDir, { recursive: true });
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
// Channel self-registration barrel file.
|
// Channel self-registration barrel file.
|
||||||
// Each import triggers the channel module's registerChannel() call.
|
// Each import triggers the channel module's registerChannel() call.
|
||||||
|
|
||||||
// discord
|
|
||||||
import './discord.js';
|
import './discord.js';
|
||||||
|
|
||||||
// gmail
|
|
||||||
|
|
||||||
// slack
|
|
||||||
|
|
||||||
// telegram
|
|
||||||
|
|
||||||
// whatsapp
|
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import type { AgentType } from './types.js';
|
||||||
import { readEnvFile } from './env.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 =
|
export const ASSISTANT_NAME =
|
||||||
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy';
|
||||||
export const ASSISTANT_HAS_OWN_NUMBER =
|
export const ASSISTANT_HAS_OWN_NUMBER =
|
||||||
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
|
(process.env.ASSISTANT_HAS_OWN_NUMBER ||
|
||||||
envConfig.ASSISTANT_HAS_OWN_NUMBER) === 'true';
|
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 POLL_INTERVAL = 2000;
|
||||||
export const SCHEDULER_POLL_INTERVAL = 60000;
|
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_CHANNEL_ID = process.env.STATUS_CHANNEL_ID || '';
|
||||||
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
export const STATUS_UPDATE_INTERVAL = 10000; // 10s
|
||||||
export const USAGE_UPDATE_INTERVAL = 300000; // 5 minutes
|
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.)
|
// Timezone for scheduled tasks (cron expressions, etc.)
|
||||||
// Uses system timezone by default
|
// Uses system timezone by default
|
||||||
export const TIMEZONE =
|
export const TIMEZONE =
|
||||||
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -455,25 +455,25 @@ describe('message query LIMIT', () => {
|
|||||||
|
|
||||||
describe('registered group isMain', () => {
|
describe('registered group isMain', () => {
|
||||||
it('persists isMain=true through set/get round-trip', () => {
|
it('persists isMain=true through set/get round-trip', () => {
|
||||||
setRegisteredGroup('main@s.whatsapp.net', {
|
setRegisteredGroup('dc:main', {
|
||||||
name: 'Main Chat',
|
name: 'Main Chat',
|
||||||
folder: 'whatsapp_main',
|
folder: 'discord_main',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
isMain: true,
|
isMain: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const groups = getAllRegisteredGroups();
|
const groups = getAllRegisteredGroups();
|
||||||
const group = groups['main@s.whatsapp.net'];
|
const group = groups['dc:main'];
|
||||||
expect(group).toBeDefined();
|
expect(group).toBeDefined();
|
||||||
expect(group.isMain).toBe(true);
|
expect(group.isMain).toBe(true);
|
||||||
expect(group.folder).toBe('whatsapp_main');
|
expect(group.folder).toBe('discord_main');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits isMain for non-main groups', () => {
|
it('omits isMain for non-main groups', () => {
|
||||||
setRegisteredGroup('group@g.us', {
|
setRegisteredGroup('group@g.us', {
|
||||||
name: 'Family Chat',
|
name: 'Family Chat',
|
||||||
folder: 'whatsapp_family-chat',
|
folder: 'discord_family-chat',
|
||||||
trigger: '@Andy',
|
trigger: '@Andy',
|
||||||
added_at: '2024-01-01T00:00:00.000Z',
|
added_at: '2024-01-01T00:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
364
src/db.ts
364
src/db.ts
@@ -2,11 +2,18 @@ import Database from 'better-sqlite3';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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 { isValidGroupFolder } from './group-folder.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import {
|
import {
|
||||||
NewMessage,
|
NewMessage,
|
||||||
|
AgentType,
|
||||||
RegisteredGroup,
|
RegisteredGroup,
|
||||||
ScheduledTask,
|
ScheduledTask,
|
||||||
TaskRunLog,
|
TaskRunLog,
|
||||||
@@ -70,17 +77,24 @@ function createSchema(database: Database.Database): void {
|
|||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS sessions (
|
CREATE TABLE IF NOT EXISTS sessions (
|
||||||
group_folder TEXT PRIMARY KEY,
|
group_folder TEXT NOT NULL,
|
||||||
session_id 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 (
|
CREATE TABLE IF NOT EXISTS registered_groups (
|
||||||
jid TEXT PRIMARY KEY,
|
jid TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
folder TEXT NOT NULL UNIQUE,
|
folder TEXT NOT NULL,
|
||||||
trigger_pattern TEXT NOT NULL,
|
trigger_pattern TEXT NOT NULL,
|
||||||
added_at TEXT NOT NULL,
|
added_at TEXT NOT NULL,
|
||||||
container_config TEXT,
|
agent_config TEXT,
|
||||||
requires_trigger INTEGER DEFAULT 1
|
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 */
|
/* column already exists */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add is_main column if it doesn't exist (migration for existing DBs)
|
// Migrate registered_groups to composite keys so Claude/Codex can share a jid/folder.
|
||||||
try {
|
const registeredGroupsSql = (
|
||||||
database.exec(
|
database
|
||||||
`ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
|
.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
|
// Backfill: existing rows with folder = 'main' are the main group
|
||||||
database.exec(
|
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)
|
const registeredGroupCols = database
|
||||||
try {
|
.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(
|
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)
|
// Migrate sessions table to composite PK (group_folder, agent_type)
|
||||||
try {
|
const sessionCols = database
|
||||||
database.exec(`ALTER TABLE registered_groups ADD COLUMN work_dir TEXT`);
|
.prepare('PRAGMA table_info(sessions)')
|
||||||
} catch {
|
.all() as Array<{ name: string }>;
|
||||||
/* column already exists */
|
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)
|
// 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 });
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||||
|
|
||||||
db = new Database(dbPath);
|
db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('busy_timeout = 5000');
|
||||||
createSchema(db);
|
createSchema(db);
|
||||||
|
|
||||||
// Migrate from JSON files if they exist
|
// 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 {
|
export interface ChatInfo {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -251,27 +357,6 @@ export function getAllChats(): ChatInfo[] {
|
|||||||
.all() as 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.
|
* Store a message with full content.
|
||||||
* Only call this for registered groups where message history is needed.
|
* 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(
|
export function getNewMessages(
|
||||||
jids: string[],
|
jids: string[],
|
||||||
lastTimestamp: string,
|
lastTimestamp: string,
|
||||||
@@ -327,9 +385,9 @@ export function getNewMessages(
|
|||||||
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
|
||||||
|
|
||||||
const placeholders = jids.map(() => '?').join(',');
|
const placeholders = jids.map(() => '?').join(',');
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
// and happens in message-runtime so cross-bot collaboration still works.
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
||||||
@@ -360,9 +418,9 @@ export function getMessagesSince(
|
|||||||
botPrefix: string,
|
botPrefix: string,
|
||||||
limit: number = 200,
|
limit: number = 200,
|
||||||
): NewMessage[] {
|
): NewMessage[] {
|
||||||
// Filter bot messages using both the is_bot_message flag AND the content
|
// Filter legacy prefixed outbound messages as a backstop for rows written
|
||||||
// prefix as a backstop for messages written before the migration ran.
|
// before explicit bot flags existed. Self-message filtering is channel-specific
|
||||||
// Subquery takes the N most recent, outer query re-sorts chronologically.
|
// and happens in message-runtime so cross-bot collaboration still works.
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT * FROM (
|
SELECT * FROM (
|
||||||
SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message
|
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 ---
|
// --- Router state accessors ---
|
||||||
|
|
||||||
export function getRouterState(key: string): string | undefined {
|
export function getRouterState(key: string): string | undefined {
|
||||||
|
const prefixedKey = `${SERVICE_ID}:${key}`;
|
||||||
const row = db
|
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 = ?')
|
.prepare('SELECT value FROM router_state WHERE key = ?')
|
||||||
.get(key) as { value: string } | undefined;
|
.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 {
|
export function setRouterState(key: string, value: string): void {
|
||||||
|
const prefixedKey = `${SERVICE_ID}:${key}`;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)',
|
||||||
).run(key, value);
|
).run(prefixedKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Session accessors ---
|
// --- Session accessors ---
|
||||||
|
|
||||||
export function getSession(groupFolder: string): string | undefined {
|
export function getSession(groupFolder: string): string | undefined {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare('SELECT session_id FROM sessions WHERE group_folder = ?')
|
.prepare(
|
||||||
.get(groupFolder) as { session_id: string } | undefined;
|
'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;
|
return row?.session_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSession(groupFolder: string, sessionId: string): void {
|
export function setSession(groupFolder: string, sessionId: string): void {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)',
|
'INSERT OR REPLACE INTO sessions (group_folder, agent_type, session_id) VALUES (?, ?, ?)',
|
||||||
).run(groupFolder, sessionId);
|
).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> {
|
export function getAllSessions(): Record<string, string> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.prepare('SELECT group_folder, session_id FROM sessions')
|
.prepare(
|
||||||
.all() as Array<{ group_folder: string; session_id: string }>;
|
'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> = {};
|
const result: Record<string, string> = {};
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
result[row.group_folder] = row.session_id;
|
result[row.group_folder] = row.session_id;
|
||||||
@@ -574,15 +660,15 @@ export function getRegisteredGroup(
|
|||||||
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
.prepare('SELECT * FROM registered_groups WHERE jid = ?')
|
||||||
.get(jid) as
|
.get(jid) as
|
||||||
| {
|
| {
|
||||||
jid: string;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
trigger_pattern: string;
|
trigger_pattern: string;
|
||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
agent_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
is_main: number | null;
|
||||||
agent_type: string | null;
|
agent_type: string | null;
|
||||||
work_dir: string | null;
|
work_dir: string | null;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -600,8 +686,8 @@ export function getRegisteredGroup(
|
|||||||
folder: row.folder,
|
folder: row.folder,
|
||||||
trigger: row.trigger_pattern,
|
trigger: row.trigger_pattern,
|
||||||
added_at: row.added_at,
|
added_at: row.added_at,
|
||||||
agentConfig: row.container_config
|
agentConfig: row.agent_config
|
||||||
? JSON.parse(row.container_config)
|
? JSON.parse(row.agent_config)
|
||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
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}`);
|
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
|
||||||
}
|
}
|
||||||
db.prepare(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
).run(
|
).run(
|
||||||
jid,
|
jid,
|
||||||
@@ -632,17 +718,25 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
export function getAllRegisteredGroups(
|
||||||
const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{
|
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;
|
jid: string;
|
||||||
name: string;
|
name: string;
|
||||||
folder: string;
|
folder: string;
|
||||||
trigger_pattern: string;
|
trigger_pattern: string;
|
||||||
added_at: string;
|
added_at: string;
|
||||||
container_config: string | null;
|
agent_config: string | null;
|
||||||
requires_trigger: number | null;
|
requires_trigger: number | null;
|
||||||
is_main: number | null;
|
is_main: number | null;
|
||||||
agent_type: string | null;
|
agent_type: string | null;
|
||||||
work_dir: string | null;
|
work_dir: string | null;
|
||||||
}>;
|
}>;
|
||||||
const result: Record<string, RegisteredGroup> = {};
|
const result: Record<string, RegisteredGroup> = {};
|
||||||
@@ -659,8 +753,8 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
folder: row.folder,
|
folder: row.folder,
|
||||||
trigger: row.trigger_pattern,
|
trigger: row.trigger_pattern,
|
||||||
added_at: row.added_at,
|
added_at: row.added_at,
|
||||||
agentConfig: row.container_config
|
agentConfig: row.agent_config
|
||||||
? JSON.parse(row.container_config)
|
? JSON.parse(row.agent_config)
|
||||||
: undefined,
|
: undefined,
|
||||||
requiresTrigger:
|
requiresTrigger:
|
||||||
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
|
||||||
@@ -672,6 +766,28 @@ export function getAllRegisteredGroups(): Record<string, RegisteredGroup> {
|
|||||||
return result;
|
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 ---
|
// --- JSON migration ---
|
||||||
|
|
||||||
function migrateJsonState(): void {
|
function migrateJsonState(): void {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { DATA_DIR, GROUPS_DIR } from './config.js';
|
||||||
import {
|
import {
|
||||||
isValidGroupFolder,
|
isValidGroupFolder,
|
||||||
resolveGroupFolderPath,
|
resolveGroupFolderPath,
|
||||||
@@ -24,16 +25,12 @@ describe('group folder validation', () => {
|
|||||||
|
|
||||||
it('resolves safe paths under groups directory', () => {
|
it('resolves safe paths under groups directory', () => {
|
||||||
const resolved = resolveGroupFolderPath('family-chat');
|
const resolved = resolveGroupFolderPath('family-chat');
|
||||||
expect(resolved.endsWith(`${path.sep}groups${path.sep}family-chat`)).toBe(
|
expect(resolved).toBe(path.resolve(GROUPS_DIR, 'family-chat'));
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves safe paths under data ipc directory', () => {
|
it('resolves safe paths under data ipc directory', () => {
|
||||||
const resolved = resolveGroupIpcPath('family-chat');
|
const resolved = resolveGroupIpcPath('family-chat');
|
||||||
expect(
|
expect(resolved).toBe(path.resolve(DATA_DIR, 'ipc', 'family-chat'));
|
||||||
resolved.endsWith(`${path.sep}data${path.sep}ipc${path.sep}family-chat`),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws for unsafe folder names', () => {
|
it('throws for unsafe folder names', () => {
|
||||||
|
|||||||
10
src/index.ts
10
src/index.ts
@@ -7,6 +7,7 @@ import {
|
|||||||
ASSISTANT_NAME,
|
ASSISTANT_NAME,
|
||||||
IDLE_TIMEOUT,
|
IDLE_TIMEOUT,
|
||||||
POLL_INTERVAL,
|
POLL_INTERVAL,
|
||||||
|
isSessionCommandSenderAllowed,
|
||||||
STATUS_CHANNEL_ID,
|
STATUS_CHANNEL_ID,
|
||||||
STATUS_UPDATE_INTERVAL,
|
STATUS_UPDATE_INTERVAL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
initDatabase,
|
initDatabase,
|
||||||
setRegisteredGroup,
|
setRegisteredGroup,
|
||||||
setRouterState,
|
setRouterState,
|
||||||
|
deleteSession,
|
||||||
setSession,
|
setSession,
|
||||||
storeChatMetadata,
|
storeChatMetadata,
|
||||||
storeMessage,
|
storeMessage,
|
||||||
@@ -94,6 +96,11 @@ function saveState(): void {
|
|||||||
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSession(groupFolder: string): void {
|
||||||
|
delete sessions[groupFolder];
|
||||||
|
deleteSession(groupFolder);
|
||||||
|
}
|
||||||
|
|
||||||
function registerGroup(jid: string, group: RegisteredGroup): void {
|
function registerGroup(jid: string, group: RegisteredGroup): void {
|
||||||
let groupDir: string;
|
let groupDir: string;
|
||||||
try {
|
try {
|
||||||
@@ -182,11 +189,13 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
|
|||||||
runAgent: (prompt, onOutput) =>
|
runAgent: (prompt, onOutput) =>
|
||||||
runAgent(group, prompt, chatJid, onOutput),
|
runAgent(group, prompt, chatJid, onOutput),
|
||||||
closeStdin: () => queue.closeStdin(chatJid),
|
closeStdin: () => queue.closeStdin(chatJid),
|
||||||
|
clearSession: () => clearSession(group.folder),
|
||||||
advanceCursor: (ts) => {
|
advanceCursor: (ts) => {
|
||||||
lastAgentTimestamp[chatJid] = ts;
|
lastAgentTimestamp[chatJid] = ts;
|
||||||
saveState();
|
saveState();
|
||||||
},
|
},
|
||||||
formatMessages,
|
formatMessages,
|
||||||
|
isAdminSender: (msg) => isSessionCommandSenderAllowed(msg.sender),
|
||||||
canSenderInteract: (msg) => {
|
canSenderInteract: (msg) => {
|
||||||
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
|
const hasTrigger = TRIGGER_PATTERN.test(msg.content.trim());
|
||||||
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
const reqTrigger = !isMainGroup && group.requiresTrigger !== false;
|
||||||
@@ -921,6 +930,7 @@ async function startMessageLoop(): Promise<void> {
|
|||||||
isSessionCommandAllowed(
|
isSessionCommandAllowed(
|
||||||
isMainGroup,
|
isMainGroup,
|
||||||
loopCmdMsg.is_from_me === true,
|
loopCmdMsg.is_from_me === true,
|
||||||
|
isSessionCommandSenderAllowed(loopCmdMsg.sender),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
queue.closeStdin(chatJid);
|
queue.closeStdin(chatJid);
|
||||||
|
|||||||
60
src/platform-prompts.ts
Normal file
60
src/platform-prompts.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -82,8 +82,10 @@ function makeDeps(
|
|||||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||||
runAgent: vi.fn().mockResolvedValue('success'),
|
runAgent: vi.fn().mockResolvedValue('success'),
|
||||||
closeStdin: vi.fn(),
|
closeStdin: vi.fn(),
|
||||||
|
clearSession: vi.fn(),
|
||||||
advanceCursor: vi.fn(),
|
advanceCursor: vi.fn(),
|
||||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||||
|
isAdminSender: vi.fn().mockReturnValue(false),
|
||||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import type { NewMessage } from './types.js';
|
import type { NewMessage } from './types.js';
|
||||||
import { logger } from './logger.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.
|
* 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();
|
let text = content.trim();
|
||||||
text = text.replace(triggerPattern, '').trim();
|
text = text.replace(triggerPattern, '').trim();
|
||||||
if (text === '/compact') return '/compact';
|
if (text === '/compact') return '/compact';
|
||||||
|
if (text === '/clear') return '/clear';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a session command sender is authorized.
|
* 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(
|
export function isSessionCommandAllowed(
|
||||||
isMainGroup: boolean,
|
isMainGroup: boolean,
|
||||||
isFromMe: boolean,
|
isFromMe: boolean,
|
||||||
|
isAdminSender: boolean = false,
|
||||||
): boolean {
|
): 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. */
|
/** Minimal agent result interface — matches the subset of AgentOutput used here. */
|
||||||
@@ -41,8 +59,10 @@ export interface SessionCommandDeps {
|
|||||||
onOutput: (result: AgentResult) => Promise<void>,
|
onOutput: (result: AgentResult) => Promise<void>,
|
||||||
) => Promise<'success' | 'error'>;
|
) => Promise<'success' | 'error'>;
|
||||||
closeStdin: () => void;
|
closeStdin: () => void;
|
||||||
|
clearSession: () => void;
|
||||||
advanceCursor: (timestamp: string) => void;
|
advanceCursor: (timestamp: string) => void;
|
||||||
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
||||||
|
isAdminSender: (msg: NewMessage) => boolean;
|
||||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||||
canSenderInteract: (msg: NewMessage) => boolean;
|
canSenderInteract: (msg: NewMessage) => boolean;
|
||||||
}
|
}
|
||||||
@@ -50,7 +70,7 @@ export interface SessionCommandDeps {
|
|||||||
function resultToText(result: string | object | null | undefined): string {
|
function resultToText(result: string | object | null | undefined): string {
|
||||||
if (!result) return '';
|
if (!result) return '';
|
||||||
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
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[];
|
missedMessages: NewMessage[];
|
||||||
isMainGroup: boolean;
|
isMainGroup: boolean;
|
||||||
groupName: string;
|
groupName: string;
|
||||||
|
runId?: string;
|
||||||
triggerPattern: RegExp;
|
triggerPattern: RegExp;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
deps: SessionCommandDeps;
|
deps: SessionCommandDeps;
|
||||||
@@ -71,6 +92,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
missedMessages,
|
missedMessages,
|
||||||
isMainGroup,
|
isMainGroup,
|
||||||
groupName,
|
groupName,
|
||||||
|
runId,
|
||||||
triggerPattern,
|
triggerPattern,
|
||||||
timezone,
|
timezone,
|
||||||
deps,
|
deps,
|
||||||
@@ -85,7 +107,13 @@ export async function handleSessionCommand(opts: {
|
|||||||
|
|
||||||
if (!command || !cmdMsg) return { handled: false };
|
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,
|
// DENIED: send denial if the sender would normally be allowed to interact,
|
||||||
// then silently consume the command by advancing the cursor past it.
|
// then silently consume the command by advancing the cursor past it.
|
||||||
// Trade-off: other messages in the same batch are also consumed (cursor is
|
// 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
|
// 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 cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||||
@@ -125,7 +163,7 @@ export async function handleSessionCommand(opts: {
|
|||||||
|
|
||||||
if (preResult === 'error' || hadPreError) {
|
if (preResult === 'error' || hadPreError) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
{ group: groupName },
|
{ group: groupName, runId },
|
||||||
'Pre-compact processing failed, aborting session command',
|
'Pre-compact processing failed, aborting session command',
|
||||||
);
|
);
|
||||||
await deps.sendMessage(
|
await deps.sendMessage(
|
||||||
|
|||||||
@@ -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 {
|
export interface AgentConfig {
|
||||||
additionalMounts?: AdditionalMount[];
|
|
||||||
timeout?: number; // Default: 300000 (5 minutes)
|
timeout?: number; // Default: 300000 (5 minutes)
|
||||||
// Per-group model/effort overrides (take precedence over global env vars)
|
// Per-group model/effort overrides (take precedence over global env vars)
|
||||||
codexModel?: string;
|
codexModel?: string;
|
||||||
@@ -78,6 +71,8 @@ export interface Channel {
|
|||||||
sendMessage(jid: string, text: string): Promise<void>;
|
sendMessage(jid: string, text: string): Promise<void>;
|
||||||
isConnected(): boolean;
|
isConnected(): boolean;
|
||||||
ownsJid(jid: string): 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>;
|
disconnect(): Promise<void>;
|
||||||
// Optional: typing indicator. Channels that support it implement it.
|
// Optional: typing indicator. Channels that support it implement it.
|
||||||
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
setTyping?(jid: string, isTyping: boolean): Promise<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user