mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-18 18:29:35 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 420fe5ecff |
@@ -1,5 +1,41 @@
|
||||
{
|
||||
"sandbox": {
|
||||
"enabled": false
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bash setup.sh*)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(pnpm exec tsx setup/index.ts*)",
|
||||
"Bash(pnpm exec tsx scripts/init-first-agent.ts*)",
|
||||
"Bash(pnpm install @chat-adapter/*)",
|
||||
"Bash(pnpm install chat-adapter-imessage*)",
|
||||
"Bash(pnpm install @bitbasti/chat-adapter-webex*)",
|
||||
"Bash(pnpm install @resend/chat-sdk-adapter*)",
|
||||
"Bash(pnpm install @whiskeysockets/baileys*)",
|
||||
"Bash(pnpm install @beeper/chat-adapter-matrix*)",
|
||||
"Bash(pnpm install @nanoco/nanoclaw-dashboard*)",
|
||||
"Bash(pnpm install --frozen-lockfile*)",
|
||||
"Bash(pnpm run build*)",
|
||||
"Bash(curl -fsSL onecli.sh*)",
|
||||
"Bash(onecli *)",
|
||||
"Bash(grep -q *)",
|
||||
"Bash(echo *>> .env)",
|
||||
"Bash(ls *)",
|
||||
"Bash(cat ~/.config/nanoclaw/*)",
|
||||
"Bash(tail *logs/*)",
|
||||
"Bash(launchctl *nanoclaw*)",
|
||||
"Bash(sqlite3 data/*)",
|
||||
"Bash(docker info*)",
|
||||
"Bash(docker logs *)",
|
||||
"Bash(mkdir -p *)",
|
||||
"Bash(cp .env *)",
|
||||
"Bash(rsync -a .claude/skills/*)",
|
||||
"Bash(head *)",
|
||||
"Bash(xattr *)",
|
||||
"Bash(find ~/.npm *)",
|
||||
"Bash(which onecli*)",
|
||||
"Bash(./container/build.sh*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
name: add-compact
|
||||
description: Add /compact command for manual context compaction. Solves context rot in long sessions by forwarding the SDK's built-in /compact slash command. Main-group or trusted sender only.
|
||||
---
|
||||
|
||||
# Add /compact Command
|
||||
|
||||
Adds a `/compact` session command that compacts conversation history to fight context rot in long-running sessions. Uses the Claude Agent SDK's built-in `/compact` slash command — no synthetic system prompts.
|
||||
|
||||
**Session contract:** `/compact` keeps the same logical session alive. The SDK returns a new session ID after compaction (via the `init` system message), which the agent-runner forwards to the orchestrator as `newSessionId`. No destructive reset occurs — the agent retains summarized context.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
Check if `src/session-commands.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/session-commands.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
Merge the skill branch:
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/compact
|
||||
git merge upstream/skill/compact
|
||||
```
|
||||
|
||||
> **Note:** `upstream` is the remote pointing to `qwibitai/nanoclaw`. If using a different remote name, substitute accordingly.
|
||||
|
||||
This adds:
|
||||
- `src/session-commands.ts` (extract and authorize session commands)
|
||||
- `src/session-commands.test.ts` (unit tests for command parsing and auth)
|
||||
- Session command interception in `src/index.ts` (both `processGroupMessages` and `startMessageLoop`)
|
||||
- Slash command handling in `container/agent-runner/src/index.ts`
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### Rebuild container
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
### Restart service
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Integration Test
|
||||
|
||||
1. Start NanoClaw in dev mode: `pnpm 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
|
||||
pnpm run build
|
||||
pnpm 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.
|
||||
+127
-134
@@ -1,11 +1,12 @@
|
||||
---
|
||||
name: add-emacs
|
||||
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Local HTTP bridge — no bot token or external service needed.
|
||||
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
|
||||
---
|
||||
|
||||
# Add Emacs Channel
|
||||
|
||||
Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
|
||||
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
|
||||
|
||||
## What you can do with this
|
||||
|
||||
@@ -14,99 +15,95 @@ Adds Emacs support via a local HTTP bridge. Works with Doom Emacs, Spacemacs, an
|
||||
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
|
||||
- **Draft writing** — send org prose; receive revisions or continuations in place
|
||||
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
|
||||
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
|
||||
|
||||
## Install
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Emacs adapter and the Lisp client in from the `channels` branch. Native HTTP bridge — no Chat SDK, no adapter package.
|
||||
### Check if already applied
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Enable** if all of these are already in place:
|
||||
|
||||
- `src/channels/emacs.ts` exists
|
||||
- `emacs/nanoclaw.el` exists
|
||||
- `src/channels/index.ts` contains `import './emacs.js';`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
Check if `src/channels/emacs.ts` exists:
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
|
||||
```
|
||||
|
||||
### 2. Copy the adapter and Lisp client
|
||||
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
|
||||
```bash
|
||||
mkdir -p emacs
|
||||
git show origin/channels:src/channels/emacs.ts > src/channels/emacs.ts
|
||||
git show origin/channels:src/channels/emacs.test.ts > src/channels/emacs.test.ts
|
||||
git show origin/channels:emacs/nanoclaw.el > emacs/nanoclaw.el
|
||||
git remote -v
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './emacs.js';
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### 4. Build
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/emacs
|
||||
git merge upstream/skill/emacs
|
||||
```
|
||||
|
||||
If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming
|
||||
version and continuing:
|
||||
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
```
|
||||
|
||||
For any other conflict, read the conflicted file and reconcile both sides manually.
|
||||
|
||||
This adds:
|
||||
- `src/channels/emacs.ts` — `EmacsBridgeChannel` HTTP server (port 8766)
|
||||
- `src/channels/emacs.test.ts` — unit tests
|
||||
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
|
||||
- `import './emacs.js'` appended to `src/channels/index.ts`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/emacs.test.ts
|
||||
```
|
||||
|
||||
No npm package to install — the adapter uses only Node builtins (`http`).
|
||||
Build must be clean and tests must pass before proceeding.
|
||||
|
||||
## Enable
|
||||
## Phase 3: Setup
|
||||
|
||||
The adapter is gated by `EMACS_ENABLED` so the HTTP port isn't opened on hosts that aren't running Emacs. Add to `.env`:
|
||||
### Configure environment (optional)
|
||||
|
||||
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
|
||||
|
||||
```bash
|
||||
EMACS_ENABLED=true
|
||||
EMACS_CHANNEL_PORT=8766 # optional — change only if 8766 is taken
|
||||
EMACS_AUTH_TOKEN= # optional — set to a random string to lock the endpoint
|
||||
EMACS_PLATFORM_ID=default # optional — only change if you want a non-default chat id
|
||||
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
|
||||
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
|
||||
```
|
||||
|
||||
Generate an auth token (recommended even on single-user machines — prevents other local processes from poking the endpoint):
|
||||
If you change or add values, sync to the container environment:
|
||||
|
||||
```bash
|
||||
node -e "console.log(require('crypto').randomBytes(16).toString('hex'))"
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
## Wire the channel
|
||||
### Configure Emacs
|
||||
|
||||
Emacs is a single-user, single-chat channel. One host = one messaging group with `platform_id = "default"`.
|
||||
|
||||
### If this is your first agent group
|
||||
|
||||
Run `/init-first-agent` — pick **Emacs** as the channel, use any short handle as the "user id" (e.g. your OS username), and the skill will create the agent group, wire the channel, and write a welcome message that the agent delivers back to your Emacs buffer.
|
||||
|
||||
### Otherwise — wire to an existing agent group
|
||||
|
||||
Run the `register` step directly. The `EMACS_PLATFORM_ID` (default `default`) becomes the messaging group's platform id:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step register -- \
|
||||
--platform-id "default" --name "Emacs" \
|
||||
--folder "<existing-folder>" --channel "emacs" \
|
||||
--session-mode "agent-shared" \
|
||||
--assistant-name "<existing-assistant-name>"
|
||||
```
|
||||
|
||||
`agent-shared` puts Emacs messages in the same session as any other channel wired to the same agent group — so a conversation you started in Telegram continues in Emacs. Use `shared` to keep an independent Emacs thread with the same workspace, or a new `--folder` for a dedicated Emacs-only agent.
|
||||
|
||||
## Configure Emacs
|
||||
|
||||
`nanoclaw.el` needs only Emacs 27.1+ builtins (`url`, `json`, `org`) — no package manager.
|
||||
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
|
||||
|
||||
AskUserQuestion: Which Emacs distribution are you using?
|
||||
- **Doom Emacs** — `config.el` with `map!` keybindings
|
||||
- **Spacemacs** — `dotspacemacs/user-config` in `~/.spacemacs`
|
||||
- **Vanilla Emacs / other** — `init.el` with `global-set-key`
|
||||
- **Doom Emacs** - config.el with map! keybindings
|
||||
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
|
||||
- **Vanilla Emacs / other** - init.el with global-set-key
|
||||
|
||||
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
|
||||
|
||||
@@ -120,7 +117,7 @@ AskUserQuestion: Which Emacs distribution are you using?
|
||||
:desc "Send org" "o" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x doom/reload`
|
||||
Then reload: `M-x doom/reload`
|
||||
|
||||
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
|
||||
|
||||
@@ -132,9 +129,9 @@ Reload: `M-x doom/reload`
|
||||
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el`:
|
||||
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
|
||||
|
||||
```elisp
|
||||
;; NanoClaw — personal AI assistant channel
|
||||
@@ -144,75 +141,61 @@ Reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
|
||||
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
|
||||
```
|
||||
|
||||
Reload: `M-x eval-buffer` or restart Emacs.
|
||||
Then reload: `M-x eval-buffer` or restart Emacs.
|
||||
|
||||
Replace `~/src/nanoclaw/emacs/nanoclaw.el` with your actual NanoClaw checkout path.
|
||||
|
||||
If `EMACS_AUTH_TOKEN` is set, also add (any distribution):
|
||||
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token "<your-token>")
|
||||
```
|
||||
|
||||
If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-port <your-port>)
|
||||
```
|
||||
|
||||
## Restart NanoClaw
|
||||
### Restart NanoClaw
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Verify
|
||||
## Phase 4: Verify
|
||||
|
||||
### HTTP endpoint
|
||||
### Test the HTTP endpoint
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:8766/api/messages?since=0
|
||||
curl -s "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
Expected: `{"messages":[]}`. With an auth token:
|
||||
Expected: `{"messages":[]}`
|
||||
|
||||
If you set `EMACS_AUTH_TOKEN`:
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer <token>" http://localhost:8766/api/messages?since=0
|
||||
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
|
||||
```
|
||||
|
||||
### From Emacs
|
||||
### Test from Emacs
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
|
||||
> 2. Type a message and press `C-c C-c` to send (RET inserts newlines)
|
||||
> 3. A response should appear within a few seconds
|
||||
> 2. Type a message and press `RET`
|
||||
> 3. A response from Andy should appear within a few seconds
|
||||
>
|
||||
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
|
||||
|
||||
### Log line
|
||||
### Check logs if needed
|
||||
|
||||
`tail -f logs/nanoclaw.log` should show `Emacs channel listening` at startup.
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `emacs`
|
||||
- **terminology**: Single local buffer. There are no "groups" or separate chats — one host = one chat, addressed by a `platform_id` string (default `default`).
|
||||
- **how-to-find-id**: The platform id is whatever you set in `EMACS_PLATFORM_ID` (default `default`). User handles are arbitrary; your OS username or first name is fine (e.g. `emacs:<username>`).
|
||||
- **supports-threads**: no
|
||||
- **typical-use**: Single developer talking to the assistant from within Emacs, alongside whatever other channel they use (Slack, Telegram, Discord).
|
||||
- **default-isolation**: Same agent group as the primary DM, with `session-mode = agent-shared` so a conversation started elsewhere continues in Emacs. Pick a separate folder only if you specifically want an Emacs-only persona.
|
||||
|
||||
### Features
|
||||
|
||||
- Interactive chat buffer (`nanoclaw-chat`) with markdown → org-mode rendering
|
||||
- Org integration (`nanoclaw-org-send`) — sends the current subtree or region; reply lands as a child heading
|
||||
- Optional bearer-token auth for the local endpoint
|
||||
- Single-user: the adapter exposes exactly one messaging group per host
|
||||
|
||||
Not applicable (design): multi-user channels, threads, cold DM initiation, typing indicators, attachments.
|
||||
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -222,53 +205,66 @@ Not applicable (design): multi-user channels, threads, cold DM initiation, typin
|
||||
Error: listen EADDRINUSE: address already in use :::8766
|
||||
```
|
||||
|
||||
Either a stale NanoClaw is running or another app has the port. Kill stale process or change port:
|
||||
Either a stale NanoClaw process is running, or 8766 is taken by another app.
|
||||
|
||||
Find and kill the stale process:
|
||||
|
||||
```bash
|
||||
lsof -ti :8766 | xargs kill -9
|
||||
# or set EMACS_CHANNEL_PORT in .env and mirror in Emacs config (nanoclaw-port)
|
||||
```
|
||||
|
||||
### Adapter not starting
|
||||
|
||||
If `grep "Emacs channel listening" logs/nanoclaw.log` returns nothing, check that `EMACS_ENABLED=true` is in `.env` and that the adapter import is present:
|
||||
|
||||
```bash
|
||||
grep -q '^EMACS_ENABLED=true' .env && echo "enabled" || echo "not enabled"
|
||||
grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "not imported"
|
||||
```
|
||||
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
|
||||
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
2. Messaging group wired: `sqlite3 data/v2.db "SELECT mg.platform_id, ag.folder FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id JOIN agent_groups ag ON ag.id = mga.agent_group_id WHERE mg.channel_type = 'emacs'"`
|
||||
3. Logs show inbound: `grep 'channel_type=emacs\|Emacs' logs/nanoclaw.log | tail -20`
|
||||
Check:
|
||||
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
|
||||
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
3. Logs show activity: `tail -50 logs/nanoclaw.log`
|
||||
|
||||
If no messaging group row exists, run the `register` command above.
|
||||
If the group is not registered, it will be created automatically on the next NanoClaw restart.
|
||||
|
||||
### Auth token mismatch (401 Unauthorized)
|
||||
|
||||
```elisp
|
||||
M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must match `EMACS_AUTH_TOKEN` in `.env`. If you didn't set one server-side, clear it in Emacs too:
|
||||
Verify the token in Emacs matches `.env`:
|
||||
|
||||
```elisp
|
||||
(setq nanoclaw-auth-token nil)
|
||||
;; M-x describe-variable RET nanoclaw-auth-token RET
|
||||
```
|
||||
|
||||
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
|
||||
|
||||
### nanoclaw.el not loading
|
||||
|
||||
Check the path is correct:
|
||||
|
||||
```bash
|
||||
ls ~/src/nanoclaw/emacs/nanoclaw.el
|
||||
```
|
||||
|
||||
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
|
||||
|
||||
## After Setup
|
||||
|
||||
If running `pnpm run dev` while the service is active:
|
||||
|
||||
```bash
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
pnpm run dev
|
||||
# When done testing:
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
|
||||
# Linux:
|
||||
# systemctl --user stop nanoclaw
|
||||
# pnpm run dev
|
||||
# systemctl --user start nanoclaw
|
||||
```
|
||||
|
||||
## Agent Formatting
|
||||
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should output standard markdown, **not** org-mode syntax:
|
||||
The Emacs bridge converts markdown → org-mode automatically. Agents should
|
||||
output standard markdown — **not** org-mode syntax. The conversion handles:
|
||||
|
||||
| Markdown | Org-mode |
|
||||
|----------|----------|
|
||||
@@ -278,19 +274,16 @@ The Emacs bridge converts markdown → org-mode automatically. Agents should out
|
||||
| `` `code` `` | `~code~` |
|
||||
| ` ```lang ` | `#+begin_src lang` |
|
||||
|
||||
If an agent outputs org-mode directly, markers get double-converted and render incorrectly.
|
||||
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
|
||||
and render incorrectly.
|
||||
|
||||
## Removal
|
||||
|
||||
```bash
|
||||
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
|
||||
# Remove the `import './emacs.js';` line from src/channels/index.ts
|
||||
# Remove EMACS_* lines from .env
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
To remove the Emacs channel:
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
sqlite3 data/v2.db "DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type='emacs'); DELETE FROM messaging_groups WHERE channel_type='emacs';"
|
||||
```
|
||||
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
|
||||
2. Remove `import './emacs.js'` from `src/channels/index.ts`
|
||||
3. Remove the NanoClaw block from your Emacs config file
|
||||
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
|
||||
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
|
||||
6. Rebuild: `pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `pnpm run build && systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -7,10 +7,6 @@ description: Add GitHub channel integration via Chat SDK. PR and issue comment t
|
||||
|
||||
Adds GitHub support via the Chat SDK bridge. The agent participates in PR and issue comment threads.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
You need a **dedicated GitHub bot account** (not your personal account). The adapter uses this account to post replies and filters out its own messages to avoid loops. Create a free GitHub account for your bot (e.g. `my-org-bot`), then invite it as a collaborator with write access to the repos you want monitored.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the GitHub adapter in from the `channels` branch.
|
||||
@@ -59,90 +55,40 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
### 1. Create a Personal Access Token for the bot account
|
||||
> 1. Go to [GitHub Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
|
||||
> 2. Create a **Fine-grained token** with:
|
||||
> - Repository access: select the repos you want the bot to monitor
|
||||
> - Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
|
||||
> 3. Copy the token
|
||||
> 4. Set up a webhook on your repo(s):
|
||||
> - Go to **Settings** > **Webhooks** > **Add webhook**
|
||||
> - Payload URL: `https://your-domain/webhook/github`
|
||||
> - Content type: `application/json`
|
||||
> - Secret: generate a random string
|
||||
> - Events: select **Issue comments**, **Pull request review comments**
|
||||
|
||||
Log in as your **bot account**, then:
|
||||
|
||||
1. Go to [Settings > Developer Settings > Personal Access Tokens](https://github.com/settings/tokens)
|
||||
2. Create a **Fine-grained token** with:
|
||||
- Repository access: select the repos you want the bot to monitor
|
||||
- Permissions: **Pull requests** (Read & Write), **Issues** (Read & Write)
|
||||
3. Copy the token
|
||||
|
||||
### 2. Set up a webhook on each repo
|
||||
|
||||
On each repo (logged in as the repo owner/admin):
|
||||
|
||||
1. Go to **Settings** > **Webhooks** > **Add webhook**
|
||||
2. Payload URL: `https://your-domain/webhook/github` (the shared webhook server, default port 3000)
|
||||
3. Content type: `application/json`
|
||||
4. Secret: generate a random string (e.g. `openssl rand -hex 20`)
|
||||
5. Events: select **Issue comments** and **Pull request review comments**
|
||||
|
||||
### 3. Configure environment
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
GITHUB_TOKEN=github_pat_...
|
||||
GITHUB_WEBHOOK_SECRET=your-webhook-secret
|
||||
GITHUB_BOT_USERNAME=your-bot-username
|
||||
```
|
||||
|
||||
`GITHUB_BOT_USERNAME` must match the bot account's GitHub username exactly. This is used for @-mention detection — the agent responds when someone writes `@your-bot-username` in a PR or issue comment.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public repo?**
|
||||
|
||||
- **Private repo** — use `unknown_sender_policy: 'public'`. Only collaborators can comment anyway, so it's safe to let all comments through.
|
||||
- **Public repo** — use `unknown_sender_policy: 'strict'`. Only registered members can trigger the agent, preventing strangers from consuming agent resources. Add trusted collaborators as members (see below).
|
||||
|
||||
Run `/manage-channels` to wire the GitHub channel to an agent group, or insert manually:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per repo)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'owner/repo', 1, '<policy>', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-github-myrepo', 'mg-github-myrepo', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
```
|
||||
|
||||
Replace `<policy>` with `public` or `strict` based on the user's choice above.
|
||||
|
||||
### Adding members (for strict mode)
|
||||
|
||||
When using `strict`, add each GitHub user who should be able to trigger the agent:
|
||||
|
||||
```sql
|
||||
-- Add user (kind = 'github', id = 'github:<numeric-user-id>')
|
||||
INSERT OR IGNORE INTO users (id, kind, display_name, created_at)
|
||||
VALUES ('github:<user-id>', 'github', '<username>', datetime('now'));
|
||||
|
||||
-- Grant membership to the agent group
|
||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id)
|
||||
VALUES ('github:<user-id>', '<agent-group-id>');
|
||||
```
|
||||
|
||||
To find a GitHub user's numeric ID: `gh api users/<username> --jq .id`
|
||||
|
||||
Use `per-thread` session mode so each PR/issue gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `github`
|
||||
- **terminology**: GitHub has "repositories" containing "pull requests" and "issues." Each PR or issue comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is `github:owner/repo` (e.g. `github:acme/backend`). Each PR/issue becomes its own thread automatically.
|
||||
- **how-to-find-id**: The platform ID is `owner/repo` (e.g. `acme/backend`). Each PR/issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (PR and issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook-driven — the agent receives PR and issue comment events and responds in comment threads when @-mentioned. After the first mention, the thread is subscribed and the agent responds to all follow-up comments.
|
||||
- **default-isolation**: Use `per-thread` session mode. Each PR or issue gets its own isolated agent session. Typically wire to a dedicated agent group if the repo contains sensitive code.
|
||||
- **typical-use**: Webhook/notification — the agent receives PR and issue events and responds in comment threads
|
||||
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can summarize PRs and respond to reviews in the same context. Use a separate agent group if the repo contains sensitive code that other channels shouldn't access.
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
name: add-gmail
|
||||
description: Add Gmail integration to NanoClaw. Can be configured as a tool (agent reads/sends emails when triggered from WhatsApp) or as a full channel (emails can trigger the agent, schedule tasks, and receive replies). Guides through GCP OAuth setup and implements the integration.
|
||||
---
|
||||
|
||||
# Add Gmail Integration
|
||||
|
||||
This skill adds Gmail support to NanoClaw — either as a tool (read, send, search, draft) or as a full channel that polls the inbox.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/channels/gmail.ts` exists. If it does, skip to Phase 3 (Setup). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion`:
|
||||
|
||||
AskUserQuestion: Should incoming emails be able to trigger the agent?
|
||||
|
||||
- **Yes** — Full channel mode: the agent listens on Gmail and responds to incoming emails automatically
|
||||
- **No** — Tool-only: the agent gets full Gmail tools (read, send, search, draft) but won't monitor the inbox. No channel code is added.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure channel remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `gmail` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch gmail main
|
||||
git merge gmail/main || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/channels/gmail.ts` (GmailChannel class with self-registration via `registerChannel`)
|
||||
- `src/channels/gmail.test.ts` (unit tests)
|
||||
- `import './gmail.js'` appended to the channel barrel file `src/channels/index.ts`
|
||||
- Gmail credentials mount (`~/.gmail-mcp`) in `src/container-runner.ts`
|
||||
- Gmail MCP server (`@gongrzhe/server-gmail-autoauth-mcp`) and `mcp__gmail__*` allowed tool in `container/agent-runner/src/index.ts`
|
||||
- `googleapis` npm dependency in `package.json`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Add email handling instructions (Channel mode only)
|
||||
|
||||
If the user chose channel mode, append the following to `groups/main/CLAUDE.md` (before the formatting section):
|
||||
|
||||
```markdown
|
||||
## Email Notifications
|
||||
|
||||
When you receive an email notification (messages starting with `[Email from ...`), inform the user about it but do NOT reply to the email unless specifically asked. You have Gmail tools available — use them only when the user explicitly asks you to reply, forward, or take action on an email.
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec 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 with real tokens (not `onecli-managed` values), skip to "Build and restart" below.
|
||||
|
||||
### GCP Project Setup
|
||||
|
||||
Check if OneCLI is configured:
|
||||
|
||||
```bash
|
||||
grep -q 'ONECLI_URL=.' .env 2>/dev/null && echo "onecli" || echo "manual"
|
||||
```
|
||||
|
||||
**If OneCLI:** Tell the user to open `${ONECLI_URL}/connections?connect=gmail` to set up their Gmail connection. The dashboard walks them through creating a Google Cloud OAuth app and authorizing it. Ask them to let you know when done.
|
||||
|
||||
Once the user confirms, run:
|
||||
|
||||
```bash
|
||||
onecli apps get --provider gmail
|
||||
```
|
||||
|
||||
Check that `config.hasCredentials` is `true` or `connection` is not null. The response `hint` field has instructions and a docs URL for what stub credential files to create under `~/.gmail-mcp/`. Follow the hint — never overwrite existing files that don't contain `onecli-managed` values.
|
||||
|
||||
**If manual:** 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
|
||||
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp auth
|
||||
```
|
||||
|
||||
If that fails (some versions don't have an auth subcommand), try `timeout 60 pnpm dlx @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
|
||||
pnpm 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
|
||||
pnpm dlx @gongrzhe/server-gmail-autoauth-mcp
|
||||
```
|
||||
|
||||
### OAuth token expired
|
||||
|
||||
Re-authorize:
|
||||
|
||||
```bash
|
||||
rm ~/.gmail-mcp/credentials.json
|
||||
pnpm dlx @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 .. && pnpm 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: `pnpm 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 .. && pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
name: add-image-vision
|
||||
description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
|
||||
---
|
||||
|
||||
# Image Vision Skill
|
||||
|
||||
Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
1. Check if `src/image.ts` exists — skip to Phase 3 if already applied
|
||||
2. Confirm `sharp` is installable (native bindings require build tools)
|
||||
|
||||
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/image-vision
|
||||
git merge whatsapp/skill/image-vision || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/image.ts` (image download, resize via sharp, base64 encoding)
|
||||
- `src/image.test.ts` (8 unit tests)
|
||||
- Image attachment handling in `src/channels/whatsapp.ts`
|
||||
- Image passing to agent in `src/index.ts` and `src/container-runner.ts`
|
||||
- Image content block support in `container/agent-runner/src/index.ts`
|
||||
- `sharp` npm dependency in `package.json`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec 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 `pnpm 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.
|
||||
@@ -5,24 +5,11 @@ description: Add Linear channel integration via Chat SDK. Issue comment threads
|
||||
|
||||
# Add Linear Channel
|
||||
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads. Every comment on a Linear issue triggers the agent — no @-mention needed.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Recommended:** Create a Linear **OAuth application** so the agent posts as an app identity, not as you. This prevents the adapter from filtering your own comments as self-messages.
|
||||
|
||||
1. Go to [Linear Settings > API > OAuth Applications](https://linear.app/settings/api/applications/new)
|
||||
2. Create an app (e.g. "NanoClaw Bot")
|
||||
- Developer URL: your repo URL (e.g. `https://github.com/your-org/nanoclaw`)
|
||||
- Callback URL: `http://localhost`
|
||||
3. After creating, click the app and enable **Client credentials** under grant types
|
||||
4. Copy the **Client ID** and **Client Secret**
|
||||
|
||||
**Alternative:** Use a Personal API Key (`LINEAR_API_KEY`) for simpler setup. The agent will post as you, and your own comments will be filtered (other team members' comments still work).
|
||||
Adds Linear support via the Chat SDK bridge. The agent participates in issue comment threads.
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch and patches the Chat SDK bridge to support catch-all message forwarding (Linear OAuth apps can't be @-mentioned).
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the Linear adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
@@ -31,7 +18,6 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `src/channels/linear.ts` exists
|
||||
- `src/channels/index.ts` contains `import './linear.js';`
|
||||
- `@chat-adapter/linear` is listed in `package.json` dependencies
|
||||
- `src/channels/chat-sdk-bridge.ts` contains `catchAll`
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
@@ -55,42 +41,13 @@ Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
import './linear.js';
|
||||
```
|
||||
|
||||
### 4. Patch the Chat SDK bridge for catch-all message forwarding
|
||||
|
||||
Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler never fires. Add `catchAll` support to `src/channels/chat-sdk-bridge.ts`:
|
||||
|
||||
**4a.** Add `catchAll?: boolean` to the `ChatSdkBridgeConfig` interface:
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Forward ALL messages in unsubscribed threads, not just @-mentions.
|
||||
* Use for platforms where the bot identity can't be @-mentioned (e.g.
|
||||
* Linear OAuth apps). The thread is auto-subscribed on first message.
|
||||
*/
|
||||
catchAll?: boolean;
|
||||
```
|
||||
|
||||
**4b.** Add this handler block right after the `chat.onNewMention(...)` block (before the DMs block):
|
||||
|
||||
```typescript
|
||||
// Catch-all for platforms where @-mention isn't possible (e.g. Linear
|
||||
// OAuth apps). Forward every unsubscribed message and auto-subscribe.
|
||||
if (config.catchAll) {
|
||||
chat.onNewMessage(/.*/, async (thread, message) => {
|
||||
const channelId = adapter.channelIdFromThreadId(thread.id);
|
||||
await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message));
|
||||
await thread.subscribe();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Install the adapter package (pinned)
|
||||
### 4. Install the adapter package (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
```
|
||||
|
||||
### 6. Build
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
@@ -98,71 +55,37 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
### 1. Set up a webhook
|
||||
> 1. Go to [Linear Settings > API Keys](https://linear.app/settings/account/security/api-keys/new)
|
||||
> 2. Create a **Personal API Key** (or use an OAuth application for team-wide access)
|
||||
> 3. Copy the API key
|
||||
> 4. Set up a webhook:
|
||||
> - Go to **Settings** > **API** > **Webhooks** > **New webhook**
|
||||
> - URL: `https://your-domain/webhook/linear`
|
||||
> - Select events: **Comment** (created, updated)
|
||||
> - Copy the signing secret
|
||||
|
||||
1. Go to **Linear Settings** > **API** > **Webhooks** > **New webhook**
|
||||
2. Label: `NanoClaw`
|
||||
3. URL: `https://your-domain/webhook/linear` (the shared webhook server, default port 3000)
|
||||
4. Team: select the team you want to monitor
|
||||
5. Events: check **Comment**
|
||||
6. Save — copy the **signing secret**
|
||||
|
||||
Note: Linear webhook delivery may be delayed 1-5 minutes for new webhooks. This is normal.
|
||||
|
||||
### 2. Configure environment
|
||||
### Configure environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
# OAuth app (recommended)
|
||||
LINEAR_CLIENT_ID=your-client-id
|
||||
LINEAR_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# OR Personal API key (simpler, but agent posts as you)
|
||||
# LINEAR_API_KEY=lin_api_...
|
||||
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-signing-secret
|
||||
LINEAR_BOT_USERNAME=NanoClaw Bot
|
||||
LINEAR_TEAM_KEY=ENG
|
||||
LINEAR_API_KEY=lin_api_...
|
||||
LINEAR_WEBHOOK_SECRET=your-webhook-secret
|
||||
```
|
||||
|
||||
- `LINEAR_BOT_USERNAME`: display name for the bot (used for self-message detection when using a Personal API Key)
|
||||
- `LINEAR_TEAM_KEY`: the Linear team key (e.g. `ENG`, `NAN`). Find it in Linear under Settings > Teams. All issues in this team route to one messaging group.
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Wiring
|
||||
|
||||
Ask the user: **Is this a private or public Linear workspace?**
|
||||
|
||||
- **Private workspace** — use `unknown_sender_policy: 'public'`. Only workspace members can comment.
|
||||
- **Public workspace** — use `unknown_sender_policy: 'strict'` and add trusted members (see GitHub skill for member registration example).
|
||||
|
||||
Run `/manage-channels` to wire the Linear channel to an agent group, or insert manually:
|
||||
|
||||
```sql
|
||||
-- Create messaging group (one per team)
|
||||
INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'Engineering', 1, 'public', datetime('now'));
|
||||
|
||||
-- Wire to agent group
|
||||
INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES ('mga-linear-eng', 'mg-linear-eng', '<your-agent-group-id>', '', 'all', 'per-thread', 10, datetime('now'));
|
||||
```
|
||||
|
||||
The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `linear`
|
||||
- **terminology**: Linear has "teams" containing "issues." Each issue's comment thread is a separate conversation.
|
||||
- **how-to-find-id**: The platform ID is `linear:<TEAM_KEY>` (e.g. `linear:ENG`). Find your team key in Linear under Settings > Teams. Each issue becomes its own thread automatically.
|
||||
- **how-to-find-id**: The platform ID is your team key (e.g. `ENG`). Find it in Linear under Settings > Teams. Each issue becomes its own thread automatically.
|
||||
- **supports-threads**: yes (issue comment threads are native conversations)
|
||||
- **typical-use**: Webhook-driven — the agent receives all issue comment events and responds automatically. No @-mention needed (Linear OAuth apps can't be @-mentioned).
|
||||
- **default-isolation**: Use `per-thread` session mode. Each issue comment thread gets its own isolated agent session.
|
||||
- **typical-use**: Webhook/notification — the agent receives issue comment events and responds in threads
|
||||
- **default-isolation**: Typically shares a session with a chat channel (e.g. Slack) so the agent can discuss issues in the same context as team chat. Use a separate agent group if the Linear team tracks sensitive work.
|
||||
|
||||
@@ -47,29 +47,7 @@ import './matrix.js';
|
||||
pnpm install @beeper/chat-adapter-matrix@0.2.0
|
||||
```
|
||||
|
||||
### 5. Patch matrix-js-sdk ESM imports
|
||||
|
||||
The adapter's published dist references `matrix-js-sdk/lib/...` without `.js`
|
||||
extensions, which fails under Node 22 strict ESM resolution. Add the missing
|
||||
extensions (idempotent — safe to re-run):
|
||||
|
||||
```bash
|
||||
node -e '
|
||||
const fs = require("fs"), path = require("path");
|
||||
const root = "node_modules/.pnpm";
|
||||
const dir = fs.readdirSync(root).find(d => d.startsWith("@beeper+chat-adapter-matrix@"));
|
||||
if (!dir) { console.log("Matrix adapter not installed"); process.exit(0); }
|
||||
const f = path.join(root, dir, "node_modules/@beeper/chat-adapter-matrix/dist/index.js");
|
||||
fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace(
|
||||
/from "(matrix-js-sdk\/lib\/[^"]+?)(?<!\.js)"/g, "from \"$1.js\""
|
||||
));
|
||||
console.log("Patched", f);
|
||||
'
|
||||
```
|
||||
|
||||
Re-run this after every `pnpm install` that touches the adapter.
|
||||
|
||||
### 6. Build
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
@@ -77,60 +55,25 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
The bot needs its own Matrix account — separate from the user's account. This is required because Matrix cannot send DMs to yourself.
|
||||
1. Register a bot account on your Matrix homeserver (e.g., via Element)
|
||||
2. Get the homeserver URL (e.g., `https://matrix.org` or your self-hosted URL)
|
||||
3. Get an access token:
|
||||
- In Element: **Settings** > **Help & About** > **Access Token** (advanced)
|
||||
- Or via API: `curl -XPOST 'https://matrix.org/_matrix/client/r0/login' -d '{"type":"m.login.password","user":"botuser","password":"..."}'`
|
||||
4. Note the bot's user ID (e.g., `@botuser:matrix.org`)
|
||||
|
||||
### Create a bot account
|
||||
### Configure environment
|
||||
|
||||
1. Open [app.element.io](https://app.element.io) in a private/incognito window (or sign out first)
|
||||
2. Register a new account for the bot (e.g. `andybot` on matrix.org)
|
||||
3. Note the bot's user ID (e.g. `@andybot:matrix.org`)
|
||||
|
||||
### Choose an auth method
|
||||
|
||||
**Option A: Username + Password (simpler)**
|
||||
|
||||
No extra steps — just use the bot account's credentials directly. The adapter logs in automatically.
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_USERNAME=andybot
|
||||
MATRIX_PASSWORD=your-bot-password
|
||||
MATRIX_USER_ID=@andybot:matrix.org
|
||||
MATRIX_BOT_USERNAME=Andy
|
||||
```
|
||||
|
||||
**Option B: Access Token (recommended for production)**
|
||||
|
||||
Get an access token from Element: sign into the bot account → **Settings** > **Help & About** > **Access Token** (under Advanced). Or via API:
|
||||
|
||||
```bash
|
||||
curl -XPOST 'https://matrix.org/_matrix/client/r0/login' \
|
||||
-d '{"type":"m.login.password","user":"andybot","password":"..."}'
|
||||
```
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
MATRIX_BASE_URL=https://matrix.org
|
||||
MATRIX_ACCESS_TOKEN=your-access-token
|
||||
MATRIX_USER_ID=@andybot:matrix.org
|
||||
MATRIX_BOT_USERNAME=Andy
|
||||
MATRIX_USER_ID=@botuser:matrix.org
|
||||
MATRIX_BOT_USERNAME=botuser
|
||||
```
|
||||
|
||||
### Optional settings
|
||||
|
||||
```bash
|
||||
MATRIX_INVITE_AUTOJOIN=true # Auto-accept room invites (default: true)
|
||||
MATRIX_INVITE_AUTOJOIN_ALLOWLIST=@you:matrix.org # Only accept invites from these users
|
||||
MATRIX_RECOVERY_KEY=your-recovery-key # Enable E2EE cross-signing
|
||||
MATRIX_DEVICE_ID=NANOCLAW01 # Stable device ID across restarts
|
||||
```
|
||||
|
||||
### Configure environment
|
||||
|
||||
Add the chosen env vars to `.env`, then sync:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
## Next Steps
|
||||
|
||||
@@ -142,7 +85,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
||||
|
||||
- **type**: `matrix`
|
||||
- **terminology**: Matrix has "rooms." A room can be a group chat or a direct message. Rooms have internal IDs (like `!abc123:matrix.org`) and optional aliases (like `#general:matrix.org`).
|
||||
- **how-to-find-id**: For DMs, use the bot's `openDM` to resolve the room automatically. For group rooms, in Element click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
|
||||
- **how-to-find-id**: In Element, click the room name > Settings > Advanced — the "Internal room ID" is the platform ID (starts with `!`). Or use a room alias like `#general:matrix.org`.
|
||||
- **supports-threads**: partial (some clients support threads, but not all — treat as no for reliability)
|
||||
- **typical-use**: Interactive chat — rooms or direct messages. Requires a separate bot account (the agent cannot DM users from their own account).
|
||||
- **typical-use**: Interactive chat — rooms or direct messages
|
||||
- **default-isolation**: Same agent group for rooms where you're the primary user. Separate agent group for rooms with different communities or sensitive contexts.
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: add-ollama-provider
|
||||
description: Route a NanoClaw agent group to a local Ollama model instead of the Anthropic API. Ollama speaks the Anthropic API natively (v1/messages), so no provider code changes are needed — just env var overrides and a model setting. Use when the user wants to run their agent locally, cut API costs, or experiment with open-weight models. See docs/ollama.md for background.
|
||||
---
|
||||
|
||||
# Add Ollama Provider
|
||||
|
||||
Routes an agent group to a local Ollama instance instead of the Anthropic API.
|
||||
See `docs/ollama.md` for how this works and the tradeoffs involved.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Ollama is installed and running** on the host — verify: `curl -s http://localhost:11434/api/tags`
|
||||
2. **A model is pulled** — e.g. `ollama pull gemma4` or `ollama pull qwen3-coder`
|
||||
3. **The agent group already exists** — run `/init-first-agent` first if needed
|
||||
|
||||
## 1. Check source support
|
||||
|
||||
The feature requires two fields in `ContainerConfig` (`env` and `blockedHosts`) and their
|
||||
corresponding wiring in `container-runner.ts`. Check if already present:
|
||||
|
||||
```bash
|
||||
grep -c 'blockedHosts' src/container-config.ts src/container-runner.ts
|
||||
```
|
||||
|
||||
If either count is 0, apply the changes in steps 1a and 1b. Otherwise skip to step 2.
|
||||
|
||||
### 1a. Extend ContainerConfig
|
||||
|
||||
In `src/container-config.ts`, add to the `ContainerConfig` interface:
|
||||
|
||||
```typescript
|
||||
env?: Record<string, string>;
|
||||
blockedHosts?: string[];
|
||||
```
|
||||
|
||||
And in `readContainerConfig`, add inside the returned object:
|
||||
|
||||
```typescript
|
||||
env: raw.env,
|
||||
blockedHosts: raw.blockedHosts,
|
||||
```
|
||||
|
||||
### 1b. Wire into container-runner
|
||||
|
||||
In `src/container-runner.ts`, after the `NANOCLAW_MCP_SERVERS` block, add:
|
||||
|
||||
```typescript
|
||||
// Per-agent-group env overrides — applied last to win over OneCLI values.
|
||||
if (containerConfig.env) {
|
||||
for (const [key, value] of Object.entries(containerConfig.env)) {
|
||||
args.push('-e', `${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Blocked hosts: resolve to 0.0.0.0 so they are unreachable inside the container.
|
||||
if (containerConfig.blockedHosts) {
|
||||
for (const host of containerConfig.blockedHosts) {
|
||||
args.push('--add-host', `${host}:0.0.0.0`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 1c. Fix home directory permissions (if not already done)
|
||||
|
||||
The container may run as your host uid (not uid 1000). Check the Dockerfile:
|
||||
|
||||
```bash
|
||||
grep 'chmod.*home/node' container/Dockerfile
|
||||
```
|
||||
|
||||
If it shows `chmod 755`, change it to `chmod 777` so any uid can write there.
|
||||
Then rebuild the container image: `./container/build.sh`
|
||||
|
||||
## 2. Identify the setup
|
||||
|
||||
Ask the user (plain text, not AskUserQuestion):
|
||||
|
||||
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
|
||||
2. **Which Ollama model?** List available: `curl -s http://localhost:11434/api/tags | grep '"name"'`
|
||||
3. **Block Anthropic API?** Recommended yes — prevents accidental spend if config drifts.
|
||||
|
||||
Record as `FOLDER`, `MODEL`, and `BLOCK_ANTHROPIC`.
|
||||
|
||||
## 3. Configure container.json
|
||||
|
||||
Read `groups/<FOLDER>/container.json`. Add (or merge into) an `env` block and optionally `blockedHosts`:
|
||||
|
||||
```json
|
||||
{
|
||||
"env": {
|
||||
"ANTHROPIC_BASE_URL": "http://host.docker.internal:11434",
|
||||
"ANTHROPIC_API_KEY": "ollama",
|
||||
"NO_PROXY": "host.docker.internal",
|
||||
"no_proxy": "host.docker.internal"
|
||||
},
|
||||
"blockedHosts": ["api.anthropic.com"]
|
||||
}
|
||||
```
|
||||
|
||||
Omit `blockedHosts` if the user declined step 2.
|
||||
|
||||
**Why these vars:** `ANTHROPIC_BASE_URL` redirects the Anthropic SDK to Ollama.
|
||||
`ANTHROPIC_API_KEY=ollama` satisfies the SDK's key requirement (Ollama ignores it).
|
||||
`NO_PROXY` bypasses the OneCLI HTTPS proxy for requests to `host.docker.internal`
|
||||
so they reach Ollama directly instead of going through the credential gateway.
|
||||
|
||||
## 4. Set the model
|
||||
|
||||
Read the agent group's shared Claude settings:
|
||||
|
||||
```bash
|
||||
# Find the agent group ID
|
||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
Add `"model": "<MODEL>"` to that settings file. Create the file if it doesn't exist:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gemma4:latest"
|
||||
}
|
||||
```
|
||||
|
||||
If the file already has content, merge the `model` key in — don't overwrite existing keys.
|
||||
|
||||
**Why here and not container.json:** Claude Code reads its model from its own settings
|
||||
file, not from env vars. This file is bind-mounted into the container as `~/.claude/settings.json`.
|
||||
|
||||
## 5. Build and restart
|
||||
|
||||
```bash
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
pnpm run build
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## 6. Verify
|
||||
|
||||
Send a message to the agent. Then confirm:
|
||||
|
||||
```bash
|
||||
# Ollama shows the model as active
|
||||
curl -s http://localhost:11434/api/ps | grep '"name"'
|
||||
|
||||
# Container has the right env vars
|
||||
CTR=$(docker ps --filter "name=nanoclaw-v2-<FOLDER>" --format "{{.Names}}" | head -1)
|
||||
docker inspect "$CTR" --format '{{json .HostConfig.ExtraHosts}}'
|
||||
docker exec "$CTR" env | grep ANTHROPIC
|
||||
```
|
||||
|
||||
Expected: `api.anthropic.com:0.0.0.0` in ExtraHosts, `ANTHROPIC_BASE_URL=http://host.docker.internal:11434`.
|
||||
|
||||
## Reverting to Claude
|
||||
|
||||
To switch back to the Anthropic API:
|
||||
|
||||
1. Remove the `env` and `blockedHosts` keys from `groups/<FOLDER>/container.json`
|
||||
2. Remove `"model"` from the shared settings file
|
||||
3. Restart the service
|
||||
|
||||
No rebuild needed — both files are read at container spawn time.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Agent hangs, no response:** Ollama may be loading the model cold (large models take 10–30s).
|
||||
Watch `curl -s http://localhost:11434/api/ps` — the model appears once loaded.
|
||||
|
||||
**"model not found" error in container logs:** The model name in settings.json doesn't match
|
||||
what Ollama has. Run `ollama list` on the host and use the exact name shown.
|
||||
|
||||
**Responses claim to be Claude:** The model was trained on data that includes Claude conversations.
|
||||
Add a line to `groups/<FOLDER>/CLAUDE.md` telling it what model it runs on.
|
||||
|
||||
**Agent responds but Ollama shows no activity:** `NO_PROXY` may not have taken effect for
|
||||
`http_proxy` (lowercase). Add both `NO_PROXY` and `no_proxy` to the env block.
|
||||
@@ -60,10 +60,10 @@ import './opencode.js';
|
||||
|
||||
### 4. Add the agent-runner dependency
|
||||
|
||||
Pinned. Bump deliberately, not with `bun update`. Use `1.4.17` — must match the `opencode-ai` CLI version pinned in step 5. The 1.14.x SDK has a completely different API and is **incompatible** with the current provider code.
|
||||
Pinned. Bump deliberately, not with `bun update`.
|
||||
|
||||
```bash
|
||||
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.17 && cd -
|
||||
cd container/agent-runner && bun add @opencode-ai/sdk@1.4.3 && cd -
|
||||
```
|
||||
|
||||
### 5. Add `opencode-ai` to the container Dockerfile
|
||||
@@ -73,11 +73,9 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present):
|
||||
**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG VERCEL_VERSION=latest`:
|
||||
|
||||
```dockerfile
|
||||
ARG OPENCODE_VERSION=1.4.17
|
||||
ARG OPENCODE_VERSION=latest
|
||||
```
|
||||
|
||||
> **Do not use `latest`** — the CLI and SDK must be the same version. `latest` silently upgrades the CLI to 1.14.x which has a breaking session API change (UUID session IDs → `ses_` prefix) incompatible with SDK 1.4.x.
|
||||
|
||||
**(b)** In the `pnpm install -g` block (around line 80), append `"opencode-ai@${OPENCODE_VERSION}"` to the list:
|
||||
|
||||
```dockerfile
|
||||
@@ -96,25 +94,6 @@ pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typ
|
||||
./container/build.sh # agent image
|
||||
```
|
||||
|
||||
> **Build cache gotcha:** The container buildkit caches COPY steps aggressively. If provider files were already present in the build context before, the new files may not be picked up. If you see "Unknown provider: opencode" after the build, prune the builder and rebuild:
|
||||
> ```bash
|
||||
> docker builder prune -f && ./container/build.sh
|
||||
> ```
|
||||
|
||||
### 7. Propagate to existing per-group overlays
|
||||
|
||||
Each agent group has a live source overlay at `data/v2-sessions/<group-id>/agent-runner-src/providers/` that **overrides the image at runtime**. This overlay is created when the group is first wired and never auto-updated by image rebuilds. Any group that already existed before this skill ran needs the new files copied in manually.
|
||||
|
||||
```bash
|
||||
for overlay in data/v2-sessions/*/agent-runner-src/providers/; do
|
||||
[ -d "$overlay" ] || continue
|
||||
cp container/agent-runner/src/providers/opencode.ts "$overlay"
|
||||
cp container/agent-runner/src/providers/mcp-to-opencode.ts "$overlay"
|
||||
cp container/agent-runner/src/providers/index.ts "$overlay"
|
||||
echo "Updated: $overlay"
|
||||
done
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Host `.env` (typical)
|
||||
@@ -123,62 +102,35 @@ Set model/provider strings in the form OpenCode expects (often `provider/model-i
|
||||
|
||||
These variables are read **on the host** and passed into the container only when the effective provider is `opencode`. They do not switch the provider by themselves; the DB still needs `agent_provider` set (below).
|
||||
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic`, `deepseek`.
|
||||
- `OPENCODE_MODEL` — full model id in `provider/model` form, e.g. `deepseek/deepseek-chat`.
|
||||
- `OPENCODE_SMALL_MODEL` — optional second model for lighter tasks; defaults to `OPENCODE_MODEL` if unset.
|
||||
- `ANTHROPIC_BASE_URL` — **required for non-`anthropic` providers.** The opencode container provider passes this as the `baseURL` for the upstream provider config so requests route through OneCLI's credential proxy or directly to the provider's API. Set it to the provider's API base URL (e.g. `https://api.deepseek.com/v1`, `https://openrouter.ai/api/v1`).
|
||||
- `OPENCODE_PROVIDER` — OpenCode provider id, e.g. `openrouter`, `anthropic` (if unset, the runner defaults to `anthropic`).
|
||||
- `OPENCODE_MODEL` — full model id, e.g. `openrouter/anthropic/claude-sonnet-4`.
|
||||
- `OPENCODE_SMALL_MODEL` — optional second model for "small" tasks.
|
||||
|
||||
Credentials: register provider API keys in OneCLI with the matching `--host-pattern` (e.g. `api.deepseek.com`, `openrouter.ai`). OneCLI injects them via `HTTPS_PROXY` in the container — the key never lives in `.env` or the container environment.
|
||||
|
||||
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||
|
||||
```bash
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
```env
|
||||
OPENCODE_PROVIDER=deepseek
|
||||
OPENCODE_MODEL=deepseek/deepseek-chat
|
||||
OPENCODE_SMALL_MODEL=deepseek/deepseek-chat
|
||||
ANTHROPIC_BASE_URL=https://api.deepseek.com/v1
|
||||
```
|
||||
|
||||
Register the key:
|
||||
```bash
|
||||
onecli secrets create --name "DeepSeek" --type generic \
|
||||
--value YOUR_KEY --host-pattern "api.deepseek.com" \
|
||||
--header-name "Authorization" --value-format "Bearer {value}"
|
||||
```
|
||||
Credentials: OneCLI / credential proxy patterns are unchanged. For non-`anthropic` OpenCode providers, the runner registers a placeholder API key and **`ANTHROPIC_BASE_URL`** (the credential proxy) as `baseURL` so the real key never lives in the container.
|
||||
|
||||
#### Example: OpenRouter
|
||||
|
||||
```env
|
||||
# OpenCode — host passes these into the container when agent_provider is opencode
|
||||
OPENCODE_PROVIDER=openrouter
|
||||
OPENCODE_MODEL=openrouter/anthropic/claude-sonnet-4
|
||||
OPENCODE_SMALL_MODEL=openrouter/anthropic/claude-haiku-4.5
|
||||
ANTHROPIC_BASE_URL=https://openrouter.ai/api/v1
|
||||
```
|
||||
|
||||
Register the key:
|
||||
```bash
|
||||
onecli secrets create --name "OpenRouter" --type generic \
|
||||
--value YOUR_KEY --host-pattern "openrouter.ai" \
|
||||
--header-name "Authorization" --value-format "Bearer {value}"
|
||||
```
|
||||
#### Example: Anthropic via existing proxy env
|
||||
|
||||
#### Example: Anthropic (no ANTHROPIC_BASE_URL needed)
|
||||
|
||||
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container — the proxy + placeholder key pattern is unchanged and `ANTHROPIC_BASE_URL` is not required.
|
||||
When `OPENCODE_PROVIDER` is `anthropic`, OpenCode uses normal Anthropic env inside the container (proxy + placeholder key pattern unchanged).
|
||||
|
||||
```env
|
||||
OPENCODE_PROVIDER=anthropic
|
||||
OPENCODE_MODEL=anthropic/claude-sonnet-4-20250514
|
||||
OPENCODE_SMALL_MODEL=anthropic/claude-haiku-4-5-20251001
|
||||
```
|
||||
|
||||
#### Example: only a main model
|
||||
|
||||
```env
|
||||
OPENCODE_PROVIDER=openrouter
|
||||
OPENCODE_MODEL=openrouter/google/gemini-2.5-pro-preview
|
||||
```
|
||||
|
||||
#### OpenCode Zen (`x-api-key`, not Bearer)
|
||||
@@ -190,9 +142,13 @@ Zen's HTTP API (e.g. `POST …/zen/v1/messages`) expects the key in the **`x-api
|
||||
**Host `.env` (typical Zen shape):**
|
||||
|
||||
```env
|
||||
# NanoClaw still resolves AGENT_PROVIDER from agent_groups / sessions; set agent_provider to opencode there.
|
||||
# OpenCode SDK: Zen as the upstream provider + models under opencode/…
|
||||
OPENCODE_PROVIDER=opencode
|
||||
OPENCODE_MODEL=opencode/big-pickle
|
||||
OPENCODE_SMALL_MODEL=opencode/big-pickle
|
||||
|
||||
# Point the credential proxy at Zen's Anthropic-compatible base URL (host + OneCLI must forward this host).
|
||||
ANTHROPIC_BASE_URL=https://opencode.ai/zen/v1
|
||||
```
|
||||
|
||||
@@ -206,6 +162,8 @@ onecli secrets create --name "OpenCode Zen" --type generic \
|
||||
--header-name "x-api-key" --value-format "{value}"
|
||||
```
|
||||
|
||||
For comparison, OpenRouter uses `Authorization` + `Bearer {value}`. Zen is different by design.
|
||||
|
||||
### Per group / per session
|
||||
|
||||
Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `opencode` for groups or sessions that should use OpenCode. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group).
|
||||
@@ -215,7 +173,7 @@ Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config
|
||||
## Operational notes
|
||||
|
||||
- OpenCode keeps a local **`opencode serve`** process and SSE subscription; the provider tears down with **`stream.return`** and **SIGKILL** on the server process on **`abort()`** / shared runtime reset to avoid MCP/zombie hangs.
|
||||
- Session continuation uses UUID format (SDK 1.4.x / CLI 1.4.x). Stale sessions are cleared by `isSessionInvalid` on OpenCode-specific error patterns. If you see UUID-related errors after an accidental CLI upgrade, clear `session_state` in `outbound.db` and wipe the `opencode-xdg` directory under the session folder.
|
||||
- Session continuation is opaque (`ses_*` ids); stale sessions are cleared using **`isSessionInvalid`** on OpenCode-specific errors (timeouts, connection resets, not-found patterns) in addition to the poll-loop's existing recovery.
|
||||
- **`NO_PROXY`** for localhost matters when the OpenCode client talks to `127.0.0.1` inside the container while HTTP(S)_PROXY is set (e.g. OneCLI).
|
||||
|
||||
## Verify
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: add-pdf-reader
|
||||
description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files.
|
||||
---
|
||||
|
||||
# Add PDF Reader
|
||||
|
||||
Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
1. Check if `container/skills/pdf-reader/pdf-reader` exists — skip to Phase 3 if already applied
|
||||
2. Confirm WhatsApp is installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/pdf-reader
|
||||
git merge whatsapp/skill/pdf-reader || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `container/skills/pdf-reader/SKILL.md` (agent-facing documentation)
|
||||
- `container/skills/pdf-reader/pdf-reader` (CLI script)
|
||||
- `poppler-utils` in `container/Dockerfile`
|
||||
- PDF attachment download in `src/channels/whatsapp.ts`
|
||||
- PDF tests in `src/channels/whatsapp.test.ts`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
pnpm exec 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.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: add-reactions
|
||||
description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.
|
||||
---
|
||||
|
||||
# Add Reactions
|
||||
|
||||
This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/status-tracker.ts` exists:
|
||||
|
||||
```bash
|
||||
test -f src/status-tracker.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/reactions
|
||||
git merge whatsapp/skill/reactions || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This adds:
|
||||
- `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes)
|
||||
- `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry)
|
||||
- `src/status-tracker.test.ts` (unit tests for StatusTracker)
|
||||
- `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool)
|
||||
- Reaction support in `src/db.ts`, `src/channels/whatsapp.ts`, `src/types.ts`, `src/ipc.ts`, `src/index.ts`, `src/group-queue.ts`, and `container/agent-runner/src/ipc-mcp-stdio.ts`
|
||||
|
||||
### Run database migration
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/migrate-reactions.ts
|
||||
```
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Build and restart
|
||||
|
||||
```bash
|
||||
pnpm 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
|
||||
@@ -0,0 +1,384 @@
|
||||
---
|
||||
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
|
||||
pnpm 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: `pnpm run build && ./container/build.sh && launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist` (macOS) or `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw` (Linux)
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
name: add-voice-transcription
|
||||
description: Add voice message transcription to NanoClaw using OpenAI's Whisper API. Automatically transcribes WhatsApp voice notes so the agent can read and respond to them.
|
||||
---
|
||||
|
||||
# Add Voice Transcription
|
||||
|
||||
This skill adds automatic voice message transcription to NanoClaw's WhatsApp channel using OpenAI's Whisper API. When a voice note arrives, it is downloaded, transcribed, and delivered to the agent as `[Voice: <transcript>]`.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/transcription.ts` exists. If it does, skip to Phase 3 (Configure). The code changes are already in place.
|
||||
|
||||
### Ask the user
|
||||
|
||||
Use `AskUserQuestion` to collect information:
|
||||
|
||||
AskUserQuestion: Do you have an OpenAI API key for Whisper transcription?
|
||||
|
||||
If yes, collect it now. If no, direct them to create one at https://platform.openai.com/api-keys.
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
**Prerequisite:** WhatsApp must be installed first (`skill/whatsapp` merged). This skill modifies WhatsApp channel files.
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/voice-transcription
|
||||
git merge whatsapp/skill/voice-transcription || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This merges in:
|
||||
- `src/transcription.ts` (voice transcription module using OpenAI Whisper)
|
||||
- Voice handling in `src/channels/whatsapp.ts` (isVoiceMessage check, transcribeAudioMessage call)
|
||||
- Transcription tests in `src/channels/whatsapp.test.ts`
|
||||
- `openai` npm dependency in `package.json`
|
||||
- `OPENAI_API_KEY` in `.env.example`
|
||||
|
||||
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
|
||||
|
||||
### Validate code changes
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/channels/whatsapp.test.ts
|
||||
```
|
||||
|
||||
All tests must pass and build must be clean before proceeding.
|
||||
|
||||
## Phase 3: Configure
|
||||
|
||||
### Get OpenAI API key (if needed)
|
||||
|
||||
If the user doesn't have an API key:
|
||||
|
||||
> I need you to create an OpenAI API key:
|
||||
>
|
||||
> 1. Go to https://platform.openai.com/api-keys
|
||||
> 2. Click "Create new secret key"
|
||||
> 3. Give it a name (e.g., "NanoClaw Transcription")
|
||||
> 4. Copy the key (starts with `sk-`)
|
||||
>
|
||||
> Cost: ~$0.006 per minute of audio (~$0.003 per typical 30-second voice note)
|
||||
|
||||
Wait for the user to provide the key.
|
||||
|
||||
### Add to environment
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=<their-key>
|
||||
```
|
||||
|
||||
Sync to container environment:
|
||||
|
||||
```bash
|
||||
mkdir -p data/env && cp .env data/env/env
|
||||
```
|
||||
|
||||
The container reads environment from `data/env/env`, not `.env` directly.
|
||||
|
||||
### Build and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test with a voice note
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Send a voice note in any registered WhatsApp chat. The agent should receive it as `[Voice: <transcript>]` and respond to its content.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep -i voice
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `Transcribed voice message` — successful transcription with character count
|
||||
- `OPENAI_API_KEY not set` — key missing from `.env`
|
||||
- `OpenAI transcription failed` — API error (check key validity, billing)
|
||||
- `Failed to download audio message` — media download issue
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Voice notes show "[Voice Message - transcription unavailable]"
|
||||
|
||||
1. Check `OPENAI_API_KEY` is set in `.env` AND synced to `data/env/env`
|
||||
2. Verify key works: `curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 200`
|
||||
3. Check OpenAI billing — Whisper requires a funded account
|
||||
|
||||
### Voice notes show "[Voice Message - transcription failed]"
|
||||
|
||||
Check logs for the specific error. Common causes:
|
||||
- Network timeout — transient, will work on next message
|
||||
- Invalid API key — regenerate at https://platform.openai.com/api-keys
|
||||
- Rate limiting — wait and retry
|
||||
|
||||
### Agent doesn't respond to voice notes
|
||||
|
||||
Verify the chat is registered and the agent is running. Voice transcription only runs for registered groups.
|
||||
@@ -1,49 +0,0 @@
|
||||
# Remove WeChat Channel
|
||||
|
||||
Undo `/add-wechat`.
|
||||
|
||||
### 1. Remove credentials
|
||||
|
||||
Delete WeChat lines from `.env`:
|
||||
|
||||
```bash
|
||||
sed -i.bak '/^WECHAT_ENABLED=/d' .env && rm -f .env.bak
|
||||
cp .env data/env/env
|
||||
```
|
||||
|
||||
### 2. Remove adapter and import
|
||||
|
||||
```bash
|
||||
rm -f src/channels/wechat.ts
|
||||
sed -i.bak "/import '\.\/wechat\.js';/d" src/channels/index.ts && rm -f src/channels/index.ts.bak
|
||||
```
|
||||
|
||||
### 3. Uninstall the package
|
||||
|
||||
```bash
|
||||
pnpm remove wechat-ilink-client
|
||||
```
|
||||
|
||||
### 4. Remove saved auth + sync state
|
||||
|
||||
```bash
|
||||
rm -rf data/wechat
|
||||
```
|
||||
|
||||
### 5. Remove DB wiring
|
||||
|
||||
```sql
|
||||
-- Remove any sessions first (foreign key)
|
||||
DELETE FROM sessions WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_group_agents WHERE messaging_group_id IN (SELECT id FROM messaging_groups WHERE channel_type = 'wechat');
|
||||
DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
```
|
||||
|
||||
### 6. Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
@@ -1,170 +0,0 @@
|
||||
---
|
||||
name: add-wechat
|
||||
description: Add WeChat (personal) channel integration via Tencent's official iLink Bot API. Uses long-polling and QR scan — no webhook, no ToS risk, no paid token.
|
||||
---
|
||||
|
||||
# Add WeChat Channel
|
||||
|
||||
Adds WeChat support via **iLink Bot API** — the first-party Tencent API for personal WeChat bots (different from WeCom / Official Account).
|
||||
|
||||
**Why this is different from wechaty/PadLocal:**
|
||||
|
||||
- Official Tencent API — no ToS violation, no ban risk
|
||||
- Free — no PadLocal token required
|
||||
- No public webhook URL needed — uses long-poll
|
||||
- Works with any personal WeChat account
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A **personal WeChat account** with the mobile app installed
|
||||
- A phone to scan the QR code for login
|
||||
- Node.js >= 20 (already required by NanoClaw)
|
||||
|
||||
## Install
|
||||
|
||||
NanoClaw doesn't ship channels in trunk. This skill copies the WeChat adapter in from the `channels` branch.
|
||||
|
||||
### Pre-flight (idempotent)
|
||||
|
||||
Skip to **Credentials** if all of these are already in place:
|
||||
|
||||
- `src/channels/wechat.ts` exists
|
||||
- `src/channels/index.ts` contains `import './wechat.js';`
|
||||
- `wechat-ilink-client` is listed in `package.json` dependencies
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
### 1. Fetch the channels branch
|
||||
|
||||
```bash
|
||||
git fetch origin channels
|
||||
```
|
||||
|
||||
### 2. Copy the adapter
|
||||
|
||||
```bash
|
||||
git show origin/channels:src/channels/wechat.ts > src/channels/wechat.ts
|
||||
```
|
||||
|
||||
### 3. Append the self-registration import
|
||||
|
||||
Append to `src/channels/index.ts` (skip if the line is already present):
|
||||
|
||||
```typescript
|
||||
import './wechat.js';
|
||||
```
|
||||
|
||||
### 4. Install the library (pinned)
|
||||
|
||||
```bash
|
||||
pnpm install wechat-ilink-client@0.1.0
|
||||
```
|
||||
|
||||
### 5. Build
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
## Credentials
|
||||
|
||||
Unlike most channels, WeChat requires **no pre-configured API keys**. Auth happens via QR code scan from your phone.
|
||||
|
||||
### 1. Enable the channel
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```bash
|
||||
WECHAT_ENABLED=true
|
||||
```
|
||||
|
||||
Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
Restart NanoClaw:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log | grep WeChat
|
||||
# or
|
||||
cat data/wechat/qr.txt
|
||||
```
|
||||
|
||||
Open the URL in a browser (it renders a QR code), then:
|
||||
|
||||
1. Open WeChat on your phone
|
||||
2. Use its built-in QR scanner (top-right "+" → Scan)
|
||||
3. Approve the authorization on your phone
|
||||
4. Auth credentials are saved to `data/wechat/auth.json` — do not commit this file
|
||||
|
||||
The bot is now connected as your WeChat account.
|
||||
|
||||
## Wire your first DM
|
||||
|
||||
A successful QR login alone isn't enough — the adapter still needs to be wired to an agent group before it can respond.
|
||||
|
||||
### 1. Trigger the first inbound message
|
||||
|
||||
Have a different WeChat account send a message to the bot account. This auto-creates a `messaging_groups` row with the sender's `platform_id`.
|
||||
|
||||
### 2. Run the wire script
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
```
|
||||
|
||||
Interactive flow: the script lists all unwired WeChat messaging groups, asks which agent group to wire it to, and creates the `messaging_group_agents` row with sensible defaults (sender policy `request_approval`, session mode `shared`).
|
||||
|
||||
With `request_approval`, the next DM from the stranger fires an approval card to the admin — admin taps Approve/Deny, approved users are added as members and their queued message replays through the agent.
|
||||
|
||||
Non-interactive:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts \
|
||||
--platform-id wechat:wxid_xxxxx \
|
||||
--agent-group ag-xxxxx \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
- `--platform-id <id>` — wire a specific messaging group (default: most recent unwired)
|
||||
- `--agent-group <id>` — target agent group (default: prompt; or solo admin group in non-interactive)
|
||||
- `--sender-policy public|strict|request_approval` — default `request_approval` (fires an admin approval card on unknown-sender DMs)
|
||||
- `--session-mode shared|per-thread` — default `shared`
|
||||
|
||||
### 3. Test
|
||||
|
||||
Have the sender message the bot again — the agent should respond.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Only one instance can use a given token at a time.** Don't run multiple NanoClaw instances pointing to the same `data/wechat/auth.json`.
|
||||
- **Re-login on session expiry:** if you see `WeChat: session expired` in logs, delete `data/wechat/auth.json` and restart — you'll be asked to re-scan.
|
||||
- **Sync cursor persistence:** `data/wechat/sync-buf.txt` holds the long-poll cursor. Deleting it replays recent history on next start; don't delete it in normal operation.
|
||||
- **Account safety:** this uses the official Tencent API, so account bans for bot automation aren't a risk. That said, don't spam — normal rate limits still apply.
|
||||
|
||||
## Next Steps
|
||||
|
||||
If you're in the middle of `/setup`, return to the setup flow now.
|
||||
|
||||
Otherwise, restart the service to pick up the new channel and wiring.
|
||||
|
||||
## Channel Info
|
||||
|
||||
- **type**: `wechat`
|
||||
- **terminology**: WeChat has "contacts" (DMs) and "group chats" (rooms). Each DM or group is a separate messaging group.
|
||||
- **how-to-find-id**: Send a message to the bot from the target account; the adapter auto-creates a messaging group and logs `WeChat inbound platformId=wechat:<id>`. Use `wechat:<user_id>` for DMs, `wechat:<group_id>` for rooms.
|
||||
- **admin-user-id**: The operator's WeChat user_id (for `init-first-agent.ts --admin-user-id`) is saved to `data/wechat/auth.json` as `operatorUserId` after the QR scan. Read it with `cat data/wechat/auth.json | jq -r .operatorUserId` and prefix with `wechat:` (i.e. `wechat:<operatorUserId>`).
|
||||
- **supports-threads**: no (WeChat has no reply threads)
|
||||
- **typical-use**: Long-poll — the adapter holds a persistent connection to Tencent's iLink API and receives messages in real time. No webhook URL needed.
|
||||
- **default-isolation**: `shared` session mode per messaging group (DM or room). Use `strict` sender policy if you want only specific users to reach the agent; `public` opens it to anyone who messages the bot.
|
||||
- **post-install-wiring**: Use the `wire-dm.ts` helper (see the "Wire your first DM" section above) if running this skill standalone. If running as part of `bash nanoclaw.sh`, `init-first-agent.ts` handles wiring — just pass the `platform-id` and `admin-user-id` captured above.
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env pnpm exec tsx
|
||||
/**
|
||||
* Wire a WeChat DM (or group) to an agent group.
|
||||
*
|
||||
* After /add-wechat installs the adapter and the user scans the QR login,
|
||||
* the first inbound message from another WeChat account auto-creates a
|
||||
* `messaging_groups` row. This script finds that row, asks the operator
|
||||
* which agent group to wire it to, and inserts the `messaging_group_agents`
|
||||
* join row with sensible defaults — the "post-login wiring" step /add-wechat
|
||||
* otherwise requires manual SQL for.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx .claude/skills/add-wechat/scripts/wire-dm.ts
|
||||
*
|
||||
* Flags:
|
||||
* --platform-id <id> Wire a specific messaging group (default: most recent unwired)
|
||||
* --agent-group <id> Target agent group (default: interactive pick; or solo admin group)
|
||||
* --sender-policy <p> public | strict (default: public)
|
||||
* --session-mode <m> shared | per-thread (default: shared)
|
||||
* --non-interactive Fail instead of prompting
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'node:path';
|
||||
import readline from 'node:readline';
|
||||
|
||||
const DB_PATH = process.env.NANOCLAW_DB_PATH ?? path.join(process.cwd(), 'data', 'v2.db');
|
||||
|
||||
type SenderPolicy = 'public' | 'strict' | 'request_approval';
|
||||
|
||||
interface Args {
|
||||
platformId?: string;
|
||||
agentGroupId?: string;
|
||||
senderPolicy: SenderPolicy;
|
||||
sessionMode: 'shared' | 'per-thread';
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const args: Args = {
|
||||
// Default matches the router's auto-create (`request_approval`) so the
|
||||
// admin gets an approval card on the next unknown-sender DM rather than
|
||||
// a silent allow. Pass `--sender-policy public` to open the channel to
|
||||
// anyone, or `strict` to require explicit membership.
|
||||
senderPolicy: 'request_approval',
|
||||
sessionMode: 'shared',
|
||||
interactive: true,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const flag = argv[i];
|
||||
const val = argv[i + 1];
|
||||
switch (flag) {
|
||||
case '--platform-id': args.platformId = val; i++; break;
|
||||
case '--agent-group': args.agentGroupId = val; i++; break;
|
||||
case '--sender-policy':
|
||||
if (val !== 'public' && val !== 'strict' && val !== 'request_approval') {
|
||||
throw new Error(`bad --sender-policy: ${val} (use public | strict | request_approval)`);
|
||||
}
|
||||
args.senderPolicy = val; i++; break;
|
||||
case '--session-mode':
|
||||
if (val !== 'shared' && val !== 'per-thread') throw new Error(`bad --session-mode: ${val}`);
|
||||
args.sessionMode = val; i++; break;
|
||||
case '--non-interactive': args.interactive = false; break;
|
||||
case '--help': case '-h':
|
||||
console.log('See .claude/skills/add-wechat/scripts/wire-dm.ts header for usage.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function prompt(q: string): Promise<string> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => rl.question(q, (a) => { rl.close(); resolve(a.trim()); }));
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// 1. Pick the messaging group
|
||||
let platformId = args.platformId;
|
||||
if (!platformId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT mg.id, mg.platform_id, mg.name, mg.is_group, mg.created_at
|
||||
FROM messaging_groups mg
|
||||
LEFT JOIN messaging_group_agents mga ON mga.messaging_group_id = mg.id
|
||||
WHERE mg.channel_type = 'wechat' AND mga.id IS NULL
|
||||
ORDER BY mg.created_at DESC
|
||||
`).all() as Array<{ id: string; platform_id: string; name: string | null; is_group: number; created_at: string }>;
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.error('No unwired WeChat messaging groups found.');
|
||||
console.error('Send a message to the bot first (from another WeChat account), then re-run.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (rows.length === 1 || !args.interactive) {
|
||||
platformId = rows[0].platform_id;
|
||||
console.log(`Using most recent unwired group: ${platformId} (${rows[0].is_group ? 'group' : 'DM'})`);
|
||||
} else {
|
||||
console.log('Unwired WeChat messaging groups:');
|
||||
rows.forEach((r, i) => {
|
||||
console.log(` ${i + 1}. ${r.platform_id} (${r.is_group ? 'group' : 'DM'}, ${r.created_at})`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= rows.length) throw new Error('invalid choice');
|
||||
platformId = rows[idx].platform_id;
|
||||
}
|
||||
}
|
||||
|
||||
const mg = db.prepare(
|
||||
'SELECT id, platform_id, is_group FROM messaging_groups WHERE channel_type = ? AND platform_id = ?'
|
||||
).get('wechat', platformId) as { id: string; platform_id: string; is_group: number } | undefined;
|
||||
if (!mg) throw new Error(`no wechat messaging_group with platform_id = ${platformId}`);
|
||||
|
||||
// 2. Pick the agent group
|
||||
let agentGroupId = args.agentGroupId;
|
||||
if (!agentGroupId) {
|
||||
const agents = db.prepare('SELECT id, name, is_admin FROM agent_groups ORDER BY is_admin DESC, created_at ASC')
|
||||
.all() as Array<{ id: string; name: string; is_admin: number }>;
|
||||
if (agents.length === 0) throw new Error('no agent groups exist — create one first');
|
||||
|
||||
const adminAgents = agents.filter((a) => a.is_admin === 1);
|
||||
if (adminAgents.length === 1 && !args.interactive) {
|
||||
agentGroupId = adminAgents[0].id;
|
||||
console.log(`Auto-selected sole admin agent group: ${adminAgents[0].name} (${agentGroupId})`);
|
||||
} else if (args.interactive) {
|
||||
console.log('Agent groups:');
|
||||
agents.forEach((a, i) => {
|
||||
console.log(` ${i + 1}. ${a.name} (${a.id})${a.is_admin ? ' [admin]' : ''}`);
|
||||
});
|
||||
const pick = await prompt('Pick one [1]: ');
|
||||
const idx = pick === '' ? 0 : parseInt(pick, 10) - 1;
|
||||
if (Number.isNaN(idx) || idx < 0 || idx >= agents.length) throw new Error('invalid choice');
|
||||
agentGroupId = agents[idx].id;
|
||||
} else {
|
||||
throw new Error('multiple agent groups exist; pass --agent-group <id>');
|
||||
}
|
||||
}
|
||||
|
||||
const ag = db.prepare('SELECT id, name FROM agent_groups WHERE id = ?').get(agentGroupId) as
|
||||
{ id: string; name: string } | undefined;
|
||||
if (!ag) throw new Error(`no agent_group with id = ${agentGroupId}`);
|
||||
|
||||
// 3. Update sender policy + wire
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare('UPDATE messaging_groups SET unknown_sender_policy = ? WHERE id = ?')
|
||||
.run(args.senderPolicy, mg.id);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO messaging_group_agents
|
||||
(id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now'))
|
||||
`).run(generateId('mga'), mg.id, ag.id, args.sessionMode);
|
||||
});
|
||||
tx();
|
||||
|
||||
console.log('');
|
||||
console.log(`WIRED platform_id=${mg.platform_id} agent_group=${ag.name} policy=${args.senderPolicy} mode=${args.sessionMode}`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('FAILED:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: channel-formatting
|
||||
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
|
||||
---
|
||||
|
||||
# Channel Formatting
|
||||
|
||||
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
|
||||
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
|
||||
Telegram.
|
||||
|
||||
| Channel | Transformation |
|
||||
|---------|---------------|
|
||||
| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links → `text (url)` |
|
||||
| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) |
|
||||
| Slack | same as WhatsApp, but links become `<url\|text>` |
|
||||
| Discord | passthrough (Discord already renders Markdown) |
|
||||
| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill |
|
||||
|
||||
Code blocks (fenced and inline) are always protected — their content is never transformed.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
test -f src/text-styles.ts && echo "already applied" || echo "not yet applied"
|
||||
```
|
||||
|
||||
If `already applied`, skip to Phase 3 (Verify).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure the upstream remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
|
||||
add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch upstream skill/channel-formatting
|
||||
git merge upstream/skill/channel-formatting
|
||||
```
|
||||
|
||||
If there are merge conflicts on `pnpm-lock.yaml`, resolve them by accepting the incoming
|
||||
version and continuing:
|
||||
|
||||
```bash
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
```
|
||||
|
||||
For any other conflict, read the conflicted file and reconcile both sides manually.
|
||||
|
||||
This merge adds:
|
||||
|
||||
- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and
|
||||
`parseSignalStyles(text)` for Signal native rich text
|
||||
- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided
|
||||
it calls `parseTextStyles` after stripping `<internal>` tags
|
||||
- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound`
|
||||
- `src/formatting.test.ts` — test coverage for both functions across all channels
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm run build
|
||||
pnpm exec vitest run src/formatting.test.ts
|
||||
```
|
||||
|
||||
All 73 tests should pass and the build should be clean before continuing.
|
||||
|
||||
## Phase 3: Verify
|
||||
|
||||
### Rebuild and restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
```
|
||||
|
||||
### Spot-check formatting
|
||||
|
||||
Send a message through any registered WhatsApp or Telegram chat that will trigger a
|
||||
response from Claude. Ask something that will produce formatted output, such as:
|
||||
|
||||
> Summarise the three main advantages of TypeScript using bullet points and **bold** headings.
|
||||
|
||||
Confirm that the response arrives with native bold (`*text*`) rather than raw double
|
||||
asterisks.
|
||||
|
||||
### Check logs if needed
|
||||
|
||||
```bash
|
||||
tail -f logs/nanoclaw.log
|
||||
```
|
||||
|
||||
## Signal Skill Integration
|
||||
|
||||
If you have the Signal skill installed, `src/channels/signal.ts` can import
|
||||
`parseSignalStyles` from the newly present `src/text-styles.ts`:
|
||||
|
||||
```typescript
|
||||
import { parseSignalStyles, SignalTextStyle } from '../text-styles.js';
|
||||
```
|
||||
|
||||
`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where
|
||||
`textStyle` is an array of `{ style, start, length }` objects suitable for the
|
||||
`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`).
|
||||
|
||||
## Removal
|
||||
|
||||
```bash
|
||||
# Remove the new file
|
||||
rm src/text-styles.ts
|
||||
|
||||
# Revert router.ts to remove the channel param
|
||||
git diff upstream/main src/router.ts # review changes
|
||||
git checkout upstream/main -- src/router.ts
|
||||
|
||||
# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText)
|
||||
# (edit manually or: git checkout upstream/main -- src/index.ts)
|
||||
|
||||
pnpm run build
|
||||
```
|
||||
@@ -87,17 +87,18 @@ The script:
|
||||
2. Creates the `agent_groups` row and calls `initGroupFilesystem` at `groups/dm-with-<name>/`.
|
||||
3. Reuses or creates the DM `messaging_groups` row.
|
||||
4. Wires them via `messaging_group_agents` (which auto-creates the companion `agent_destinations` row).
|
||||
5. Hands the welcome message to the running service via its CLI socket (`data/cli.sock`), targeting the DM messaging group. The service routes it into the DM session, which wakes the container synchronously. If the socket isn't reachable (service down), falls back to a direct `inbound.db` write that the next host sweep picks up.
|
||||
5. Resolves the session (creates `inbound.db` / `outbound.db`).
|
||||
6. Writes a `kind: 'chat'`, `sender: 'system'` welcome message into `inbound.db`.
|
||||
|
||||
Show the script's output to the user.
|
||||
|
||||
## 5. Verify
|
||||
|
||||
The welcome DM is queued synchronously; the only wait is container cold-start (~60s on first launch) before the agent processes the message and the reply flows through `outbound.db` to the channel.
|
||||
Host sweep runs every ~60s. Within one sweep window the container wakes, the agent processes the system message, and the reply flows through `outbound.db` to the channel.
|
||||
|
||||
Do not tail the log or poll in a sleep loop. Ask the user in plain text:
|
||||
|
||||
> The welcome DM should arrive shortly. Let me know when you've received it (or if it doesn't arrive within two minutes).
|
||||
> The welcome DM should arrive within ~60 seconds. Let me know when you've received it (or if it doesn't arrive within two minutes).
|
||||
|
||||
Wait for the user's reply. If they confirm receipt, the skill is done.
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ rm /tmp/nanoclaw-diagnostics.json
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Remove the diagnostics sections from each corresponding SKILL.md
|
||||
4. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Replace contents of `.claude/skills/migrate-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
4. Remove the diagnostics sections from each corresponding SKILL.md
|
||||
5. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
@@ -1,10 +1,350 @@
|
||||
---
|
||||
name: setup
|
||||
description: Run initial NanoClaw setup. Use when user wants to install NanoClaw, configure it, or go through first-time setup. Triggers on "setup", "install", "configure nanoclaw", or first-time setup requests.
|
||||
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.
|
||||
allowed-tools: Bash(git remote*), Bash(bash setup.sh), Bash(pnpm exec tsx setup/index.ts*)
|
||||
---
|
||||
|
||||
# NanoClaw Setup
|
||||
|
||||
Tell the user to run `bash nanoclaw.sh` in their terminal. That script handles the full end-to-end setup — dependencies, container image, OneCLI vault, Anthropic credential, service, first agent, and optional channel wiring.
|
||||
Welcome the user to NanoClaw. Introduce yourself — you'll be walking them through the entire setup process step by step, from installing dependencies to getting their first message through. Keep it warm and brief (2-3 sentences).
|
||||
|
||||
If they hit an error partway through, it will offer Claude-assisted recovery inline — no need to come back here.
|
||||
Then explain that setup involves running many shell commands (installing packages, building containers, starting services), and recommend pre-approving the standard setup commands so they don't have to confirm each one individually.
|
||||
|
||||
Use `AskUserQuestion` with these options:
|
||||
|
||||
1. **Pre-approve (recommended)** — description: "Pre-approve standard setup commands so you don't have to confirm each one. You can review the list first if you'd like."
|
||||
2. **No thanks** — description: "I'll approve each command individually as it comes up."
|
||||
3. **Show me the list first** — description: "Show me exactly which commands will be pre-approved before I decide."
|
||||
|
||||
If they pick option 1: read `.claude/skills/setup/setup-permissions.json`, then read the project settings file at `.claude/settings.json` (create it if it doesn't exist with `{}`), and directly edit it to add/merge the permissions into the `permissions.allow` array. Do NOT use the `update-config` skill.
|
||||
|
||||
If they pick option 3: read and display `.claude/skills/setup/setup-permissions.json`, then re-ask with just options 1 and 2.
|
||||
|
||||
If they decline, continue — they'll approve commands individually.
|
||||
|
||||
---
|
||||
|
||||
**Internal guidance (do not show to user):**
|
||||
|
||||
- Run setup steps automatically. Only pause when user action is required (channel authentication, configuration choices).
|
||||
- Setup uses `bash setup.sh` for bootstrap, then `npx tsx setup/index.ts --step <name>` for all other steps. Steps emit structured status blocks to stdout. Verbose logs go to `logs/setup.log`.
|
||||
- **Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair.
|
||||
- **UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "which credential method?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply.
|
||||
- **Timeouts:** Use 5m timeouts for install and build steps.
|
||||
- **Waiting on user:** When the user needs to do something (change a setting, get a token, open a browser, etc.), stop and wait. Give clear instructions, then say "Let me know when done or if you need help." Do NOT continue to the next step. If they ask for help, give more detail, ask where they got stuck, and try to assist.
|
||||
|
||||
## 0. Git Upstream
|
||||
|
||||
Ensure `upstream` remote points to `qwibitai/nanoclaw`. If missing, add it silently.
|
||||
|
||||
```!
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
|
||||
git remote -v
|
||||
```
|
||||
|
||||
## 1. Bootstrap (Node.js + Dependencies)
|
||||
|
||||
Output from !`bash setup.sh` — parse the status block.
|
||||
|
||||
- If NODE_OK=false → Node.js is missing or too old. Use `AskUserQuestion: Would you like me to install Node.js 22?` If confirmed:
|
||||
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
|
||||
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
|
||||
- After installing Node, re-run `bash setup.sh`
|
||||
- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
|
||||
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
|
||||
- Record PLATFORM and IS_WSL for later steps.
|
||||
|
||||
## 2. Check Environment
|
||||
|
||||
Output from !`pnpm exec tsx setup/index.ts --step environment` — parse the status block.
|
||||
|
||||
- If HAS_AUTH=true → WhatsApp is already configured, note for step 5
|
||||
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
|
||||
- Record DOCKER value for step 3
|
||||
|
||||
### OpenClaw Migration Detection
|
||||
|
||||
If OPENCLAW_PATH is not `none` from the environment check above, AskUserQuestion:
|
||||
|
||||
1. **Migrate now** — "Import identity, credentials, and settings from OpenClaw before continuing setup."
|
||||
2. **Fresh start** — "Skip migration and set up NanoClaw from scratch."
|
||||
3. **Migrate later** — "Continue setup now, run `/migrate-from-openclaw` anytime later."
|
||||
|
||||
If "Migrate now": invoke `/migrate-from-openclaw`, then return here and continue at step 2a (Timezone).
|
||||
|
||||
## 2a. Timezone
|
||||
|
||||
Output from !`pnpm exec tsx setup/index.ts --step timezone` — parse the status block.
|
||||
|
||||
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `pnpm exec tsx setup/index.ts --step timezone -- --tz <their-answer>`.
|
||||
- If STATUS=success and RESOLVED_TZ is `UTC` or `Etc/UTC` → confirm with the user: "Your system timezone is UTC — is that correct, or are you on a remote server?" If wrong, ask for their actual timezone and re-run with `--tz`.
|
||||
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
|
||||
|
||||
## 3. Container Runtime (Docker)
|
||||
|
||||
### 3a. Install Docker
|
||||
|
||||
- DOCKER=running → continue to step 4
|
||||
- DOCKER=installed_not_running → start Docker: `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Wait 15s, re-check with `docker info`.
|
||||
- DOCKER=not_found → Use `AskUserQuestion: Docker is required for running agents. Would you like me to install it?` If confirmed:
|
||||
- macOS: install via `brew install --cask docker`, then `open -a Docker` and wait for it to start. If brew not available, direct to Docker Desktop download at https://docker.com/products/docker-desktop
|
||||
- Linux: install with `curl -fsSL https://get.docker.com | sh && sudo usermod -aG docker $USER`. Note: user may need to log out/in for group membership.
|
||||
|
||||
### 3b. CJK fonts
|
||||
|
||||
Agent containers skip CJK fonts by default (~200MB saved). Without them, Chromium-rendered screenshots and PDFs show tofu for Chinese/Japanese/Korean.
|
||||
|
||||
- **User writing to you in Chinese, Japanese, or Korean** → enable without asking. Mention it briefly.
|
||||
- **Resolved timezone from step 2a is a CJK region** (`Asia/Tokyo`, `Asia/Shanghai`, `Asia/Hong_Kong`, `Asia/Taipei`, `Asia/Seoul`) or other signal short of active CJK use → ask: "Enable CJK fonts? Adds ~200MB, lets the agent render CJK in screenshots and PDFs."
|
||||
- **Otherwise** → skip.
|
||||
|
||||
To enable, write `INSTALL_CJK_FONTS=true` to `.env`:
|
||||
|
||||
```bash
|
||||
grep -q '^INSTALL_CJK_FONTS=' .env && sed -i.bak 's/^INSTALL_CJK_FONTS=.*/INSTALL_CJK_FONTS=true/' .env && rm -f .env.bak || echo 'INSTALL_CJK_FONTS=true' >> .env
|
||||
```
|
||||
|
||||
The next step's build picks it up automatically.
|
||||
|
||||
### 3c. Build and test
|
||||
|
||||
Run `pnpm exec tsx setup/index.ts --step container -- --runtime docker` and parse the status block.
|
||||
|
||||
**If BUILD_OK=false:** Read `logs/setup.log` tail for the build error.
|
||||
- Cache issue (stale layers): `docker builder prune -f`. Retry.
|
||||
- Dockerfile syntax or missing files: diagnose from the log and fix, then retry.
|
||||
|
||||
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
|
||||
|
||||
## 4. Credential System
|
||||
|
||||
### 4a. OneCLI
|
||||
|
||||
Install OneCLI and its CLI tool:
|
||||
|
||||
```bash
|
||||
curl -fsSL onecli.sh/install | sh
|
||||
curl -fsSL onecli.sh/cli/install | sh
|
||||
```
|
||||
|
||||
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Persist for future sessions (append to shell profile if not already present)
|
||||
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
|
||||
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
|
||||
```
|
||||
|
||||
Then re-verify with `onecli version`.
|
||||
|
||||
Point the CLI at the local OneCLI instance, the ONECLI_URL was output from the install script above:
|
||||
```bash
|
||||
onecli config set api-host ${ONECLI_URL}
|
||||
```
|
||||
|
||||
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
|
||||
```bash
|
||||
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=${ONECLI_URL}' >> .env
|
||||
```
|
||||
|
||||
Check if a secret already exists:
|
||||
```bash
|
||||
onecli secrets list
|
||||
```
|
||||
|
||||
If an Anthropic secret is listed, confirm with user: keep or reconfigure? If keeping, skip to step 5.
|
||||
|
||||
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
|
||||
|
||||
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
|
||||
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
|
||||
|
||||
#### Subscription path
|
||||
|
||||
Tell the user:
|
||||
|
||||
> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here.
|
||||
|
||||
Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond.
|
||||
|
||||
Once they confirm, they register it with OneCLI. AskUserQuestion with two options:
|
||||
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
|
||||
|
||||
#### API key path
|
||||
|
||||
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
|
||||
|
||||
Then AskUserQuestion with two options:
|
||||
|
||||
1. **Dashboard** — description: "Best if you have a browser on this machine. Open ${ONECLI_URL} and add the secret in the UI."
|
||||
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
|
||||
|
||||
#### After either path
|
||||
|
||||
Ask them to let you know when done.
|
||||
|
||||
**If the user's response happens to contain a token or key** (starts with `sk-ant-`): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
|
||||
|
||||
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
|
||||
|
||||
## 5. Set Up Channels
|
||||
|
||||
Show the full list of available channels in plain text (do NOT use AskUserQuestion — it limits to 4 options). Ask which one they want to start with. They can add more later with `/customize`.
|
||||
|
||||
Channels where the agent gets its own identity (name and avatar) are marked as recommended.
|
||||
|
||||
1. Discord *(recommended — agent gets own identity)*
|
||||
2. Slack *(recommended — agent gets own identity)*
|
||||
3. Telegram *(recommended — agent gets own identity)*
|
||||
4. Microsoft Teams *(recommended — agent gets own identity)*
|
||||
5. Webex *(recommended — agent gets own identity)*
|
||||
6. WhatsApp
|
||||
7. WhatsApp Cloud API
|
||||
8. iMessage
|
||||
9. GitHub
|
||||
10. Linear
|
||||
11. Google Chat
|
||||
12. Resend (email)
|
||||
13. Matrix
|
||||
|
||||
**Delegate to the selected channel's skill.** Each channel skill handles its own package installation, authentication, registration, and configuration.
|
||||
|
||||
Invoke the matching skill:
|
||||
|
||||
- **Discord:** Invoke `/add-discord`
|
||||
- **Slack:** Invoke `/add-slack`
|
||||
- **Telegram:** Invoke `/add-telegram`
|
||||
- **GitHub:** Invoke `/add-github`
|
||||
- **Linear:** Invoke `/add-linear`
|
||||
- **Microsoft Teams:** Invoke `/add-teams`
|
||||
- **Google Chat:** Invoke `/add-gchat`
|
||||
- **WhatsApp Cloud API:** Invoke `/add-whatsapp-cloud`
|
||||
- **WhatsApp Baileys:** Invoke `/add-whatsapp`
|
||||
- **Resend:** Invoke `/add-resend`
|
||||
- **Matrix:** Invoke `/add-matrix`
|
||||
- **Webex:** Invoke `/add-webex`
|
||||
- **iMessage:** Invoke `/add-imessage`
|
||||
|
||||
The skill will:
|
||||
1. Install the Chat SDK adapter package
|
||||
2. Uncomment the channel import in `src/channels/index.ts`
|
||||
3. Collect credentials/tokens and write to `.env`
|
||||
4. Build and verify
|
||||
|
||||
**After the channel skill completes**, install dependencies and rebuild — channel merges may introduce new packages:
|
||||
|
||||
```bash
|
||||
pnpm install && pnpm run build
|
||||
```
|
||||
|
||||
If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 5a.
|
||||
|
||||
## 6. Mount Allowlist
|
||||
|
||||
Set empty mount allowlist (agents only access their own workspace). Users can configure mounts later with `/manage-mounts`.
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step mounts -- --empty
|
||||
```
|
||||
|
||||
## 7. Start Service
|
||||
|
||||
If service already running: unload first.
|
||||
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
|
||||
- Linux: `systemctl --user stop nanoclaw` (or `systemctl stop nanoclaw` if root)
|
||||
|
||||
Run `pnpm exec tsx setup/index.ts --step service` and parse the status block.
|
||||
|
||||
**If FALLBACK=wsl_no_systemd:** WSL without systemd detected. Tell user they can either enable systemd in WSL (`echo -e "[boot]\nsystemd=true" | sudo tee /etc/wsl.conf` then restart WSL) or use the generated `start-nanoclaw.sh` wrapper.
|
||||
|
||||
**If DOCKER_GROUP_STALE=true:** The user was added to the docker group after their session started — the systemd service can't reach the Docker socket. Ask user to run these two commands:
|
||||
|
||||
1. Immediate fix: `sudo setfacl -m u:$(whoami):rw /var/run/docker.sock`
|
||||
2. Persistent fix (re-applies after every Docker restart):
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/docker.service.d
|
||||
sudo tee /etc/systemd/system/docker.service.d/socket-acl.conf << 'EOF'
|
||||
[Service]
|
||||
ExecStartPost=/usr/bin/setfacl -m u:USERNAME:rw /var/run/docker.sock
|
||||
EOF
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo` commands separately — the `tee` heredoc first, then `daemon-reload`. After user confirms setfacl ran, re-run the service step.
|
||||
|
||||
**If SERVICE_LOADED=false:**
|
||||
- Read `logs/setup.log` for the error.
|
||||
- macOS: check `launchctl list | grep nanoclaw`. If PID=`-` and status non-zero, read `logs/nanoclaw.error.log`.
|
||||
- Linux: check `systemctl --user status nanoclaw`.
|
||||
- Re-run the service step after fixing.
|
||||
|
||||
## 7a. Wire Channels to Agent Groups
|
||||
|
||||
The service is now running, so polling-based adapters (Telegram) can observe inbound messages — required for pairing.
|
||||
|
||||
Invoke `/manage-channels` to wire the installed channels to agent groups. This step:
|
||||
1. Creates the agent group(s) and assigns a name to the assistant
|
||||
2. Resolves each channel's platform-specific ID (Telegram via pairing code; other channels via the platform's own ID lookup)
|
||||
3. Decides the isolation level — whether channels share an agent, session, or are fully separate
|
||||
|
||||
The `/manage-channels` skill reads each channel's `## Channel Info` section from its SKILL.md for platform-specific guidance (terminology, how to find IDs, recommended isolation).
|
||||
|
||||
**This step is required.** Without it, channels are installed but not wired — messages will be silently dropped because the router has no agent group to route to.
|
||||
|
||||
## 7b. Dashboard & Web Applications
|
||||
|
||||
AskUserQuestion: Do you want to create a dashboard and build web applications?
|
||||
|
||||
1. **Yes (recommended)** — description: "Get a NanoClaw dashboard to monitor your agents and build custom websites however you want. Deploys to Vercel."
|
||||
2. **Not now** — description: "You can add this later with `/add-vercel`."
|
||||
|
||||
If yes: invoke `/add-vercel`.
|
||||
|
||||
## 8. Verify
|
||||
|
||||
Run `pnpm exec tsx setup/index.ts --step verify` and parse the status block.
|
||||
|
||||
**If STATUS=failed, fix each:**
|
||||
- SERVICE=stopped → `pnpm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
|
||||
- SERVICE=not_found → re-run step 7
|
||||
- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list`)
|
||||
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
|
||||
- REGISTERED_GROUPS=0 → re-invoke `/manage-channels` from step 7a
|
||||
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (check `curl ${ONECLI_URL}/api/health`), missing channel credentials (re-invoke channel skill).
|
||||
|
||||
**Container agent fails ("Claude Code process exited with code 1"):** Ensure Docker is running — `open -a Docker` (macOS) or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
|
||||
|
||||
**No response to messages:** Check trigger pattern. Main channel doesn't need prefix. Check DB: `pnpm exec tsx setup/index.ts --step verify`. Check `logs/nanoclaw.log`.
|
||||
|
||||
**Channel not connecting:** Verify the channel's credentials are set in `.env`. Channels auto-enable when their credentials are present. For WhatsApp: check `store/auth/creds.json` exists. For token-based channels: check token values in `.env`. Restart the service after any `.env` change.
|
||||
|
||||
**Unload service:** macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist` | Linux: `systemctl --user stop nanoclaw`
|
||||
|
||||
|
||||
## 9. Diagnostics
|
||||
|
||||
1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
|
||||
2. Follow every step in that file before completing setup.
|
||||
|
||||
## 10. Fork Setup
|
||||
|
||||
Only run this after the user has confirmed 2-way messaging works.
|
||||
|
||||
Check `git remote -v`. If `origin` points to `qwibitai/nanoclaw` (not a fork), ask in plain text:
|
||||
|
||||
> We recommend forking NanoClaw so you can push your customizations and pull updates easily. Would you like to set up a fork now?
|
||||
|
||||
If yes: instruct the user to fork `qwibitai/nanoclaw` on GitHub (they need to do this in their browser), then ask 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
|
||||
```
|
||||
|
||||
If no: skip — upstream is already configured from step 0.
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Diagnostics
|
||||
|
||||
Gather system info:
|
||||
|
||||
```bash
|
||||
node -p "require('./package.json').version"
|
||||
uname -s
|
||||
uname -m
|
||||
node -p "process.versions.node.split('.')[0]"
|
||||
```
|
||||
|
||||
Check if the user migrated from OpenClaw during this setup session (i.e. `/migrate-from-openclaw` was invoked). If you're unsure (e.g. after context compaction), check for `migration-state.md` in the project root — it exists during and sometimes after migration.
|
||||
|
||||
Write `/tmp/nanoclaw-diagnostics.json`. No paths, usernames, hostnames, or IP addresses.
|
||||
|
||||
```json
|
||||
{
|
||||
"api_key": "phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP",
|
||||
"event": "setup_complete",
|
||||
"distinct_id": "<uuid>",
|
||||
"properties": {
|
||||
"success": true,
|
||||
"nanoclaw_version": "1.2.21",
|
||||
"os_platform": "darwin",
|
||||
"arch": "arm64",
|
||||
"node_major_version": 22,
|
||||
"channels_selected": ["telegram", "whatsapp"],
|
||||
"migrated_from_openclaw": false,
|
||||
"error_count": 0,
|
||||
"failed_step": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Show the entire JSON to the user and ask via AskUserQuestion: **Yes** / **No** / **Never ask again**
|
||||
|
||||
**Yes**:
|
||||
```bash
|
||||
curl -s -X POST https://us.i.posthog.com/capture/ -H 'Content-Type: application/json' -d @/tmp/nanoclaw-diagnostics.json
|
||||
rm /tmp/nanoclaw-diagnostics.json
|
||||
```
|
||||
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
|
||||
4. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
@@ -0,0 +1,34 @@
|
||||
[
|
||||
"Bash(bash setup.sh*)",
|
||||
"Bash(git remote *)",
|
||||
"Bash(npx tsx setup/index.ts*)",
|
||||
"Bash(npx tsx scripts/init-first-agent.ts*)",
|
||||
"Bash(npm install @chat-adapter/*)",
|
||||
"Bash(npm install chat-adapter-imessage*)",
|
||||
"Bash(npm install @bitbasti/chat-adapter-webex*)",
|
||||
"Bash(npm install @resend/chat-sdk-adapter*)",
|
||||
"Bash(npm install @whiskeysockets/baileys*)",
|
||||
"Bash(npm install @beeper/chat-adapter-matrix*)",
|
||||
"Bash(npm install @nanoco/nanoclaw-dashboard*)",
|
||||
"Bash(npm ci*)",
|
||||
"Bash(npm run build*)",
|
||||
"Bash(curl -fsSL onecli.sh*)",
|
||||
"Bash(onecli *)",
|
||||
"Bash(grep -q *)",
|
||||
"Bash(echo *>> .env)",
|
||||
"Bash(ls *)",
|
||||
"Bash(cat ~/.config/nanoclaw/*)",
|
||||
"Bash(tail *logs/*)",
|
||||
"Bash(launchctl *nanoclaw*)",
|
||||
"Bash(sqlite3 data/*)",
|
||||
"Bash(docker info*)",
|
||||
"Bash(docker logs *)",
|
||||
"Bash(mkdir -p *)",
|
||||
"Bash(cp .env *)",
|
||||
"Bash(rsync -a .claude/skills/*)",
|
||||
"Bash(head *)",
|
||||
"Bash(xattr *)",
|
||||
"Bash(find ~/.npm *)",
|
||||
"Bash(which onecli*)",
|
||||
"Bash(./container/build.sh*)"
|
||||
]
|
||||
@@ -43,6 +43,7 @@ rm /tmp/nanoclaw-diagnostics.json
|
||||
**No**: `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
**Never ask again**:
|
||||
1. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Remove the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
|
||||
3. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
1. Replace contents of `.claude/skills/setup/diagnostics.md` with `# Diagnostics — opted out`
|
||||
2. Replace contents of `.claude/skills/update-nanoclaw/diagnostics.md` with `# Diagnostics — opted out`
|
||||
3. Remove the `## 9. Diagnostics` section from `.claude/skills/setup/SKILL.md` and the `## Diagnostics` section from `.claude/skills/update-nanoclaw/SKILL.md`
|
||||
4. `rm /tmp/nanoclaw-diagnostics.json`
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
name: use-local-whisper
|
||||
description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first.
|
||||
---
|
||||
|
||||
# Use Local Whisper
|
||||
|
||||
Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost.
|
||||
|
||||
**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them.
|
||||
|
||||
**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `voice-transcription` skill must be applied first (WhatsApp channel)
|
||||
- macOS with Apple Silicon (M1+) recommended
|
||||
- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary)
|
||||
- `ffmpeg` installed: `brew install ffmpeg`
|
||||
- A GGML model file downloaded to `data/models/`
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
Check if `src/transcription.ts` already uses `whisper-cli`:
|
||||
|
||||
```bash
|
||||
grep 'whisper-cli' src/transcription.ts && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
### Check dependencies are installed
|
||||
|
||||
```bash
|
||||
whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING"
|
||||
ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING"
|
||||
```
|
||||
|
||||
If missing, install via Homebrew:
|
||||
```bash
|
||||
brew install whisper-cpp ffmpeg
|
||||
```
|
||||
|
||||
### Check for model file
|
||||
|
||||
```bash
|
||||
ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL"
|
||||
```
|
||||
|
||||
If no model exists, download the base model (148MB, good balance of speed and accuracy):
|
||||
```bash
|
||||
mkdir -p data/models
|
||||
curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"
|
||||
```
|
||||
|
||||
For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB).
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
|
||||
### Ensure WhatsApp fork remote
|
||||
|
||||
```bash
|
||||
git remote -v
|
||||
```
|
||||
|
||||
If `whatsapp` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
|
||||
```bash
|
||||
git fetch whatsapp skill/local-whisper
|
||||
git merge whatsapp/skill/local-whisper || {
|
||||
git checkout --theirs pnpm-lock.yaml
|
||||
git add pnpm-lock.yaml
|
||||
git merge --continue
|
||||
}
|
||||
```
|
||||
|
||||
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
|
||||
|
||||
### Validate
|
||||
|
||||
```bash
|
||||
pnpm 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
|
||||
pnpm 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`.
|
||||
+7
-8
@@ -11,19 +11,18 @@ store/
|
||||
data/
|
||||
logs/
|
||||
|
||||
# Groups - per-installation state, not tracked
|
||||
# Groups - only track base structure and specific CLAUDE.md files
|
||||
groups/*
|
||||
|
||||
# Composer-managed CLAUDE.md artifacts (regenerated every spawn) and
|
||||
# per-group memory (CLAUDE.local.md) must never be committed.
|
||||
**/CLAUDE.local.md
|
||||
**/.claude-shared.md
|
||||
**/.claude-fragments/
|
||||
!groups/main/
|
||||
!groups/global/
|
||||
groups/main/*
|
||||
groups/global/*
|
||||
!groups/main/CLAUDE.md
|
||||
!groups/global/CLAUDE.md
|
||||
|
||||
# Secrets
|
||||
*.keys.json
|
||||
.env
|
||||
.env*
|
||||
|
||||
# Temp files
|
||||
.tmp-*
|
||||
|
||||
@@ -4,21 +4,6 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
|
||||
## [2.0.0] - 2026-04-22
|
||||
|
||||
Major version. NanoClaw v2 is a substantial architectural rewrite. Existing forks should run `/migrate-nanoclaw` (clean-base replay of customizations) or `/update-nanoclaw` (selective cherry-pick) before resuming work.
|
||||
|
||||
- [BREAKING] **New entity model.** Users, roles (owner/admin), messaging groups, and agent groups are now tracked as separate entities, wired via `messaging_group_agents`. Privilege is user-level instead of channel-level, so the old "main channel = admin" concept is retired. See [docs/architecture.md](docs/architecture.md) and [docs/isolation-model.md](docs/isolation-model.md).
|
||||
- [BREAKING] **Two-DB session split.** Each session now has `inbound.db` (host writes, container reads) and `outbound.db` (container writes, host reads) with exactly one writer each. Replaces the single shared session DB and eliminates cross-mount SQLite contention. See [docs/db-session.md](docs/db-session.md).
|
||||
- [BREAKING] **Install flow replaced.** `bash nanoclaw.sh` is the new default: a scripted installer that hands off to Claude Code for error recovery and guided decisions. The `/setup` Claude-guided skill still works as an alternative.
|
||||
- [BREAKING] **Channels moved to the `channels` branch.** Trunk no longer ships Discord, Slack, Telegram, WhatsApp, iMessage, Teams, Linear, GitHub, WeChat, Matrix, Google Chat, Webex, Resend, or WhatsApp Cloud. Install them per fork via `/add-<channel>` skills, which copy from the `channels` branch. `/update-nanoclaw` will re-install the channels your fork had.
|
||||
- [BREAKING] **Alternative providers moved to the `providers` branch.** OpenCode, Codex, and Ollama install via `/add-opencode`, `/add-codex`, `/add-ollama-provider`. Claude remains the default provider baked into trunk.
|
||||
- [BREAKING] **Three-level channel isolation.** Wire channels to their own agent (separate agent groups), share an agent with independent conversations (`session_mode: 'shared'`), or merge channels into one shared session (`session_mode: 'agent-shared'`). Chosen per channel via `/manage-channels`.
|
||||
- [BREAKING] **Apple Container removed from default setup.** Still available as an opt-in via `/convert-to-apple-container`.
|
||||
- **Shared-source agent-runner.** Per-group `agent-runner-src/` overlays are gone; all groups mount the same agent-runner read-only. Per-group customization flows through composed `CLAUDE.md` (shared base + per-group fragments).
|
||||
- **Agent-runner runtime moved from Node to Bun.** Container image is self-contained; no host-side impact. Host remains on Node + pnpm.
|
||||
- **OneCLI Agent Vault is the sole credential path.** Containers never receive raw API keys; credentials are injected at request time.
|
||||
|
||||
## [1.2.36] - 2026-03-26
|
||||
|
||||
- [BREAKING] Replaced pino logger with built-in logger. WhatsApp users must re-merge the WhatsApp fork to pick up the Baileys logger compatibility fix: `git fetch whatsapp main && git merge whatsapp/main`. If the `whatsapp` remote is not configured: `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git`.
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
# ⚠️ STOP — READ THIS FIRST IF YOU ARE CLAUDE ⚠️
|
||||
|
||||
**If you are reading this because you just ran `git pull`, `git merge`, `git fetch && git merge`, or any equivalent to bring in upstream changes — and you see merge conflicts or a large diff involving this file — HALT IMMEDIATELY.**
|
||||
|
||||
This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It cannot be merged into an existing v1 install. Attempting to resolve the conflicts by hand, run builds, or "fix" anything will corrupt the user's install and burn tokens for no result.
|
||||
|
||||
**Do this instead:**
|
||||
1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed).
|
||||
2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."*
|
||||
3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself.
|
||||
|
||||
If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below.
|
||||
|
||||
---
|
||||
|
||||
# NanoClaw
|
||||
|
||||
Personal Claude assistant. See [README.md](README.md) for philosophy and setup. Architecture lives in `docs/`.
|
||||
@@ -64,9 +49,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
|
||||
| `src/session-manager.ts` | Resolves sessions; opens `inbound.db` / `outbound.db`; manages heartbeat path |
|
||||
| `src/container-runner.ts` | Spawns per-agent-group Docker containers with session DB + outbox mounts, OneCLI `ensureAgent` |
|
||||
| `src/container-runtime.ts` | Runtime selection (Docker vs Apple containers), orphan cleanup |
|
||||
| `src/modules/permissions/access.ts` | `canAccessAgentGroup` — owner / global admin / scoped admin / member resolution against `user_roles` + `agent_group_members` |
|
||||
| `src/modules/approvals/primitive.ts` | `pickApprover`, `pickApprovalDelivery`, `requestApproval`, approval-handler registry |
|
||||
| `src/command-gate.ts` | Router-side admin command gate — queries `user_roles` directly (no env var, no container-side check) |
|
||||
| `src/access.ts` | `pickApprover`, `pickApprovalDelivery`, admin resolution for `NANOCLAW_ADMIN_USER_IDS` |
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
@@ -91,7 +74,7 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
|
||||
|
||||
One tier of agent self-modification today:
|
||||
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
1. **`install_packages` / `add_mcp_server` / `request_rebuild`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Admin approval, rebuild, container restart. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
|
||||
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
|
||||
|
||||
@@ -99,41 +82,6 @@ A second tier (direct source-level self-edits via a draft/activate flow) is plan
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Gotcha: auto-created agents start in `selective` secret mode
|
||||
|
||||
When the host first spawns a session for a new agent group, `container-runner.ts:385` calls `onecli.ensureAgent({ name, identifier })`. The OneCLI `POST /api/agents` endpoint creates the agent in **`selective`** secret mode — meaning **no secrets are assigned to it by default**, even if the secrets exist in the vault and have host patterns that would otherwise match.
|
||||
|
||||
Symptom: container starts, the proxy + CA cert are wired correctly, but the agent gets `401 Unauthorized` (or similar) from APIs whose credentials *are* in the vault. The credential just isn't in this agent's allow-list.
|
||||
|
||||
The SDK does not expose `setSecretMode` — the only fix is the CLI (or the web UI at `http://127.0.0.1:10254`).
|
||||
|
||||
```bash
|
||||
# Find the agent (identifier is the agent group id)
|
||||
onecli agents list
|
||||
|
||||
# Flip to "all" so every vault secret with a matching host pattern gets injected
|
||||
onecli agents set-secret-mode --id <agent-id> --mode all
|
||||
|
||||
# Or, stay selective and assign specific secrets
|
||||
onecli secrets list # find secret ids
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <id1>,<id2>
|
||||
|
||||
# Inspect what an agent currently has
|
||||
onecli agents secrets --id <agent-id> # secrets assigned to this agent
|
||||
onecli secrets list # all vault secrets (with host patterns)
|
||||
```
|
||||
|
||||
If you've just enabled `mode all`, no container restart is needed — the gateway looks up secrets per request, so the next API call from the running container will see the new credentials.
|
||||
|
||||
### Requiring approval for credential use
|
||||
|
||||
Approval-gating credentialed actions is a **two-sided** flow:
|
||||
|
||||
- **Server-side** (OneCLI gateway): decides *when* to hold a request and emit a pending approval. As of `onecli@1.3.0`, the CLI does **not** expose this — `rules create --action` only accepts `block` or `rate_limit`, and `secrets create` has no approval flag. Approval policies must be configured via the OneCLI web UI at `http://127.0.0.1:10254`. If/when the CLI grows an `approve` action, this section needs updating.
|
||||
- **Host-side** (nanoclaw): receives pending approvals and routes them to a human. `src/modules/approvals/onecli-approvals.ts` registers a callback via `onecli.configureManualApproval(cb)` (long-polls `GET /api/approvals/pending`). The callback uses `pickApprover` + `pickApprovalDelivery` from `src/modules/approvals/primitive.ts` to DM an approver. Approvers are resolved from the `user_roles` table — preference order: scoped admins for the agent group → global admins → owners. There is no env var like `NANOCLAW_ADMIN_USER_IDS`; roles are persisted in the central DB only.
|
||||
|
||||
If approvals are configured server-side but the host callback isn't running (or throws), every credentialed call hangs until the gateway times out. Conversely, if the gateway has no rule asking for approval, the host callback never fires regardless of how it's wired.
|
||||
|
||||
## Skills
|
||||
|
||||
Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxonomy.
|
||||
@@ -209,6 +157,7 @@ This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspac
|
||||
| [docs/agent-runner-details.md](docs/agent-runner-details.md) | Agent-runner internals + MCP tool interface |
|
||||
| [docs/isolation-model.md](docs/isolation-model.md) | Three-level channel isolation model |
|
||||
| [docs/setup-wiring.md](docs/setup-wiring.md) | What's wired, what's open in the setup flow |
|
||||
| [docs/checklist.md](docs/checklist.md) | Rolling status checklist across all subsystems |
|
||||
| [docs/architecture-diagram.md](docs/architecture-diagram.md) | Diagram version of the architecture |
|
||||
| [docs/build-and-runtime.md](docs/build-and-runtime.md) | Runtime split (Node host + Bun container), lockfiles, image build surface, CI, key invariants |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<a href="README_zh.md">中文</a> •
|
||||
<a href="README_ja.md">日本語</a> •
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="repo tokens" valign="middle"></a>
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
@@ -26,36 +26,55 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw && bash nanoclaw.sh
|
||||
gh repo fork qwibitai/nanoclaw --clone
|
||||
cd nanoclaw
|
||||
claude
|
||||
```
|
||||
|
||||
`nanoclaw.sh` walks you from a fresh machine to a named agent you can message. It installs Node, pnpm, and Docker if missing, registers your Anthropic credential with OneCLI, builds the agent container, and pairs your first channel (Telegram, Discord, WhatsApp, or a local CLI). If a step fails, Claude Code is invoked automatically to diagnose and resume from where it broke.
|
||||
<details>
|
||||
<summary>Without GitHub CLI</summary>
|
||||
|
||||
1. Fork [qwibitai/nanoclaw](https://github.com/qwibitai/nanoclaw) on GitHub (click the Fork button)
|
||||
2. `git clone https://github.com/<your-username>/nanoclaw.git`
|
||||
3. `cd nanoclaw`
|
||||
4. `claude`
|
||||
|
||||
</details>
|
||||
|
||||
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration.
|
||||
|
||||
> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal. If you don't have Claude Code installed, get it at [claude.com/product/claude-code](https://claude.com/product/claude-code).
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
|
||||
|
||||
**Secure by isolation.** Agents run in Linux containers and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
|
||||
**Secure by isolation.** Agents run in Linux containers (Apple Container on macOS, or Docker) and they can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your host.
|
||||
|
||||
**Built for the individual user.** NanoClaw isn't a monolithic framework; it's software that fits each user's exact needs. Instead of becoming bloatware, NanoClaw is designed to be bespoke. You make your own fork and have Claude Code modify it to match your needs.
|
||||
|
||||
**Customization = code changes.** No configuration sprawl. Want different behavior? Modify the code. The codebase is small enough that it's safe to make changes.
|
||||
|
||||
**AI-native, hybrid by design.** The install and onboarding flow is an optimized scripted path, fast and deterministic. When a step needs judgment, whether a failed install, a guided decision, or a customization, control hands off to Claude Code seamlessly. Beyond setup there's no monitoring dashboard or debugging UI either: describe the problem in chat and Claude Code handles it.
|
||||
**AI-native.**
|
||||
- No installation wizard; Claude Code guides setup.
|
||||
- No monitoring dashboard; ask Claude what's happening.
|
||||
- No debugging tools; describe the problem and Claude fixes it.
|
||||
|
||||
**Skills over features.** Trunk ships the registry and infrastructure, not specific channel adapters or alternative agent providers. Channels (Discord, Slack, Telegram, WhatsApp, …) live on a long-lived `channels` branch; alternative providers (OpenCode, Ollama) live on `providers`. You run `/add-telegram`, `/add-opencode`, etc. and the skill copies exactly the module(s) you need into your fork. No feature you didn't ask for.
|
||||
**Skills over features.** Instead of adding features (e.g. support for Telegram) to the codebase, contributors submit [claude code skills](https://code.claude.com/docs/en/skills) like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need.
|
||||
|
||||
**Best harness, best model.** NanoClaw natively uses Claude Code via Anthropic's official Claude Agent SDK, so you get the latest Claude models and Claude Code's full toolset, including the ability to modify and expand your own NanoClaw fork. Other providers are drop-in options: `/add-codex` for OpenAI's Codex (ChatGPT subscription or API key), `/add-opencode` for OpenRouter, Google, DeepSeek and more via OpenCode, and `/add-ollama-provider` for local open-weight models. Provider is configurable per agent group.
|
||||
**Best harness, best model.** NanoClaw runs on the Claude Agent SDK, which means you're running Claude Code directly. Claude Code is highly capable and its coding and problem-solving capabilities allow it to modify and expand NanoClaw and tailor it to each user.
|
||||
|
||||
## What It Supports
|
||||
|
||||
- **Multi-channel messaging** — WhatsApp, Telegram, Discord, Slack, Microsoft Teams, iMessage, Matrix, Google Chat, Webex, Linear, GitHub, WeChat, and email via Resend. Installed on demand with `/add-<channel>` skills. Run one or many at the same time.
|
||||
- **Flexible isolation** — connect each channel to its own agent for full privacy, share one agent across many channels for unified memory with separate conversations, or fold multiple channels into a single shared session so one conversation spans many surfaces. Pick per channel via `/manage-channels`. See [docs/isolation-model.md](docs/isolation-model.md).
|
||||
- **Per-agent workspace** — each agent group has its own `CLAUDE.md`, its own memory, its own container, and only the mounts you allow. Nothing crosses the boundary unless you wire it to.
|
||||
- **Scheduled tasks** — recurring jobs that run Claude and can message you back
|
||||
- **Web access** — search and fetch content from the web
|
||||
- **Container isolation** — agents are sandboxed in Docker (macOS/Linux/WSL2), with optional [Docker Sandboxes](docs/docker-sandboxes.md) micro-VM isolation or Apple Container as a macOS-native opt-in
|
||||
- **Credential security** — agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time.
|
||||
- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it.
|
||||
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
|
||||
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
|
||||
- **Web access** - Search and fetch content from the Web
|
||||
- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
|
||||
- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
|
||||
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
|
||||
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -67,7 +86,7 @@ Talk to your assistant with the trigger word (default: `@Andy`):
|
||||
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
|
||||
```
|
||||
|
||||
From a channel you own or administer, you can manage groups and tasks:
|
||||
From the main channel (your self-chat), you can manage groups and tasks:
|
||||
```
|
||||
@Andy list all scheduled tasks across groups
|
||||
@Andy pause the Monday briefing task
|
||||
@@ -91,58 +110,54 @@ The codebase is small enough that Claude can safely modify it.
|
||||
|
||||
**Don't add features. Add skills.**
|
||||
|
||||
If you want to add a new channel or agent provider, don't add it to trunk. New channel adapters land on the `channels` branch; new agent providers land on `providers`. Users install them in their own fork with `/add-<name>` skills, which copy the relevant module(s) into the standard paths, wire the registration, and pin dependencies.
|
||||
If you want to add Telegram support, don't create a PR that adds Telegram to the core codebase. Instead, fork NanoClaw, make the code changes on a branch, and open a PR. We'll create a `skill/telegram` branch from your PR that other users can merge into their fork.
|
||||
|
||||
This keeps trunk as pure registry and infra, and every fork stays lean — users get the channels and providers they asked for and nothing else.
|
||||
Users then run `/add-telegram` on their fork and get clean code that does exactly what they need, not a bloated system trying to support every use case.
|
||||
|
||||
### RFS (Request for Skills)
|
||||
|
||||
Skills we'd like to see:
|
||||
|
||||
**Communication Channels**
|
||||
- `/add-signal` — Add Signal as a channel
|
||||
- `/add-signal` - Add Signal as a channel
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS or Linux (Windows via WSL2)
|
||||
- Node.js 20+ and pnpm 10+ (the installer will install both if missing)
|
||||
- [Docker Desktop](https://docker.com/products/docker-desktop) (macOS/Windows) or Docker Engine (Linux)
|
||||
- [Claude Code](https://claude.ai/download) for `/customize`, `/debug`, error recovery during setup, and all `/add-<channel>` skills
|
||||
- macOS, Linux, or Windows (via WSL2)
|
||||
- Node.js 20+
|
||||
- [Claude Code](https://claude.ai/download)
|
||||
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
messaging apps → host process (router) → inbound.db → container (Bun, Claude Agent SDK) → outbound.db → host process (delivery) → messaging apps
|
||||
Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
|
||||
```
|
||||
|
||||
A single Node host orchestrates per-session agent containers. When a message arrives, the host routes it via the entity model (user → messaging group → agent group → session), writes it to the session's `inbound.db`, and wakes the container. The agent-runner inside the container polls `inbound.db`, runs Claude, and writes responses to `outbound.db`. The host polls `outbound.db` and delivers back through the channel adapter.
|
||||
Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
|
||||
|
||||
Two SQLite files per session, each with exactly one writer — no cross-mount contention, no IPC, no stdin piping. Channels and alternative providers self-register at startup; trunk ships the registry and the Chat SDK bridge, while the adapters themselves are skill-installed per fork.
|
||||
|
||||
For the full architecture writeup see [docs/architecture.md](docs/architecture.md); for the three-level isolation model see [docs/isolation-model.md](docs/isolation-model.md).
|
||||
For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture).
|
||||
|
||||
Key files:
|
||||
- `src/index.ts` — entry point: DB init, channel adapters, delivery polls, sweep
|
||||
- `src/router.ts` — inbound routing: messaging group → agent group → session → `inbound.db`
|
||||
- `src/delivery.ts` — polls `outbound.db`, delivers via adapter, handles system actions
|
||||
- `src/host-sweep.ts` — 60s sweep: stale detection, due-message wake, recurrence
|
||||
- `src/session-manager.ts` — resolves sessions, opens `inbound.db` / `outbound.db`
|
||||
- `src/container-runner.ts` — spawns per-agent-group containers, OneCLI credential injection
|
||||
- `src/db/` — central DB (users, roles, agent groups, messaging groups, wiring, migrations)
|
||||
- `src/channels/` — channel adapter infra (adapters installed via `/add-<channel>` skills)
|
||||
- `src/providers/` — host-side provider config (`claude` baked in; others via skills)
|
||||
- `container/agent-runner/` — Bun agent-runner: poll loop, MCP tools, provider abstraction
|
||||
- `groups/<folder>/` — per-agent-group filesystem (`CLAUDE.md`, skills, container config)
|
||||
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
|
||||
- `src/channels/registry.ts` - Channel registry (self-registration at startup)
|
||||
- `src/ipc.ts` - IPC watcher and task processing
|
||||
- `src/router.ts` - Message formatting and outbound routing
|
||||
- `src/group-queue.ts` - Per-group queue with global concurrency limit
|
||||
- `src/container-runner.ts` - Spawns streaming agent containers
|
||||
- `src/task-scheduler.ts` - Runs scheduled tasks
|
||||
- `src/db.ts` - SQLite operations (messages, groups, sessions, state)
|
||||
- `groups/*/CLAUDE.md` - Per-group memory
|
||||
|
||||
## FAQ
|
||||
|
||||
**Why Docker?**
|
||||
|
||||
Docker provides cross-platform support (macOS, Linux and Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
|
||||
|
||||
**Can I run this on Linux or Windows?**
|
||||
|
||||
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `bash nanoclaw.sh`.
|
||||
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`.
|
||||
|
||||
**Is this secure?**
|
||||
|
||||
@@ -154,28 +169,33 @@ We don't want configuration sprawl. Every user should customize NanoClaw so that
|
||||
|
||||
**Can I use third-party or open-source models?**
|
||||
|
||||
Yes. The supported path is `/add-opencode` (OpenRouter, OpenAI, Google, DeepSeek, and more via OpenCode config) or `/add-ollama-provider` (local open-weight models via Ollama). Both are configurable per agent group, so different agents can run on different backends in the same install.
|
||||
|
||||
For one-off experiments, any Claude API-compatible endpoint also works via `.env`:
|
||||
Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
|
||||
ANTHROPIC_AUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
This allows you to use:
|
||||
- Local models via [Ollama](https://ollama.ai) with an API proxy
|
||||
- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc.
|
||||
- Custom model deployments with Anthropic-compatible APIs
|
||||
|
||||
Note: The model must support the Anthropic API format for best compatibility.
|
||||
|
||||
**How do I debug issues?**
|
||||
|
||||
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw.
|
||||
|
||||
**Why isn't the setup working for me?**
|
||||
|
||||
If a step fails, `nanoclaw.sh` hands off to Claude Code to diagnose and resume. If that doesn't resolve it, run `claude`, then `/debug`. If Claude identifies an issue likely to affect other users, open a PR against the relevant setup step or skill.
|
||||
If you have issues, during setup, Claude will try to dynamically fix them. If that doesn't work, run `claude`, then run `/debug`. If Claude finds an issue that is likely affecting other users, open a PR to modify the setup SKILL.md.
|
||||
|
||||
**What changes will be accepted into the codebase?**
|
||||
|
||||
Only security fixes, bug fixes, and clear improvements will be accepted to the base configuration. That's all.
|
||||
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills on the `channels` or `providers` branch.
|
||||
Everything else (new capabilities, OS compatibility, hardware support, enhancements) should be contributed as skills.
|
||||
|
||||
This keeps the base system minimal and lets every user customize their installation without inheriting features they don't want.
|
||||
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
> **注意:** この日本語訳は v1 時点のもので、最新の v2 アーキテクチャは反映されていません。最新の内容は [README.md](README.md) をご覧ください。
|
||||
|
||||
---
|
||||
|
||||
<h2 align="center">🐳 Dockerサンドボックスで動作</h2>
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a> •
|
||||
<a href="repo-tokens"><img src="repo-tokens/badge.svg" alt="34.9k tokens, 17% of context window" valign="middle"></a>
|
||||
</p>
|
||||
|
||||
> **注意:** 此中文翻译对应 v1 版本,已不反映最新的 v2 架构。请参考 [README.md](README.md) 获取最新内容。
|
||||
|
||||
通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。
|
||||
|
||||
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
# NanoClaw Refactor — Forward-Looking Reference
|
||||
|
||||
Consolidates what's still relevant from `REFACTOR_PLAN.md` and `REFACTOR_EXECUTION.md`: open decisions, remaining work, operational patterns worth keeping. Historical PR timeline and phase framing have been dropped — the work is in the commit history.
|
||||
|
||||
---
|
||||
|
||||
## Architecture (still authoritative)
|
||||
|
||||
### Module tiers
|
||||
|
||||
Three categories, distinguished by shipping model and dependency direction:
|
||||
|
||||
| Tier | Where it lives | Loaded by default? | Removal cost |
|
||||
|------|----------------|--------------------|--------------|
|
||||
| **Core** | `src/**` (outside `src/modules/`, `src/channels/`, `src/providers/`) | always | N/A — can't remove |
|
||||
| **Default modules** | `src/modules/<name>/` on main | yes — imported by `src/modules/index.ts` | edit core imports (intentional friction) |
|
||||
| **Optional modules** | `src/modules/<name>/` on main (for now — see open q #7) | yes, via barrel import | delete files + barrel line + revert `MODULE-HOOK` edits |
|
||||
| **Channel adapters** | `src/channels/<name>.ts` on `channels` branch | no — cherry-pick via `/add-<name>` | delete files + barrel line |
|
||||
| **Providers** | on `providers` branch | no — cherry-pick via `/add-<provider>` | delete files + barrel line |
|
||||
|
||||
Default modules today: `typing`, `mount-security`, `approvals`, `cli`.
|
||||
Optional modules: `interactive`, `scheduling`, `permissions`, `agent-to-agent`, `self-mod`.
|
||||
|
||||
Dependency rule: **core ← default modules ← optional modules**. Optional modules must not depend on each other. Known transitional violation (flagged): `src/db/messaging-groups.ts` auto-wires `agent_destinations` when agent-to-agent is installed.
|
||||
|
||||
### The four registries
|
||||
|
||||
Full contract in [`docs/module-contract.md`](docs/module-contract.md). Summary:
|
||||
|
||||
1. **Delivery action handlers** — `delivery.ts`; modules call `registerDeliveryAction(name, fn)`.
|
||||
2. **Router inbound gate** — `router.ts`; single setter (`setSenderResolver` + `setAccessGate`). Default: allow-all.
|
||||
3. **Response dispatcher** — `response-registry.ts`; modules call `registerResponseHandler(fn)`. First to return `true` claims.
|
||||
4. **Container MCP tool self-registration** — `container/agent-runner/src/mcp-tools/server.ts`; modules call `registerTools([...])` at import.
|
||||
|
||||
Anything else single-consumer uses either a `sqlite_master`-guarded inline read or a `MODULE-HOOK:<name>:start/end` skill edit.
|
||||
|
||||
### Module distribution (pending)
|
||||
|
||||
- **`main`** — core + default modules + default channel (`cli`). Ships clean.
|
||||
- **`channels`** — fully loaded runnable branch with all channel adapters; skills cherry-pick from it.
|
||||
- **`providers`** — same pattern for agent providers (OpenCode).
|
||||
- **`modules` branch** — proposed but NOT created yet. See "Remaining work" below.
|
||||
|
||||
---
|
||||
|
||||
## Remaining work
|
||||
|
||||
### Phase 5: merge `v2` → `main`
|
||||
|
||||
Cut-over the refactor. Pre-reqs (already met): green build, green tests, green service boot, clean `channels` / `providers` syncs.
|
||||
|
||||
Open logistics:
|
||||
- Release versioning: bump to `1.3.0` at merge time or cut a `v2-rc` tag first for internal testing? Non-blocking — decide at merge.
|
||||
- Coordinate with anyone still running the old `main` (v1.2.53) — breaking change for them.
|
||||
- Announce the new layout + the one shell command that changed (`pnpm run chat` is new default).
|
||||
|
||||
### `modules` branch — create, skip, or defer?
|
||||
|
||||
The original plan (PR #10) was to fork a `modules` branch and populate it with the 5 optional modules, so future `/add-<module>` skills pull via `git show origin/modules:path`. Three paths:
|
||||
|
||||
- **(a) Create it now.** Matches the `channels`/`providers` pattern for consistency. Extra surface to maintain: every core change must be merged into `modules` at phase boundaries (same cadence as channels/providers). Pays off if we ever want to make a module *truly* optional (not shipped on main).
|
||||
- **(b) Skip it.** Leave all 5 optional modules shipped on main. No `modules` branch, no install skills, no cherry-picking. Simpler but loses the "opt-in" property for users who want a leaner install.
|
||||
- **(c) Defer.** Ship main without the modules branch; create it later if someone actually wants to slim their install. No-cost option for now.
|
||||
|
||||
Recommendation leans toward (c) — we've already paid the architectural cost (tier boundary, dependency rule, registries) without needing the branch today.
|
||||
|
||||
### Per-module follow-ups (tracked as open questions below)
|
||||
|
||||
Each has a specific landing zone when we get to it:
|
||||
- #11–13 (admin mechanism, providers registry, container-runner audit) — scope a focused cleanup pass.
|
||||
- #14 (CLAUDE.md review) — single dedicated PR touching every module.
|
||||
- #15 (A2A / destinations rethink) — requires design, not just cleanup.
|
||||
- #17–18 (self-mod rethink, per-group source) — requires design.
|
||||
- #19 (system vs user CLAUDE.md) — requires install-skill tooling.
|
||||
|
||||
---
|
||||
|
||||
## Operational patterns (keep using these)
|
||||
|
||||
### Standing checks for every PR
|
||||
|
||||
Non-negotiable; a unit test suite alone doesn't catch circular-import TDZ bugs:
|
||||
|
||||
1. `pnpm run build` clean.
|
||||
2. `pnpm test` + `bun test` (in `container/agent-runner/`) all green.
|
||||
3. **Service actually starts.** `gtimeout 5 node dist/index.js` (or `launchctl kickstart`) must reach `NanoClaw running`. Unit tests import individual files; only `main()` exercises the module-init order.
|
||||
4. Expected boot log lines present (at least: `Central DB ready`, `Delivery polls started`, `Host sweep started`, `NanoClaw running`, plus any module lifecycle line like `OneCLI approval handler started` or `CLI channel listening`).
|
||||
|
||||
### Module architecture rule (TDZ bug, PR #3)
|
||||
|
||||
Any registry state a module writes to at import time must live in a file with **no back-edge to `src/index.ts`** — transitively. `src/index.ts` imports `src/modules/index.js` for side effects; if a module calls `registerX()` at top level and `X` lives in `src/index.ts`, the ES module loader hits a TDZ reference on the const declaration. Fix: registry state lives in its own dependency-free file (e.g. `src/response-registry.ts`). Any new registry follows the same pattern.
|
||||
|
||||
### Branch sync procedure
|
||||
|
||||
After every `v2` (or future `main`) sync into `channels` / `providers` / future `modules`:
|
||||
|
||||
1. **File-presence diff.** Enumerate files that existed pre-sync but are missing post-sync:
|
||||
```
|
||||
git ls-tree -r <pre-sync> | awk '{print $4}' | sort > /tmp/pre.txt
|
||||
git ls-tree -r <post-sync> | awk '{print $4}' | sort > /tmp/post.txt
|
||||
comm -23 /tmp/pre.txt /tmp/post.txt
|
||||
```
|
||||
Classify each missing file:
|
||||
- **Intentional** (core deleted it) → leave deleted.
|
||||
- **Branch-owned** (channels branch still needs it) → restore from pre-sync HEAD.
|
||||
|
||||
This has caught real losses on both `channels` (17 adapter files plus 3 setup scripts after PR #2's channel move) and `providers` (opencode files after PR #2).
|
||||
|
||||
2. **Cross-file consistency.** When restoring a file, check whether something *else* that also changed references it (e.g. `setup/index.ts`'s `STEPS` map).
|
||||
|
||||
3. **Run the standing checks** against the synced branch (not just v2).
|
||||
|
||||
### Prettier drift pattern
|
||||
|
||||
The `format:fix` pre-commit hook sometimes reformats peer files *after* the commit completes, leaving cosmetic-only diffs in the working tree. Discard with `git checkout -- <files>`. Do not re-commit the drift — it's trivial whitespace and noise.
|
||||
|
||||
---
|
||||
|
||||
## Open questions (curated)
|
||||
|
||||
### Design / architecture
|
||||
|
||||
1. **`NANOCLAW_ADMIN_USER_IDS` as the admin mechanism.** Host queries `user_roles` at container wake, collapses into env var, container compares sender IDs. Conflates identity-at-send with privilege-at-wake and forces the container to care about namespaced user IDs. Revisit during a container-runner audit.
|
||||
|
||||
2. **Host-side `src/providers/` registry.** One real consumer (OpenCode). A registry is probably overkill — the install skill could just edit `container-runner.ts` via `MODULE-HOOK`. Fold into the container-runner audit.
|
||||
|
||||
3. **Container-runner audit.** `src/container-runner.ts` has accreted wake/spawn/kill, mount assembly, OneCLI credential application, admin-ID env var, idle timers, image rebuild. Some pieces should pull apart or move into modules. Not blocking. Related to #1 and #2.
|
||||
|
||||
4. **Revisit destinations + A2A capability holistically.** The destination projection invariant, dual-purpose routing+ACL table, channel vs agent destination shapes, `createMessagingGroupAgent` auto-wire coupling — more machinery than the feature warrants. Phase 3 moved it out of core intact; a redesign is warranted but scoped post-refactor.
|
||||
|
||||
5. **Self-mod approach rethink.** Three separate MCP tools + three delivery actions + three approval handlers for what's essentially "mutate container.config.json and rebuild." Also: post-rebuild latency (host sweep waits up to 60s), and agents sometimes send redundant `add_mcp_server` + `request_rebuild` pairs. Consider collapsing into a single "apply this container-config diff" approval primitive.
|
||||
|
||||
6. **Per-agent-group source / per-group base image.** Self-mod today layers packages/MCP on a shared base. As groups diverge (different base images, provider configs, runtime toolchains), the shared-base assumption won't scale. Scope post-refactor.
|
||||
|
||||
### Distribution / operational
|
||||
|
||||
7. **Providers on a consolidated `modules` branch?** Staying separate for now. Revisit if a second optional provider appears.
|
||||
|
||||
8. **Per-group module enablement.** Modules are currently project-wide. If one agent group wants approvals and another doesn't, we'd need per-group feature flags. Flag if asked.
|
||||
|
||||
9. **Module removal UX.** We do not drop tables on uninstall. Is that the right default? (Alternative: `/remove-<module>` optionally runs a down migration. YAGNI until requested.)
|
||||
|
||||
10. **Cross-module ordering for the response dispatcher.** Registration order determines who claims a given `questionId`. IDs are disjoint in practice (`q-…` vs `appr-…`), so first-match-wins is safe. If a third response-consuming module arrives, we may need keyed dispatch.
|
||||
|
||||
11. **Versioned module migrations.** Reinstalls are idempotent (migrator skips anything already in `schema_version`). If a module ships a *new* migration in a later version, the install skill must append the new file + barrel entry without touching prior ones. Simplest rule: install skills are additive; content changes to an already-applied migration are a hard error.
|
||||
|
||||
12. **Telegram pairing imports from permissions (channels branch).** `src/channels/telegram.ts` reaches into `src/modules/permissions/db/*` for `grantRole`/`hasAnyOwner`/`upsertUser` in the pairing-bootstrap branch. Cross-branch tier violation. Fix: extract those writes into a pairing helper (e.g. `src/channels/telegram-pairing-accept.ts` or `setup/pair-telegram.ts`). Non-blocking.
|
||||
|
||||
### Core slotting (files not explicitly discussed)
|
||||
|
||||
13. **`state-sqlite.ts`, `webhook-server.ts`, `timezone.ts`.** state-sqlite is likely core (host tracker). Webhook-server likely core (channel infra). Timezone likely core utility. Confirm if any of them prove to be module-shaped during future audits.
|
||||
|
||||
14. **Chat SDK bridge location.** `src/channels/chat-sdk-bridge.ts` is channel infra that bridges adapters on the `channels` branch. Stays in `src/channels/` for now.
|
||||
|
||||
15. **OneCLI credential injection.** Lives in `container-runner.ts`. Every agent call uses it, no clean optional boundary. Stays core. Related: `onecli-approvals.ts` is bundled inside the `approvals` default module on the assumption OneCLI stays in core. If OneCLI later moves to its own module, `onecli-approvals` follows.
|
||||
|
||||
### Documentation
|
||||
|
||||
16. **CLAUDE.md content per module.** Every module ships with project.md + agent.md. Need a dedicated review pass: (a) write the missing agent-to-agent snippets, (b) audit other modules for accuracy/tone, (c) confirm `agent.md` files are actually tailored for the agent vs. copy-pastes of `project.md`.
|
||||
|
||||
17. **Split system CLAUDE.md from user CLAUDE.md.** Project `CLAUDE.md` and `groups/global/CLAUDE.md` mix system-authored content (module contracts, install-skill appends) with user customizations. Updates currently risk clobbering user intent. Look at a system-owned region (or separate file) that skills rewrite freely plus a user-owned one that's never touched. Related to #16.
|
||||
|
||||
---
|
||||
|
||||
## Where the canonical references live
|
||||
|
||||
- **Module contract** — [`docs/module-contract.md`](docs/module-contract.md)
|
||||
- **Architecture overview** — [`docs/architecture.md`](docs/architecture.md)
|
||||
- **DB layout** — [`docs/db.md`](docs/db.md), [`docs/db-central.md`](docs/db-central.md), [`docs/db-session.md`](docs/db-session.md)
|
||||
- **Agent-runner internals** — [`docs/agent-runner-details.md`](docs/agent-runner-details.md)
|
||||
- **Channel isolation model** — [`docs/isolation-model.md`](docs/isolation-model.md)
|
||||
- **Build + runtime split** — [`docs/build-and-runtime.md`](docs/build-and-runtime.md)
|
||||
- **Top-level** — [`CLAUDE.md`](CLAUDE.md)
|
||||
|
||||
This doc (`REFACTOR.md`) is transient — prune when open questions close; retire entirely once the refactor is fully behind us and the operational patterns have been absorbed into `CLAUDE.md` or `docs/`.
|
||||
@@ -1,21 +0,0 @@
|
||||
You are a NanoClaw agent. Your name, destinations, and message-sending rules are provided in the runtime system prompt at the top of each turn.
|
||||
|
||||
## Communication
|
||||
|
||||
Be concise — every message costs the reader's attention. Prefer outcomes over play-by-play; when the work is done, the final message should be about the result, not a transcript of what you did.
|
||||
|
||||
## Workspace
|
||||
|
||||
Files you create are saved in `/workspace/agent/`. Use this for notes, research, or anything that should persist across turns in this group.
|
||||
|
||||
The file `CLAUDE.local.md` in your workspace is your per-group memory. Record things there that you'll want to remember in future sessions — user preferences, project context, recurring facts. Keep entries short and structured.
|
||||
|
||||
## Memory
|
||||
|
||||
When the user shares any substantive information with you, it must be stored somewhere you can retrieve it when relevant. If it's information that is pertinent to every single conversation turn it should be put into CLAUDE.local.md. Otherwise, create a system for storing the information depending on its type - e.g. create a file of people that the user mentions so you can keep track or a file of projects. For every file you create, add a concise reference in your CLAUDE.local.md so you'll be able to find it in future conversations.
|
||||
|
||||
A core part of your job and the main thing that defines how useful you are to the user is how well you do in creating these systems for organizing information. These are your systems that help you do your job well. Evolve them over time as needed.
|
||||
|
||||
## Conversation history
|
||||
|
||||
The `conversations/` folder in your workspace holds searchable transcripts of past sessions with this group. Use it to recall prior context when a request references something that happened before. For structured long-lived data, prefer dedicated files (`customers.md`, `preferences.md`, etc.); split any file over ~500 lines into a folder with an index.
|
||||
+26
-34
@@ -3,12 +3,8 @@
|
||||
# Runs Claude Agent SDK in isolated Linux VM with browser automation.
|
||||
#
|
||||
# Runtime split:
|
||||
# - agent-runner (our TypeScript code): Bun, mounted RO at /app/src by host
|
||||
# - agent-runner (our TypeScript code): Bun
|
||||
# - globally-installed Node CLIs (claude-code, agent-browser, vercel): pnpm + Node
|
||||
#
|
||||
# Source is never baked in — /app/src is provided by a shared read-only
|
||||
# bind mount at runtime (see src/container-runner.ts). Source-only changes
|
||||
# never require an image rebuild.
|
||||
|
||||
FROM node:22-slim
|
||||
|
||||
@@ -19,7 +15,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG CLAUDE_CODE_VERSION=2.1.112
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=latest
|
||||
ARG BUN_VERSION=1.3.12
|
||||
@@ -70,48 +66,44 @@ RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \
|
||||
install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \
|
||||
rm -rf /root/.bun
|
||||
|
||||
# ---- agent-runner deps -------------------------------------------------------
|
||||
# Deps are cached independently of CLI versions. Source is NOT baked in —
|
||||
# it's provided by the shared RO mount at runtime.
|
||||
# ---- pnpm + global Node CLIs -------------------------------------------------
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
# agent-browser has a postinstall build script — pnpm skips these by default.
|
||||
# Allowlist it via .npmrc so the install doesn't silently produce a broken
|
||||
# package. Pinned versions so every rebuild is reproducible.
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
pnpm install -g \
|
||||
"@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \
|
||||
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||
"vercel@${VERCEL_VERSION}"
|
||||
|
||||
# ---- agent-runner ------------------------------------------------------------
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifest + lockfile first so the install layer caches independently of
|
||||
# source edits.
|
||||
COPY agent-runner/package.json agent-runner/bun.lock ./
|
||||
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
bun install --frozen-lockfile
|
||||
|
||||
# ---- pnpm + global Node CLIs -------------------------------------------------
|
||||
# Most stable first, most frequently bumped last. Bumping claude-code
|
||||
# (the most common change) only invalidates one layer.
|
||||
#
|
||||
# only-built-dependencies gates pnpm's supply-chain policy:
|
||||
# - agent-browser has a postinstall build step.
|
||||
# - @anthropic-ai/claude-code's postinstall downloads the native Claude
|
||||
# binary (linux-arm64 variant on our image). Without the allowlist
|
||||
# the SDK fails at spawn time with "native binary not found".
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
echo "only-built-dependencies[]=@anthropic-ai/claude-code" >> /root/.npmrc && \
|
||||
pnpm install -g "vercel@${VERCEL_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "agent-browser@${AGENT_BROWSER_VERSION}"
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
# Source. Bun runs TS directly — no tsc build step. The host remounts this
|
||||
# path at runtime via `src/container-runner.ts` so source edits on the host
|
||||
# take effect without rebuilding the image; the baked copy is the fallback.
|
||||
COPY agent-runner/ ./
|
||||
|
||||
# ---- Entrypoint --------------------------------------------------------------
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# ---- Workspace + permissions -------------------------------------------------
|
||||
RUN mkdir -p /workspace/group /workspace/extra && \
|
||||
RUN mkdir -p /workspace/group /workspace/global /workspace/extra && \
|
||||
chown -R node:node /workspace && \
|
||||
chmod 777 /home/node
|
||||
chmod 755 /home/node
|
||||
|
||||
USER node
|
||||
WORKDIR /workspace/group
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
@@ -18,23 +18,7 @@
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.112", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-vMFoiDKlOive8p3tphpV1gQaaytOipwGJ+uw9mvvaLQUODSC2+fCdRDAY25i2Tsv+lOtxzXBKctmaDuWqZY7ig=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
|
||||
@@ -42,6 +26,38 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.92",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* SDK signal probe: run a prompt, log every signal the Agent SDK emits —
|
||||
* async-iterator events + hook callbacks + CLI stderr — with absolute
|
||||
* and relative timing.
|
||||
*
|
||||
* Usage:
|
||||
* bun run scripts/sdk-signal-probe.ts "<prompt>" # simple string mode
|
||||
* bun run scripts/sdk-signal-probe.ts --stream "<prompt>" # streaming-input mode
|
||||
* bun run scripts/sdk-signal-probe.ts --stream "<p>" \
|
||||
* --push "5000:<text>" --push "15000:<text>" --timeout 60000 # multi-push
|
||||
*
|
||||
* Streaming mode (`--stream`) passes an AsyncIterable prompt to `query()`,
|
||||
* which keeps the CLI subprocess alive past the first result (per SDK
|
||||
* deep dive). Required for post-result pushes, agent teams, background
|
||||
* task notifications.
|
||||
*/
|
||||
import { query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const prompts: string[] = [];
|
||||
const pushes: Array<{ atMs: number; text: string }> = [];
|
||||
let streamMode = false;
|
||||
let timeoutMs: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--stream') streamMode = true;
|
||||
else if (a === '--push') {
|
||||
const val = args[++i] ?? '';
|
||||
const ix = val.indexOf(':');
|
||||
if (ix === -1) throw new Error(`bad --push (want MS:text): ${val}`);
|
||||
pushes.push({ atMs: parseInt(val.slice(0, ix), 10), text: val.slice(ix + 1) });
|
||||
} else if (a === '--timeout') timeoutMs = parseInt(args[++i] ?? '0', 10);
|
||||
else if (a === '--prompt') prompts.push(args[++i] ?? '');
|
||||
else prompts.push(a);
|
||||
}
|
||||
|
||||
const prompt = prompts.join(' ');
|
||||
if (!prompt) {
|
||||
console.error('usage: sdk-signal-probe.ts [--stream] "<prompt>" [--push MS:<text>]... [--timeout MS]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const T0 = Date.now();
|
||||
let LAST = T0;
|
||||
|
||||
function log(source: string, type: string, payload: unknown = {}): void {
|
||||
const now = Date.now();
|
||||
const entry = { t_ms: now - T0, d_ms: now - LAST, source, type, payload };
|
||||
LAST = now;
|
||||
console.log(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
function hookLogger(eventName: string) {
|
||||
return async (input: unknown, toolUseID: string | undefined): Promise<any> => {
|
||||
log('hook', eventName, { toolUseID, input });
|
||||
// Stuck-tool simulation: if env flag is set and this is a PreToolUse for Bash,
|
||||
// never resolve — simulates a tool that hangs forever.
|
||||
if (process.env.PROBE_HANG === 'true' && eventName === 'PreToolUse') {
|
||||
const toolName = (input as any)?.tool_name ?? (input as any)?.name;
|
||||
if (toolName === 'Bash') {
|
||||
log('meta', 'pre_tool_use_hanging', { toolUseID, toolName });
|
||||
await new Promise(() => {
|
||||
/* never resolves */
|
||||
});
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
}
|
||||
|
||||
const HOOK_EVENTS = [
|
||||
'PreToolUse',
|
||||
'PostToolUse',
|
||||
'PostToolUseFailure',
|
||||
'Notification',
|
||||
'UserPromptSubmit',
|
||||
'SessionStart',
|
||||
'SessionEnd',
|
||||
'Stop',
|
||||
'SubagentStart',
|
||||
'SubagentStop',
|
||||
'PreCompact',
|
||||
'PermissionRequest',
|
||||
] as const;
|
||||
|
||||
const hooks: Record<string, unknown[]> = {};
|
||||
for (const ev of HOOK_EVENTS) hooks[ev] = [{ hooks: [hookLogger(ev)] }];
|
||||
|
||||
// Build prompt — string (single-turn) or AsyncIterable (streaming-input)
|
||||
let promptInput: any;
|
||||
|
||||
if (streamMode) {
|
||||
const sessionId = `probe-${Date.now()}`;
|
||||
async function* streamPrompt() {
|
||||
// Initial user message
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
message: { role: 'user' as const, content: prompt },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
// Schedule subsequent pushes
|
||||
const startT = Date.now();
|
||||
const sorted = [...pushes].sort((a, b) => a.atMs - b.atMs);
|
||||
for (const p of sorted) {
|
||||
const waitMs = Math.max(0, p.atMs - (Date.now() - startT));
|
||||
if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs));
|
||||
log('meta', 'push_message', { atMs: p.atMs, text: p.text });
|
||||
yield {
|
||||
type: 'user' as const,
|
||||
message: { role: 'user' as const, content: p.text },
|
||||
parent_tool_use_id: null,
|
||||
session_id: sessionId,
|
||||
};
|
||||
}
|
||||
// Keep stream open for tail events; iterator ends when we return
|
||||
// (no more work expected). For post-result-idle scenarios, wait here.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
promptInput = streamPrompt();
|
||||
} else {
|
||||
promptInput = prompt;
|
||||
}
|
||||
|
||||
log('meta', 'probe_start', { prompt, streamMode, pushes, timeoutMs });
|
||||
|
||||
const q = query({
|
||||
prompt: promptInput,
|
||||
options: {
|
||||
includePartialMessages: true,
|
||||
hooks: hooks as any,
|
||||
stderr: (data: string) => log('stderr', 'chunk', { data }),
|
||||
settingSources: [],
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Absolute time cap — exit cleanly so the log flushes
|
||||
if (timeoutMs) {
|
||||
setTimeout(() => {
|
||||
log('meta', 'timeout_hit', { timeoutMs });
|
||||
setTimeout(() => process.exit(0), 250);
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const event of q) {
|
||||
const snapshot: any = { ...event };
|
||||
try {
|
||||
const raw = JSON.stringify(snapshot);
|
||||
if (raw.length > 2000) {
|
||||
snapshot._truncated_bytes = raw.length;
|
||||
if (snapshot.message?.content) {
|
||||
const c = JSON.stringify(snapshot.message.content);
|
||||
snapshot.message = { ...snapshot.message, content: c.slice(0, 500) + `…<+${c.length - 500}b>` };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
log('event', snapshot.type ?? 'unknown', { subtype: snapshot.subtype, event: snapshot });
|
||||
}
|
||||
log('meta', 'iterator_done');
|
||||
} catch (err: any) {
|
||||
log('meta', 'iterator_error', { message: err?.message, stack: err?.stack?.split('\n').slice(0, 5) });
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Runner config — reads /workspace/agent/container.json at startup.
|
||||
*
|
||||
* This file is mounted read-only inside the container. The host writes it;
|
||||
* the runner only reads. All NanoClaw-specific configuration lives here
|
||||
* instead of environment variables.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
|
||||
const CONFIG_PATH = '/workspace/agent/container.json';
|
||||
|
||||
export interface RunnerConfig {
|
||||
provider: string;
|
||||
assistantName: string;
|
||||
groupName: string;
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
|
||||
let _config: RunnerConfig | null = null;
|
||||
|
||||
/**
|
||||
* Load config from container.json. Called once at startup.
|
||||
* Falls back to sensible defaults for any missing field.
|
||||
*/
|
||||
export function loadConfig(): RunnerConfig {
|
||||
if (_config) return _config;
|
||||
|
||||
let raw: Record<string, unknown> = {};
|
||||
try {
|
||||
raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||
} catch {
|
||||
console.error(`[config] Failed to read ${CONFIG_PATH}, using defaults`);
|
||||
}
|
||||
|
||||
_config = {
|
||||
provider: (raw.provider as string) || 'claude',
|
||||
assistantName: (raw.assistantName as string) || '',
|
||||
groupName: (raw.groupName as string) || '',
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
};
|
||||
|
||||
return _config;
|
||||
}
|
||||
|
||||
/** Get the loaded config. Throws if loadConfig() hasn't been called. */
|
||||
export function getConfig(): RunnerConfig {
|
||||
if (!_config) throw new Error('Config not loaded — call loadConfig() first');
|
||||
return _config;
|
||||
}
|
||||
@@ -31,7 +31,8 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH;
|
||||
/** Inbound DB — container opens read-only (host is the sole writer). */
|
||||
export function getInboundDb(): Database {
|
||||
if (!_inbound) {
|
||||
_inbound = new Database(DEFAULT_INBOUND_PATH, { readonly: true });
|
||||
const dbPath = process.env.SESSION_INBOUND_DB_PATH || DEFAULT_INBOUND_PATH;
|
||||
_inbound = new Database(dbPath, { readonly: true });
|
||||
_inbound.exec('PRAGMA busy_timeout = 5000');
|
||||
}
|
||||
return _inbound;
|
||||
@@ -40,7 +41,8 @@ export function getInboundDb(): Database {
|
||||
/** Outbound DB — container owns this file (sole writer). */
|
||||
export function getOutboundDb(): Database {
|
||||
if (!_outbound) {
|
||||
_outbound = new Database(DEFAULT_OUTBOUND_PATH);
|
||||
const dbPath = process.env.SESSION_OUTBOUND_DB_PATH || DEFAULT_OUTBOUND_PATH;
|
||||
_outbound = new Database(dbPath);
|
||||
_outbound.exec('PRAGMA journal_mode = DELETE');
|
||||
_outbound.exec('PRAGMA busy_timeout = 5000');
|
||||
_outbound.exec('PRAGMA foreign_keys = ON');
|
||||
@@ -62,65 +64,17 @@ export function getOutboundDb(): Database {
|
||||
if (!cols.has('updated_at')) {
|
||||
_outbound.exec(`ALTER TABLE session_state ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''`);
|
||||
}
|
||||
// container_state: tracks the current tool in flight (if any) so the host
|
||||
// sweep can widen its stuck tolerance when Bash is running with a user-
|
||||
// declared long timeout. Forward-compat for older outbound.db files.
|
||||
_outbound.exec(`
|
||||
CREATE TABLE IF NOT EXISTS container_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_tool TEXT,
|
||||
tool_declared_timeout_ms INTEGER,
|
||||
tool_started_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
return _outbound;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record that a tool is starting. `declaredTimeoutMs` is the tool's own
|
||||
* timeout hint when one is available (Bash exposes it in the tool_use input);
|
||||
* omit for tools with no declared timeout.
|
||||
*/
|
||||
export function setContainerToolInFlight(tool: string, declaredTimeoutMs: number | null): void {
|
||||
const now = new Date().toISOString();
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at)
|
||||
VALUES (1, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
current_tool = excluded.current_tool,
|
||||
tool_declared_timeout_ms = excluded.tool_declared_timeout_ms,
|
||||
tool_started_at = excluded.tool_started_at,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(tool, declaredTimeoutMs, now, now);
|
||||
}
|
||||
|
||||
/** Clear the in-flight tool — called on PostToolUse / PostToolUseFailure. */
|
||||
export function clearContainerToolInFlight(): void {
|
||||
const now = new Date().toISOString();
|
||||
getOutboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO container_state (id, current_tool, tool_declared_timeout_ms, tool_started_at, updated_at)
|
||||
VALUES (1, NULL, NULL, NULL, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
current_tool = NULL,
|
||||
tool_declared_timeout_ms = NULL,
|
||||
tool_started_at = NULL,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch the heartbeat file — replaces the old touchProcessing() DB writes.
|
||||
* The host checks this file's mtime for stale container detection.
|
||||
* A file touch is cheaper and avoids cross-boundary DB write contention.
|
||||
*/
|
||||
export function touchHeartbeat(): void {
|
||||
const p = _heartbeatPath;
|
||||
const p = process.env.SESSION_HEARTBEAT_PATH || _heartbeatPath;
|
||||
const now = new Date();
|
||||
try {
|
||||
fs.utimesSync(p, now, now);
|
||||
@@ -155,9 +109,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
status TEXT DEFAULT 'pending',
|
||||
process_after TEXT,
|
||||
recurrence TEXT,
|
||||
series_id TEXT,
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
@@ -205,13 +157,6 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE container_state (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
current_tool TEXT,
|
||||
tool_declared_timeout_ms INTEGER,
|
||||
tool_started_at TEXT,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
return { inbound: _inbound, outbound: _outbound };
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
* The container never writes to inbound.db — all status tracking goes through
|
||||
* processing_ack. The host reads processing_ack to sync message lifecycle.
|
||||
*/
|
||||
import { getConfig } from '../config.js';
|
||||
import { getInboundDb, getOutboundDb } from './connection.js';
|
||||
|
||||
export interface MessageInRow {
|
||||
@@ -19,35 +18,16 @@ export interface MessageInRow {
|
||||
process_after: string | null;
|
||||
recurrence: string | null;
|
||||
tries: number;
|
||||
/** 1 = wake-eligible (default); 0 = accumulated context only */
|
||||
trigger: number;
|
||||
platform_id: string | null;
|
||||
channel_type: string | null;
|
||||
thread_id: string | null;
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Cap on how many messages reach the agent in one prompt. Read from
|
||||
// container.json; falls back to 10.
|
||||
function getMaxMessagesPerPrompt(): number {
|
||||
try {
|
||||
return getConfig().maxMessagesPerPrompt;
|
||||
} catch {
|
||||
// Config not loaded yet (e.g. test harness) — use default
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pending messages that are due for processing.
|
||||
* Reads from inbound.db (read-only), filters against processing_ack in outbound.db
|
||||
* to skip messages already picked up by this or a previous container run.
|
||||
*
|
||||
* Returns the most recent `MAX_MESSAGES_PER_PROMPT` pending rows in
|
||||
* chronological order, regardless of their `trigger` flag: accumulated
|
||||
* context (trigger=0) rides along with the wake-eligible rows so the agent
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
const inbound = getInboundDb();
|
||||
@@ -58,10 +38,9 @@ export function getPendingMessages(): MessageInRow[] {
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
.all() as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
@@ -72,9 +51,7 @@ export function getPendingMessages(): MessageInRow[] {
|
||||
),
|
||||
);
|
||||
|
||||
// Reverse: we fetched DESC to take the most recent N, but the agent
|
||||
// should see them in chronological order (oldest first).
|
||||
return pending.filter((m) => !ackedIds.has(m.id)).reverse();
|
||||
return pending.filter((m) => !ackedIds.has(m.id));
|
||||
}
|
||||
|
||||
/** Mark messages as processing — writes to processing_ack in outbound.db. */
|
||||
|
||||
@@ -72,26 +72,8 @@ export function findByRouting(
|
||||
return row ? rowToEntry(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the system-prompt addendum: agent identity + destination map.
|
||||
*
|
||||
* Identity is injected here (not in the shared CLAUDE.md) because it's
|
||||
* per-agent-group and changes when the operator renames an agent, while
|
||||
* the shared base is identical across all agents.
|
||||
*/
|
||||
export function buildSystemPromptAddendum(assistantName?: string): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (assistantName) {
|
||||
sections.push(['# You are ' + assistantName, '', `Your name is **${assistantName}**. Use it when the channel asks who you are, when introducing yourself, and when signing any message that explicitly calls for a signature.`].join('\n'));
|
||||
}
|
||||
|
||||
sections.push(buildDestinationsSection());
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function buildDestinationsSection(): string {
|
||||
/** Generate the system-prompt addendum describing destinations and syntax. */
|
||||
export function buildSystemPromptAddendum(): string {
|
||||
const all = getAllDestinations();
|
||||
|
||||
if (all.length === 0) {
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* v1-parity tests for formatter behavior.
|
||||
*
|
||||
* Port of src/v1/formatting.test.ts (at commit 27c5220, parent of the v1
|
||||
* deletion commit 86becf8). Covers: context timezone header, reply_to +
|
||||
* quoted_message rendering, XML escaping, and stripInternalTags.
|
||||
*
|
||||
* Timestamp-format assertions use `formatLocalTime()` output format, which
|
||||
* is host locale-dependent for decorators (month abbr, "," separator) but
|
||||
* stable for the numeric parts we assert on (hour, minute, year).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { formatMessages, stripInternalTags } from './formatter.js';
|
||||
import { TIMEZONE } from './timezone.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(
|
||||
id: string,
|
||||
kind: string,
|
||||
content: object,
|
||||
opts?: { timestamp?: string },
|
||||
) {
|
||||
const timestamp = opts?.timestamp ?? new Date().toISOString();
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES (?, ?, ?, 'pending', ?)`,
|
||||
)
|
||||
.run(id, kind, timestamp, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('context timezone header', () => {
|
||||
it('prepends <context timezone="..."/> to formatted output', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hello' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
|
||||
});
|
||||
|
||||
it('includes the header even when the message list is empty', () => {
|
||||
const result = formatMessages([]);
|
||||
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
|
||||
});
|
||||
|
||||
it('header comes before the <messages> block', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
|
||||
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
const ctxIdx = result.indexOf('<context');
|
||||
const msgsIdx = result.indexOf('<messages>');
|
||||
expect(ctxIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(msgsIdx).toBeGreaterThan(ctxIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timestamp formatting', () => {
|
||||
it('renders time via formatLocalTime (user TZ)', () => {
|
||||
// 2026-06-15T12:00:00Z — timezone-agnostic assertions (year is stable)
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T12:00:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
// formatLocalTime's format in en-US contains the year and a month abbrev
|
||||
expect(result).toContain('2026');
|
||||
expect(result).toMatch(/Jun/);
|
||||
});
|
||||
|
||||
it('uses 12-hour AM/PM format', () => {
|
||||
// 15:30 UTC — some hour will show with AM or PM depending on TZ
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' }, { timestamp: '2026-06-15T15:30:00.000Z' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toMatch(/(AM|PM)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reply_to + quoted_message rendering', () => {
|
||||
it('renders reply_to attribute and quoted_message when all fields present', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'Yes, on my way!',
|
||||
replyTo: { id: '42', sender: 'Bob', text: 'Are you coming tonight?' },
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).toContain('<quoted_message from="Bob">Are you coming tonight?</quoted_message>');
|
||||
expect(result).toContain('Yes, on my way!</message>');
|
||||
});
|
||||
|
||||
it('omits reply_to and quoted_message when no reply context', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'plain' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).not.toContain('reply_to');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('renders reply_to but omits quoted_message when original content is missing', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'ack',
|
||||
replyTo: { id: '42', sender: 'Bob' }, // no text
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('reply_to="42"');
|
||||
expect(result).not.toContain('quoted_message');
|
||||
});
|
||||
|
||||
it('XML-escapes reply context', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'Alice',
|
||||
text: 'reply',
|
||||
replyTo: { id: '1', sender: 'A & B', text: '<script>alert("xss")</script>' },
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('from="A & B"');
|
||||
expect(result).toContain('<script>');
|
||||
expect(result).toContain('"xss"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XML escaping', () => {
|
||||
it('escapes <, >, &, " in sender and body', () => {
|
||||
insertMessage('m1', 'chat', {
|
||||
sender: 'A & B <Co>',
|
||||
text: '<script>alert("xss")</script>',
|
||||
});
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).toContain('sender="A & B <Co>"');
|
||||
expect(result).toContain('<script>alert("xss")</script>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripInternalTags', () => {
|
||||
it('strips single-line internal tags and trims', () => {
|
||||
expect(stripInternalTags('hello <internal>secret</internal> world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('strips multi-line internal tags', () => {
|
||||
expect(stripInternalTags('hello <internal>\nsecret\nstuff\n</internal> world')).toBe(
|
||||
'hello world',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips multiple internal tag blocks', () => {
|
||||
expect(stripInternalTags('<internal>a</internal>hello<internal>b</internal>')).toBe('hello');
|
||||
});
|
||||
|
||||
it('returns empty string when input is only internal tags', () => {
|
||||
expect(stripInternalTags('<internal>only this</internal>')).toBe('');
|
||||
});
|
||||
|
||||
it('returns input unchanged when there are no internal tags', () => {
|
||||
expect(stripInternalTags('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('preserves content that surrounds internal tags', () => {
|
||||
expect(stripInternalTags('<internal>thinking</internal>The answer is 42')).toBe(
|
||||
'The answer is 42',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { findByRouting } from './destinations.js';
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
|
||||
/**
|
||||
* Command categories for messages starting with '/'.
|
||||
@@ -12,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
|
||||
|
||||
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config']);
|
||||
|
||||
export interface CommandInfo {
|
||||
category: CommandCategory;
|
||||
@@ -55,17 +54,6 @@ export function categorizeMessage(msg: MessageInRow): CommandInfo {
|
||||
return { category: 'passthrough', command, text, senderId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow check for /clear — the only command the runner handles directly.
|
||||
* All other command gating (filtered, admin) is done by the host router
|
||||
* before messages reach the container.
|
||||
*/
|
||||
export function isClearCommand(msg: MessageInRow): boolean {
|
||||
const content = parseContent(msg.content);
|
||||
const text = (content.text || '').trim();
|
||||
return text.toLowerCase().startsWith('/clear');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function extractSenderId(msg: MessageInRow, content: any): string | null {
|
||||
const raw: string | null = content?.senderId || content?.author?.userId || null;
|
||||
@@ -104,19 +92,10 @@ export function extractRouting(messages: MessageInRow[]): RoutingContext {
|
||||
|
||||
/**
|
||||
* Format a batch of messages_in rows into a prompt string.
|
||||
*
|
||||
* Prepends a `<context timezone="<IANA>" />` header so the agent always knows
|
||||
* what timezone it's in — every timestamp it sees in message bodies is the
|
||||
* user's local time, and every time it produces (schedules, suggests) should
|
||||
* be interpreted as local time in that same zone. This header is v1 behavior
|
||||
* (src/v1/router.ts:20-22); dropping it led to misinterpretations where the
|
||||
* agent scheduled tasks for the wrong hour.
|
||||
*
|
||||
* Strips routing fields — the agent never sees platform_id, channel_type, thread_id.
|
||||
*/
|
||||
export function formatMessages(messages: MessageInRow[]): string {
|
||||
const header = `<context timezone="${escapeXml(TIMEZONE)}" />\n`;
|
||||
if (messages.length === 0) return header;
|
||||
if (messages.length === 0) return '';
|
||||
|
||||
// Group by kind
|
||||
const chatMessages = messages.filter((m) => m.kind === 'chat' || m.kind === 'chat-sdk');
|
||||
@@ -139,7 +118,7 @@ export function formatMessages(messages: MessageInRow[]): string {
|
||||
parts.push(...systemMessages.map(formatSystemMessage));
|
||||
}
|
||||
|
||||
return header + parts.join('\n\n');
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
function formatChatMessages(messages: MessageInRow[]): string {
|
||||
@@ -158,10 +137,9 @@ function formatChatMessages(messages: MessageInRow[]): string {
|
||||
function formatSingleChat(msg: MessageInRow): string {
|
||||
const content = parseContent(msg.content);
|
||||
const sender = content.sender || content.author?.fullName || content.author?.userName || 'Unknown';
|
||||
const time = formatLocalTime(msg.timestamp, TIMEZONE);
|
||||
const time = formatTime(msg.timestamp);
|
||||
const text = content.text || '';
|
||||
const idAttr = msg.seq != null ? ` id="${msg.seq}"` : '';
|
||||
const replyAttr = content.replyTo?.id ? ` reply_to="${escapeXml(String(content.replyTo.id))}"` : '';
|
||||
const replyPrefix = formatReplyContext(content.replyTo);
|
||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||
|
||||
@@ -176,7 +154,7 @@ function formatSingleChat(msg: MessageInRow): string {
|
||||
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
|
||||
: '';
|
||||
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${time}">${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||
}
|
||||
|
||||
function formatTaskMessage(msg: MessageInRow): string {
|
||||
@@ -201,22 +179,13 @@ function formatSystemMessage(msg: MessageInRow): string {
|
||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the quoted original inside the <message> body.
|
||||
*
|
||||
* Matches v1 format (src/v1/router.ts:10-18): `<quoted_message from="X">Y</quoted_message>`.
|
||||
* Requires BOTH sender and text — if only id is present the reply_to attribute
|
||||
* on the parent <message> carries the link without an inline preview.
|
||||
*
|
||||
* No truncation here (v1 didn't truncate).
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function formatReplyContext(replyTo: any): string {
|
||||
if (!replyTo) return '';
|
||||
const sender = replyTo.sender;
|
||||
const text = replyTo.text;
|
||||
if (!sender || !text) return '';
|
||||
return `\n <quoted_message from="${escapeXml(sender)}">${escapeXml(text)}</quoted_message>\n`;
|
||||
const sender = replyTo.sender || 'Unknown';
|
||||
const text = replyTo.text || '';
|
||||
const preview = text.length > 100 ? text.slice(0, 100) + '…' : text;
|
||||
return `\n<reply-to sender="${escapeXml(sender)}">${escapeXml(preview)}</reply-to>\n`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -244,15 +213,15 @@ function parseContent(json: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
try {
|
||||
const d = new Date(timestamp);
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
|
||||
} catch {
|
||||
return timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip `<internal>...</internal>` blocks from agent output, then trim.
|
||||
* Ported from v1 (src/v1/router.ts:25-27). Used to remove the agent's
|
||||
* own scratchpad/reasoning before a reply goes out over a channel.
|
||||
*/
|
||||
export function stripInternalTags(text: string): string {
|
||||
return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
* Runs inside a container. All IO goes through the session DB.
|
||||
* No stdin, no stdout markers, no IPC files.
|
||||
*
|
||||
* Config is read from /workspace/agent/container.json (mounted RO).
|
||||
* Only TZ and OneCLI networking vars come from env.
|
||||
* Config:
|
||||
* - SESSION_INBOUND_DB_PATH: path to host-owned inbound DB (default: /workspace/inbound.db)
|
||||
* - SESSION_OUTBOUND_DB_PATH: path to container-owned outbound DB (default: /workspace/outbound.db)
|
||||
* - SESSION_HEARTBEAT_PATH: heartbeat file path (default: /workspace/.heartbeat)
|
||||
* - AGENT_PROVIDER: any registered provider name (default: claude). The
|
||||
* set of registered providers is whatever `providers/index.ts` imports.
|
||||
* - NANOCLAW_ASSISTANT_NAME: assistant name for transcript archiving
|
||||
* - NANOCLAW_ADMIN_USER_IDS: comma-separated user IDs allowed to run admin commands
|
||||
*
|
||||
* Mount structure:
|
||||
* /workspace/
|
||||
@@ -13,19 +19,14 @@
|
||||
* outbound.db ← container-owned session DB
|
||||
* .heartbeat ← container touches for liveness detection
|
||||
* outbox/ ← outbound files
|
||||
* agent/ ← agent group folder (CLAUDE.md, container.json, working files)
|
||||
* container.json ← per-group config (RO nested mount)
|
||||
* global/ ← shared global memory (RO)
|
||||
* /app/src/ ← shared agent-runner source (RO)
|
||||
* /app/skills/ ← shared skills (RO)
|
||||
* /home/node/.claude/ ← Claude SDK state + skill symlinks (RW)
|
||||
* agent/ ← agent group folder (CLAUDE.md, skills, working files)
|
||||
* .claude/ ← Claude SDK session data
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { loadConfig } from './config.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
// Providers barrel — each enabled provider self-registers on import.
|
||||
// Provider skills append imports to providers/index.ts.
|
||||
@@ -40,18 +41,22 @@ function log(msg: string): void {
|
||||
const CWD = '/workspace/agent';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const providerName = config.provider.toLowerCase() as ProviderName;
|
||||
const providerName = (process.env.AGENT_PROVIDER || 'claude').toLowerCase() as ProviderName;
|
||||
const assistantName = process.env.NANOCLAW_ASSISTANT_NAME;
|
||||
const adminUserIds = new Set(
|
||||
(process.env.NANOCLAW_ADMIN_USER_IDS || '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
log(`Starting v2 agent-runner (provider: ${providerName})`);
|
||||
|
||||
// Runtime-generated system-prompt addendum: agent identity (name) plus
|
||||
// the live destinations map. Everything else (capabilities, per-module
|
||||
// instructions, per-channel formatting) is loaded by Claude Code from
|
||||
// /workspace/agent/CLAUDE.md — the composed entry imports the shared
|
||||
// base (/app/CLAUDE.md) and each enabled module's fragment. Per-group
|
||||
// memory lives in /workspace/agent/CLAUDE.local.md (auto-loaded).
|
||||
const instructions = buildSystemPromptAddendum(config.assistantName || undefined);
|
||||
// Destinations addendum is the only runtime-generated context we inject.
|
||||
// Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md
|
||||
// (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to
|
||||
// read it manually anymore.
|
||||
const instructions = buildSystemPromptAddendum();
|
||||
|
||||
// Discover additional directories mounted at /workspace/extra/*
|
||||
const additionalDirectories: string[] = [];
|
||||
@@ -72,22 +77,34 @@ async function main(): Promise<void> {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const mcpServerPath = path.join(__dirname, 'mcp-tools', 'index.ts');
|
||||
|
||||
// Build MCP servers config: nanoclaw built-in + any from container.json
|
||||
// Build MCP servers config: nanoclaw built-in + any additional from host
|
||||
const mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }> = {
|
||||
nanoclaw: {
|
||||
command: 'bun',
|
||||
args: ['run', mcpServerPath],
|
||||
env: {},
|
||||
env: {
|
||||
SESSION_INBOUND_DB_PATH: process.env.SESSION_INBOUND_DB_PATH || '/workspace/inbound.db',
|
||||
SESSION_OUTBOUND_DB_PATH: process.env.SESSION_OUTBOUND_DB_PATH || '/workspace/outbound.db',
|
||||
SESSION_HEARTBEAT_PATH: process.env.SESSION_HEARTBEAT_PATH || '/workspace/.heartbeat',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
mcpServers[name] = serverConfig;
|
||||
log(`Additional MCP server: ${name} (${serverConfig.command})`);
|
||||
// Merge additional MCP servers from host configuration
|
||||
if (process.env.NANOCLAW_MCP_SERVERS) {
|
||||
try {
|
||||
const additional = JSON.parse(process.env.NANOCLAW_MCP_SERVERS) as Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
for (const [name, config] of Object.entries(additional)) {
|
||||
mcpServers[name] = config;
|
||||
log(`Additional MCP server: ${name} (${config.command})`);
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Failed to parse NANOCLAW_MCP_SERVERS: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = createProvider(providerName, {
|
||||
assistantName: config.assistantName || undefined,
|
||||
assistantName,
|
||||
mcpServers,
|
||||
env: { ...process.env },
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
@@ -97,6 +114,7 @@ async function main(): Promise<void> {
|
||||
provider,
|
||||
cwd: CWD,
|
||||
systemContext: { instructions },
|
||||
adminUserIds,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
## Companion and collaborator agents (`create_agent`)
|
||||
|
||||
`mcp__nanoclaw__create_agent({ name, instructions })` spins up a new long-lived agent and wires it as a destination — bidirectional, so you can send it tasks and it can message you back.
|
||||
|
||||
### How it works
|
||||
|
||||
- Creates a new agent with its own container, workspace, and session. Your `instructions` string seeds the agent's `CLAUDE.local.md` — its starting role and personality.
|
||||
- The agent's `name` becomes a destination on both sides: you address it via `send_message({ to: "<name>", ... })`, and its replies arrive as inbound messages with `from="<name>"`.
|
||||
- Each agent has its own persistent workspace under `groups/<folder>/` — memory, conversation history, and notes all survive across sessions. This is a full standalone agent, not a stateless sub-query.
|
||||
- **Fire-and-forget:** the call returns immediately without waiting for the agent to confirm it's ready. Messages you send will queue until it's up.
|
||||
|
||||
### When to use
|
||||
|
||||
- **Companions** — a long-running presence that accumulates context over time: a `Researcher` tracking an ongoing inquiry, a `Calendar` agent managing scheduling, an assistant that knows your preferences and history.
|
||||
- **Collaborators** — a parallel specialist that works independently and reports back: a `Builder` handling code edits while you stay in conversation, a `Reviewer` running checks in the background.
|
||||
|
||||
The right frame is: does this agent need its own memory and context that builds over time, or does it need to work independently without blocking your turn? Either is a good reason to spawn one.
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- **One-off lookups or short tasks** — use the SDK `Agent` tool instead. It's stateless, spins up and completes in one shot, and leaves no persistent footprint.
|
||||
- **Work that finishes before the user's next message** — agents persist indefinitely. Don't create one for something you could do inline.
|
||||
|
||||
### Writing good `instructions`
|
||||
|
||||
Cover: the agent's role, who it takes tasks from (you, by name), how it should report back (on completion only? with milestones for long work?), and any domain-specific rules. Don't restate NanoClaw base behavior — the shared base is already loaded on the agent's end.
|
||||
@@ -1,27 +0,0 @@
|
||||
## Sending messages
|
||||
|
||||
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
|
||||
|
||||
### Mid-turn updates (`send_message`)
|
||||
|
||||
Use the `mcp__nanoclaw__send_message` tool to send a message while you're still working (before your final output). If you have one destination, `to` is optional; with multiple, specify it. Pace your updates to the length of the work:
|
||||
|
||||
- **Short turn (≤2 quick tool calls):** Don't narrate. Output any response.
|
||||
- **Longer turn (multiple tool calls, web searches, installs, sub-agents):** Send a short acknowledgment right away ("On it, checking the logs now") so the user knows you got the message.
|
||||
- **Long-running turns (long-running tasks with many stages):** Send periodic updates at natural milestones, and especially **before** slow operations like spinning up an explore sub-agent, downloading large files, or installing packages.
|
||||
|
||||
**Never narrate micro-steps.** "I'm going to read the file now… okay, I'm reading it… now I'm parsing it…" is noise. Updates should mark meaningful transitions, not every tool call.
|
||||
|
||||
**Outcomes, not play-by-play.** When the turn is done, the final message should be about the result, not a transcript of what you did.
|
||||
|
||||
### Sending files (`send_file`)
|
||||
|
||||
Use `mcp__nanoclaw__send_file({ path, text?, filename?, to? })` to deliver a file from your workspace. `path` is absolute or relative to `/workspace/agent/`; `filename` overrides the display name shown in chat (defaults to the file's basename); `text` is an optional accompanying message. Use this for artifacts you produce (charts, PDFs, generated images, reports) rather than dumping contents into chat.
|
||||
|
||||
### Reacting to messages (`add_reaction`)
|
||||
|
||||
Use `mcp__nanoclaw__add_reaction({ messageId, emoji })` to react to a specific inbound message by its `#N` id — pass `messageId` as an integer (e.g. `22`, not `"22"`). Good for lightweight acknowledgment (`eyes` = seen, `white_check_mark` = done) when a full reply would be noise. `emoji` is the shortcode name (e.g. `thumbs_up`, `heart`), not the raw character.
|
||||
|
||||
### Internal thoughts
|
||||
|
||||
Wrap reasoning in `<internal>...</internal>` tags to mark it as scratchpad — logged but not sent.
|
||||
@@ -1,22 +0,0 @@
|
||||
## Interactive prompts
|
||||
|
||||
The two tools here solve different problems: `ask_user_question` forces a decision and waits for it; `send_card` displays structured content and moves on.
|
||||
|
||||
### Asking a multiple-choice question (`ask_user_question`)
|
||||
|
||||
`mcp__nanoclaw__ask_user_question({ title, question, options, timeout? })` presents the user with a set of choices and **blocks your turn** until they tap one or the timeout expires (default: 300 seconds). Returns their chosen value.
|
||||
|
||||
`options` can be plain strings or `{ label, selectedLabel?, value? }` objects:
|
||||
- `label` — the button text shown before selection
|
||||
- `selectedLabel` — the text shown on the button *after* selection (useful for confirmations, e.g. `"✓ Confirmed"`)
|
||||
- `value` — the string returned to you when that option is chosen (defaults to `label`)
|
||||
|
||||
Use this when you genuinely cannot proceed without a decision. For free-text input, send a normal message and wait for their reply — don't reach for this tool.
|
||||
|
||||
### Structured cards (`send_card`)
|
||||
|
||||
`mcp__nanoclaw__send_card({ card, fallbackText? })` renders a structured card and **returns immediately** — it does not pause your turn or collect a response.
|
||||
|
||||
`card` supports: `title`, `description`, `children` (nested text or content blocks), and `actions` (buttons). `fallbackText` is sent as a plain message on platforms without card support.
|
||||
|
||||
Use this for presenting information in a cleaner format than prose: summaries, options the user can read (but you're not waiting on), or results with contextual buttons. If you need the user to actually *choose* something and return a value, use `ask_user_question` instead.
|
||||
@@ -1,40 +0,0 @@
|
||||
## Task scheduling (`schedule_task`)
|
||||
|
||||
For any recurring task, use `schedule_task`. This is the scheduling path — tasks persist across sessions and restarts, and support the pre-task `script` hook described below.
|
||||
|
||||
To inspect or change existing tasks, use `list_tasks` (returns one row per series with the stable id) and `update_task` / `cancel_task` / `pause_task` / `resume_task`. Prefer `update_task` over cancel + reschedule.
|
||||
|
||||
Frequent recurring scheduled tasks — more than a few times a day — consume API credits and can risk account restrictions. You can add a `script` that runs first, and you will only be called when the check passes.
|
||||
|
||||
### How it works
|
||||
|
||||
1. Provide a bash `script` alongside the `prompt` when scheduling
|
||||
2. When the task fires, the script runs first
|
||||
3. Script returns: `{ "wakeAgent": true/false, "data": {...} }`
|
||||
4. If `wakeAgent: false` — nothing happens, task waits for next run
|
||||
5. If `wakeAgent: true` — claude receives the script's data + prompt and handles
|
||||
|
||||
### Always test your script first
|
||||
|
||||
Before scheduling, run the script directly to verify it works:
|
||||
|
||||
```bash
|
||||
bash -c 'node --input-type=module -e "
|
||||
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
|
||||
const prs = await r.json();
|
||||
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
|
||||
"'
|
||||
```
|
||||
|
||||
### When NOT to use scripts
|
||||
|
||||
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. Do not attempt to do things like sentiment analysis or advanced nlp in scripts.
|
||||
|
||||
### Frequent task guidance
|
||||
|
||||
If a user wants a task to run more than a few times a day and a script can't be used:
|
||||
|
||||
- Explain that each time the task fires it uses API credits and risks rate limits
|
||||
- Suggest adjusting the task requirements in a way that will allow you to use a script
|
||||
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
|
||||
- Help the user find the minimum viable frequency
|
||||
@@ -8,7 +8,6 @@
|
||||
import { getInboundDb } from '../db/connection.js';
|
||||
import { writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
import { TIMEZONE, parseZonedToUtc } from '../timezone.js';
|
||||
import { registerTools } from './server.js';
|
||||
import type { McpToolDefinition } from './types.js';
|
||||
|
||||
@@ -36,21 +35,13 @@ export const scheduleTask: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'schedule_task',
|
||||
description:
|
||||
`Schedule a one-shot or recurring task. The user's timezone is declared in the <context timezone="..."/> header of your prompt — interpret the user's "9pm" etc. in that zone. Cron expressions are interpreted in the user's timezone too.`,
|
||||
'Schedule a one-shot or recurring task. The task will be processed at the specified time. Use cron expressions for recurring tasks.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
prompt: { type: 'string', description: 'Task instructions/prompt' },
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`ISO 8601 timestamp for the first run. Accepts either UTC (ending in "Z" or "+00:00") or a naive local timestamp (no offset) which is interpreted in the user's timezone (e.g. "2026-01-15T21:00:00" = 9pm user-local). Prefer naive local.`,
|
||||
},
|
||||
recurrence: {
|
||||
type: 'string',
|
||||
description:
|
||||
'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" = weekdays at 9am user-local). Evaluated in the user\'s timezone.',
|
||||
},
|
||||
processAfter: { type: 'string', description: 'ISO timestamp for first run (e.g., 2024-01-15T09:00:00Z)' },
|
||||
recurrence: { type: 'string', description: 'Cron expression for recurring tasks (e.g., "0 9 * * 1-5" for weekdays at 9am)' },
|
||||
script: { type: 'string', description: 'Optional pre-agent script to run before processing' },
|
||||
},
|
||||
required: ['prompt', 'processAfter'],
|
||||
@@ -58,17 +49,8 @@ export const scheduleTask: McpToolDefinition = {
|
||||
},
|
||||
async handler(args) {
|
||||
const prompt = args.prompt as string;
|
||||
const processAfterIn = args.processAfter as string;
|
||||
if (!prompt || !processAfterIn) return err('prompt and processAfter are required');
|
||||
|
||||
let processAfter: string;
|
||||
try {
|
||||
const d = parseZonedToUtc(processAfterIn, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${processAfterIn}`);
|
||||
processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${processAfterIn}`);
|
||||
}
|
||||
const processAfter = args.processAfter as string;
|
||||
if (!prompt || !processAfter) return err('prompt and processAfter are required');
|
||||
|
||||
const id = generateId();
|
||||
const r = routing();
|
||||
@@ -251,11 +233,7 @@ export const updateTask: McpToolDefinition = {
|
||||
type: 'string',
|
||||
description: 'New cron expression (optional). Pass empty string to clear and make the task one-shot.',
|
||||
},
|
||||
processAfter: {
|
||||
type: 'string',
|
||||
description:
|
||||
`New ISO 8601 timestamp for the next run (optional). Accepts either UTC (ending in "Z" / "+00:00") or a naive local timestamp interpreted in the user's timezone.`,
|
||||
},
|
||||
processAfter: { type: 'string', description: 'New ISO timestamp for the next run (optional)' },
|
||||
script: {
|
||||
type: 'string',
|
||||
description: 'New pre-agent script (optional). Pass empty string to clear.',
|
||||
@@ -270,15 +248,7 @@ export const updateTask: McpToolDefinition = {
|
||||
|
||||
const update: Record<string, unknown> = { taskId };
|
||||
if (typeof args.prompt === 'string') update.prompt = args.prompt;
|
||||
if (typeof args.processAfter === 'string') {
|
||||
try {
|
||||
const d = parseZonedToUtc(args.processAfter, TIMEZONE);
|
||||
if (Number.isNaN(d.getTime())) return err(`invalid processAfter: ${args.processAfter}`);
|
||||
update.processAfter = d.toISOString();
|
||||
} catch {
|
||||
return err(`invalid processAfter: ${args.processAfter}`);
|
||||
}
|
||||
}
|
||||
if (typeof args.processAfter === 'string') update.processAfter = args.processAfter;
|
||||
// Empty string clears recurrence/script; undefined leaves them as-is.
|
||||
if (typeof args.recurrence === 'string') update.recurrence = args.recurrence === '' ? null : args.recurrence;
|
||||
if (typeof args.script === 'string') update.script = args.script === '' ? null : args.script;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
## Installing packages & tools
|
||||
|
||||
To install packages that persist, use the self-modification tools:
|
||||
|
||||
**`install_packages`** — request system (apt) or global npm packages. Requires admin approval.
|
||||
|
||||
Example flow:
|
||||
```
|
||||
install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription" })
|
||||
# → Admin gets an approval card → approves
|
||||
```
|
||||
|
||||
**When to use this vs workspace `pnpm install`:**
|
||||
- `pnpm install` if you only need it temporarily to do one task. Will not be available in subsequent truns.
|
||||
- `install_packages` persists for all future turns. Use especially if the user specifically asks you to add a capability
|
||||
|
||||
### MCP servers (`add_mcp_server`)
|
||||
|
||||
Use **`add_mcp_server`** to add an MCP server to your configuration. Browse available servers at https://mcp.so — it's a curated directory of high-quality MCP servers. Most Node.js servers run via `pnpm dlx`, e.g.:
|
||||
|
||||
```
|
||||
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
|
||||
```
|
||||
|
||||
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
|
||||
@@ -1,13 +1,9 @@
|
||||
/**
|
||||
* Self-modification MCP tools: install_packages, add_mcp_server.
|
||||
* Self-modification MCP tools: install_packages, add_mcp_server, request_rebuild.
|
||||
*
|
||||
* Both are fire-and-forget — the tool writes a system action row and returns
|
||||
* immediately. The host processes the request (including admin approval)
|
||||
* and notifies the agent via a chat message when complete. Admin approval
|
||||
* is approval to apply the change: `install_packages` auto-rebuilds the
|
||||
* per-agent image and restarts the container; `add_mcp_server` just
|
||||
* updates `container.json` and restarts (bun runs TS directly — no build
|
||||
* step needed for a pure MCP wiring change).
|
||||
* All three are fire-and-forget — the tool writes a system action row and
|
||||
* returns immediately. The host processes the request (including admin
|
||||
* approval) and notifies the agent via a chat message when complete.
|
||||
*
|
||||
* Package names are sanitized here at the tool boundary AND re-validated on
|
||||
* the host side (defense in depth).
|
||||
@@ -40,7 +36,7 @@ export const installPackages: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'install_packages',
|
||||
description:
|
||||
'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. On approval, the image is rebuilt and the container is restarted automatically.',
|
||||
'Install apt and/or npm packages into YOUR per-agent container image. Requires admin approval; fire-and-forget. After approval, call `request_rebuild` to apply.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
@@ -117,4 +113,32 @@ export const addMcpServer: McpToolDefinition = {
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([installPackages, addMcpServer]);
|
||||
export const requestRebuild: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'request_rebuild',
|
||||
description:
|
||||
'Rebuild YOUR container image to pick up approved `install_packages` / `add_mcp_server` changes. Requires admin approval; fire-and-forget.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
reason: { type: 'string', description: 'Why the rebuild is needed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(args) {
|
||||
const requestId = generateId();
|
||||
writeMessageOut({
|
||||
id: requestId,
|
||||
kind: 'system',
|
||||
content: JSON.stringify({
|
||||
action: 'request_rebuild',
|
||||
reason: (args.reason as string) || '',
|
||||
}),
|
||||
});
|
||||
|
||||
log(`request_rebuild: ${requestId}`);
|
||||
return ok(`Rebuild request submitted. You will be notified when admin approves or rejects.`);
|
||||
},
|
||||
};
|
||||
|
||||
registerTools([installPackages, addMcpServer, requestRebuild]);
|
||||
|
||||
@@ -14,13 +14,13 @@ afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
|
||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string }) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?)`,
|
||||
)
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
|
||||
.run(id, kind, opts?.processAfter ?? null, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('formatter', () => {
|
||||
@@ -84,51 +84,6 @@ describe('formatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('accumulate gate (trigger column)', () => {
|
||||
it('getPendingMessages returns both trigger=0 and trigger=1 rows', () => {
|
||||
// trigger=0 rides along as context, trigger=1 is the wake-eligible row.
|
||||
// The poll loop's gate depends on this data contract.
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'chit chat' }, { trigger: 0 });
|
||||
insertMessage('m2', 'chat', { sender: 'B', text: 'actual mention' }, { trigger: 1 });
|
||||
const messages = getPendingMessages();
|
||||
expect(messages).toHaveLength(2);
|
||||
const byId = Object.fromEntries(messages.map((m) => [m.id, m]));
|
||||
expect(byId.m1.trigger).toBe(0);
|
||||
expect(byId.m2.trigger).toBe(1);
|
||||
});
|
||||
|
||||
it('trigger=0-only batch: gate predicate `some(trigger===1)` is false', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'noise' }, { trigger: 0 });
|
||||
insertMessage('m2', 'chat', { sender: 'B', text: 'more noise' }, { trigger: 0 });
|
||||
const messages = getPendingMessages();
|
||||
// This is the exact predicate the poll loop uses to skip accumulate-only
|
||||
// batches — gate should be false, so the loop sleeps without waking the agent.
|
||||
expect(messages.some((m) => m.trigger === 1)).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed batch: gate is true → loop proceeds, accumulated rows ride along', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'earlier chatter' }, { trigger: 0 });
|
||||
insertMessage('m2', 'chat', { sender: 'B', text: 'the real mention' }, { trigger: 1 });
|
||||
const messages = getPendingMessages();
|
||||
expect(messages.some((m) => m.trigger === 1)).toBe(true);
|
||||
// Both messages are present for the formatter → agent sees the prior context.
|
||||
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('trigger column defaults to 1 for legacy inserts without explicit value', () => {
|
||||
// The schema default is 1 (see src/db/schema.ts INBOUND_SCHEMA) — existing
|
||||
// rows / tests without the column set are effectively wake-eligible.
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
|
||||
)
|
||||
.run();
|
||||
const [msg] = getPendingMessages();
|
||||
expect(msg.trigger).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
it('should extract routing from messages', () => {
|
||||
getInboundDb()
|
||||
|
||||
@@ -3,11 +3,12 @@ import { getPendingMessages, markProcessing, markCompleted, type MessageInRow }
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
const IDLE_END_MS = 20_000; // End stream after 20s with no SDK events
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[poll-loop] ${msg}`);
|
||||
@@ -23,6 +24,12 @@ export interface PollLoopConfig {
|
||||
systemContext?: {
|
||||
instructions?: string;
|
||||
};
|
||||
/**
|
||||
* Set of user IDs allowed to run admin commands (e.g. /clear) in this
|
||||
* agent group. Host populates from owners + global admins + scoped admins
|
||||
* at container wake time, so role changes take effect on next spawn.
|
||||
*/
|
||||
adminUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,54 +73,79 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate gate: if the batch contains only trigger=0 rows
|
||||
// (context-only, router-stored under ignored_message_policy='accumulate'),
|
||||
// don't wake the agent. Leave them `pending` — they'll ride along the
|
||||
// next time a real trigger=1 message lands via this same getPendingMessages
|
||||
// query. Without this gate, a warm container keeps processing
|
||||
// (and potentially responding to) every accumulate-only batch, defeating
|
||||
// the "store as context, don't engage" contract. Host-side countDueMessages
|
||||
// gates the same way for wake-from-cold (see src/db/session-db.ts).
|
||||
if (!messages.some((m) => m.trigger === 1)) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = messages.map((m) => m.id);
|
||||
markProcessing(ids);
|
||||
|
||||
const routing = extractRouting(messages);
|
||||
|
||||
// Command handling: the host router gates filtered and unauthorized
|
||||
// admin commands before they reach the container. The only command
|
||||
// the runner handles directly is /clear (session reset).
|
||||
const normalMessages: MessageInRow[] = [];
|
||||
// Handle commands: categorize chat messages
|
||||
const adminUserIds = config.adminUserIds ?? new Set<string>();
|
||||
const normalMessages = [];
|
||||
const commandIds: string[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isClearCommand(msg)) {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: 'Session cleared.' }),
|
||||
});
|
||||
if (msg.kind !== 'chat' && msg.kind !== 'chat-sdk') {
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
const cmdInfo = categorizeMessage(msg);
|
||||
|
||||
if (cmdInfo.category === 'filtered') {
|
||||
// Silently drop — mark completed, don't process
|
||||
log(`Filtered command: ${cmdInfo.command} (msg: ${msg.id})`);
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (cmdInfo.category === 'admin') {
|
||||
if (!cmdInfo.senderId || !adminUserIds.has(cmdInfo.senderId)) {
|
||||
log(`Admin command denied: ${cmdInfo.command} from ${cmdInfo.senderId} (msg: ${msg.id})`);
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Permission denied: ${cmdInfo.command} requires admin access.` }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
// Handle admin commands directly
|
||||
if (cmdInfo.command === '/clear') {
|
||||
log('Clearing session (resetting continuation)');
|
||||
continuation = undefined;
|
||||
clearStoredSessionId();
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: 'Session cleared.' }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other admin commands — pass through to agent
|
||||
normalMessages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// passthrough or none
|
||||
normalMessages.push(msg);
|
||||
}
|
||||
|
||||
// Mark filtered/denied command messages as completed immediately
|
||||
if (commandIds.length > 0) {
|
||||
markCompleted(commandIds);
|
||||
}
|
||||
|
||||
// If all messages were filtered commands, skip processing
|
||||
if (normalMessages.length === 0) {
|
||||
// Mark remaining processing IDs as completed
|
||||
const remainingIds = ids.filter((id) => !commandIds.includes(id));
|
||||
if (remainingIds.length > 0) markCompleted(remainingIds);
|
||||
log(`All ${messages.length} message(s) were commands, skipping query`);
|
||||
@@ -160,7 +192,7 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds);
|
||||
const result = await processQuery(query, routing);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
continuation = result.continuation;
|
||||
setStoredSessionId(continuation);
|
||||
@@ -189,8 +221,6 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
// (e.g. stream closed unexpectedly).
|
||||
markCompleted(processingIds);
|
||||
log(`Completed ${ids.length} message(s)`);
|
||||
}
|
||||
@@ -234,33 +264,27 @@ interface QueryResult {
|
||||
continuation?: string;
|
||||
}
|
||||
|
||||
async function processQuery(
|
||||
query: AgentQuery,
|
||||
routing: RoutingContext,
|
||||
initialBatchIds: string[],
|
||||
): Promise<QueryResult> {
|
||||
async function processQuery(query: AgentQuery, routing: RoutingContext): Promise<QueryResult> {
|
||||
let queryContinuation: string | undefined;
|
||||
let done = false;
|
||||
let lastEventTime = Date.now();
|
||||
|
||||
// Concurrent polling: push follow-ups into the active query as they arrive.
|
||||
// We do NOT force-end the stream on silence — keeping the query open is
|
||||
// strictly cheaper than close+reopen (no cold prompt cache, no reconnect).
|
||||
// Stream liveness is decided host-side via the heartbeat file + processing
|
||||
// claim age (see src/host-sweep.ts); if something is truly stuck, the host
|
||||
// will kill the container and messages get reset to pending.
|
||||
// Concurrent polling: push follow-ups, checkpoint WAL, detect idle
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done) return;
|
||||
|
||||
// Skip system messages (MCP tool responses) and /clear (needs fresh query).
|
||||
// Thread routing is the router's concern — if a message landed in this
|
||||
// session, the agent should see it. Per-thread sessions already isolate
|
||||
// threads into separate containers; shared sessions intentionally merge
|
||||
// everything. Filtering on thread_id here caused deadlocks when the
|
||||
// initial batch and follow-ups had mismatched thread_ids (e.g. a
|
||||
// host-generated welcome trigger with null thread vs a Discord DM reply).
|
||||
// Skip system messages (MCP tool responses) and admin commands (need fresh query).
|
||||
// Also defer messages whose thread_id differs from the active turn's routing
|
||||
// — mixing threads into one streaming turn would send the reply to the wrong
|
||||
// thread because `routing` is captured at turn start. The next turn will pick
|
||||
// them up with fresh routing.
|
||||
const newMessages = getPendingMessages().filter((m) => {
|
||||
if (m.kind === 'system') return false;
|
||||
if ((m.kind === 'chat' || m.kind === 'chat-sdk') && isClearCommand(m)) return false;
|
||||
if (m.kind === 'chat' || m.kind === 'chat-sdk') {
|
||||
const cmd = categorizeMessage(m);
|
||||
if (cmd.category === 'admin') return false;
|
||||
}
|
||||
if ((m.thread_id ?? null) !== (routing.threadId ?? null)) return false;
|
||||
return true;
|
||||
});
|
||||
if (newMessages.length > 0) {
|
||||
@@ -272,34 +296,26 @@ async function processQuery(
|
||||
query.push(prompt);
|
||||
|
||||
markCompleted(newIds);
|
||||
lastEventTime = Date.now(); // new input counts as activity
|
||||
}
|
||||
|
||||
// End stream when agent is idle: no SDK events and no pending messages
|
||||
if (Date.now() - lastEventTime > IDLE_END_MS) {
|
||||
log(`No SDK events for ${IDLE_END_MS / 1000}s, ending query`);
|
||||
query.end();
|
||||
}
|
||||
}, ACTIVE_POLL_INTERVAL_MS);
|
||||
|
||||
try {
|
||||
for await (const event of query.events) {
|
||||
lastEventTime = Date.now();
|
||||
handleEvent(event, routing);
|
||||
touchHeartbeat();
|
||||
|
||||
if (event.type === 'init') {
|
||||
queryContinuation = event.continuation;
|
||||
// Persist immediately so a mid-turn container crash still lets the
|
||||
// next wake resume the conversation. Without this, the session id
|
||||
// was only written after the full stream completed — if the
|
||||
// container died between `init` and `result`, the SDK session was
|
||||
// effectively orphaned and the next message started a blank
|
||||
// Claude session with no prior context.
|
||||
setStoredSessionId(event.continuation);
|
||||
} else if (event.type === 'result') {
|
||||
// A result — with or without text — means the turn is done. Mark
|
||||
// the initial batch completed now so the host sweep doesn't see
|
||||
// stale 'processing' claims while the query stays open for
|
||||
// follow-up pushes. The agent may have responded via MCP
|
||||
// (send_message) mid-turn, or the message may not need a response
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
} else if (event.type === 'result' && event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -368,7 +384,10 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
scratchpadParts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
const scratchpad = stripInternalTags(scratchpadParts.join(''));
|
||||
const scratchpad = scratchpadParts
|
||||
.join('')
|
||||
.replace(/<internal>[\s\S]*?<\/internal>/g, '')
|
||||
.trim();
|
||||
|
||||
// Single-destination shortcut: the agent wrote plain text — send to
|
||||
// the session's originating channel (from session_routing) if available,
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from 'path';
|
||||
|
||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js';
|
||||
import { registerProvider } from './provider-registry.js';
|
||||
import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
|
||||
|
||||
@@ -11,28 +10,10 @@ function log(msg: string): void {
|
||||
console.error(`[claude-provider] ${msg}`);
|
||||
}
|
||||
|
||||
// Deferred SDK builtins that either sidestep nanoclaw's own scheduling or
|
||||
// don't fit our async message-passing model (they're designed for Claude
|
||||
// Code's interactive UI and would hang here).
|
||||
//
|
||||
// - CronCreate / CronDelete / CronList / ScheduleWakeup: we have durable
|
||||
// scheduling via mcp__nanoclaw__schedule_task.
|
||||
// - AskUserQuestion: SDK returns a placeholder instead of blocking on a
|
||||
// real answer — we have mcp__nanoclaw__ask_user_question that persists
|
||||
// the question and blocks on the real reply.
|
||||
// - EnterPlanMode / ExitPlanMode / EnterWorktree / ExitWorktree: Claude
|
||||
// Code UI affordances; in a headless container they'd appear stuck.
|
||||
const SDK_DISALLOWED_TOOLS = [
|
||||
'CronCreate',
|
||||
'CronDelete',
|
||||
'CronList',
|
||||
'ScheduleWakeup',
|
||||
'AskUserQuestion',
|
||||
'EnterPlanMode',
|
||||
'ExitPlanMode',
|
||||
'EnterWorktree',
|
||||
'ExitWorktree',
|
||||
];
|
||||
// Deferred SDK builtins that would sidestep nanoclaw's own scheduling.
|
||||
// Scheduling goes through mcp__nanoclaw__schedule_task so that tasks are
|
||||
// durable across sessions/restarts and gated by our pre-task script hook.
|
||||
const SDK_DISALLOWED_TOOLS = ['CronCreate', 'CronDelete', 'CronList', 'ScheduleWakeup'];
|
||||
|
||||
// Tool allowlist for NanoClaw agent containers
|
||||
const TOOL_ALLOWLIST = [
|
||||
@@ -141,43 +122,6 @@ function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | nu
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* PreToolUse hook: record the current tool + its declared timeout so the host
|
||||
* sweep can widen its stuck tolerance while Bash is running a long-declared
|
||||
* script. Defense-in-depth: if SDK_DISALLOWED_TOOLS slips through somehow,
|
||||
* block the call here instead of letting the agent hang.
|
||||
*/
|
||||
const preToolUseHook: HookCallback = async (input) => {
|
||||
const i = input as { tool_name?: string; tool_input?: Record<string, unknown> };
|
||||
const toolName = i.tool_name ?? '';
|
||||
if (SDK_DISALLOWED_TOOLS.includes(toolName)) {
|
||||
return {
|
||||
decision: 'block',
|
||||
stopReason: `Tool '${toolName}' is not available in this environment — use the nanoclaw equivalent.`,
|
||||
} as unknown as ReturnType<HookCallback>;
|
||||
}
|
||||
// Bash exposes its timeout via the tool_input.timeout field (ms). Any other
|
||||
// tool: no declared timeout.
|
||||
const declaredTimeoutMs =
|
||||
toolName === 'Bash' && typeof i.tool_input?.timeout === 'number' ? (i.tool_input.timeout as number) : null;
|
||||
try {
|
||||
setContainerToolInFlight(toolName, declaredTimeoutMs);
|
||||
} catch (err) {
|
||||
log(`PreToolUse: failed to record container_state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/** Clear in-flight tool on PostToolUse / PostToolUseFailure. */
|
||||
const postToolUseHook: HookCallback = async () => {
|
||||
try {
|
||||
clearContainerToolInFlight();
|
||||
} catch (err) {
|
||||
log(`PostToolUse: failed to clear container_state: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
@@ -271,7 +215,6 @@ export class ClaudeProvider implements AgentProvider {
|
||||
cwd: input.cwd,
|
||||
additionalDirectories: this.additionalDirectories,
|
||||
resume: input.continuation,
|
||||
pathToClaudeCodeExecutable: '/pnpm/claude',
|
||||
systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined,
|
||||
allowedTools: TOOL_ALLOWLIST,
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
@@ -281,9 +224,6 @@ export class ClaudeProvider implements AgentProvider {
|
||||
settingSources: ['project', 'user'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
PostToolUse: [{ hooks: [postToolUseHook] }],
|
||||
PostToolUseFailure: [{ hooks: [postToolUseHook] }],
|
||||
PreCompact: [{ hooks: [createPreCompactHook(this.assistantName)] }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
|
||||
import { formatLocalTime, isValidTimezone, parseZonedToUtc, resolveTimezone } from './timezone.js';
|
||||
|
||||
// --- formatLocalTime ---
|
||||
|
||||
describe('formatLocalTime', () => {
|
||||
it('converts UTC to local time display', () => {
|
||||
// 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
|
||||
const result = formatLocalTime('2026-02-04T18:30:00.000Z', 'America/New_York');
|
||||
expect(result).toContain('1:30');
|
||||
expect(result).toContain('PM');
|
||||
expect(result).toContain('Feb');
|
||||
expect(result).toContain('2026');
|
||||
});
|
||||
|
||||
it('handles different timezones', () => {
|
||||
// Same UTC time should produce different local times
|
||||
const utc = '2026-06-15T12:00:00.000Z';
|
||||
const ny = formatLocalTime(utc, 'America/New_York');
|
||||
const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
|
||||
// NY is UTC-4 in summer (EDT), Tokyo is UTC+9
|
||||
expect(ny).toContain('8:00');
|
||||
expect(tokyo).toContain('9:00');
|
||||
});
|
||||
|
||||
it('does not throw on invalid timezone, falls back to UTC', () => {
|
||||
expect(() => formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2')).not.toThrow();
|
||||
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
|
||||
// Should format as UTC (noon UTC = 12:00 PM)
|
||||
expect(result).toContain('12:00');
|
||||
expect(result).toContain('PM');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidTimezone', () => {
|
||||
it('accepts valid IANA identifiers', () => {
|
||||
expect(isValidTimezone('America/New_York')).toBe(true);
|
||||
expect(isValidTimezone('UTC')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
|
||||
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid timezone strings', () => {
|
||||
expect(isValidTimezone('IST-2')).toBe(false);
|
||||
expect(isValidTimezone('XYZ+3')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects empty and garbage strings', () => {
|
||||
expect(isValidTimezone('')).toBe(false);
|
||||
expect(isValidTimezone('NotATimezone')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTimezone', () => {
|
||||
it('returns the timezone if valid', () => {
|
||||
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
|
||||
});
|
||||
|
||||
it('falls back to UTC for invalid timezone', () => {
|
||||
expect(resolveTimezone('IST-2')).toBe('UTC');
|
||||
expect(resolveTimezone('')).toBe('UTC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseZonedToUtc', () => {
|
||||
it('passes strings with Z suffix through unchanged', () => {
|
||||
const d = parseZonedToUtc('2026-01-15T09:00:00Z', 'America/New_York');
|
||||
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
|
||||
});
|
||||
|
||||
it('passes strings with numeric offset through unchanged', () => {
|
||||
const d = parseZonedToUtc('2026-01-15T09:00:00+02:00', 'America/New_York');
|
||||
expect(d.toISOString()).toBe('2026-01-15T07:00:00.000Z');
|
||||
});
|
||||
|
||||
it('interprets naive ISO as wall-clock in the given timezone', () => {
|
||||
// 09:00 naive in NY in January = 09:00 EST = 14:00 UTC
|
||||
const d = parseZonedToUtc('2026-01-15T09:00:00', 'America/New_York');
|
||||
expect(d.toISOString()).toBe('2026-01-15T14:00:00.000Z');
|
||||
});
|
||||
|
||||
it('handles a different positive-offset zone', () => {
|
||||
// 09:00 naive in Tokyo (UTC+9) = 00:00 UTC
|
||||
const d = parseZonedToUtc('2026-06-15T09:00:00', 'Asia/Tokyo');
|
||||
expect(d.toISOString()).toBe('2026-06-15T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('treats invalid timezone as UTC', () => {
|
||||
const d = parseZonedToUtc('2026-01-15T09:00:00', 'NotATimezone');
|
||||
expect(d.toISOString()).toBe('2026-01-15T09:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Timezone utilities — mirror of src/timezone.ts (host).
|
||||
*
|
||||
* The container can't import from src/ (separate tsconfig, different runtime).
|
||||
* Kept deliberately byte-aligned with the host module so behaviour is the
|
||||
* same on both sides of the session-DB boundary.
|
||||
*
|
||||
* TIMEZONE is resolved once at module load from process.env.TZ (which the host
|
||||
* sets from its own TIMEZONE constant when spawning the container; see
|
||||
* src/container-runner.ts). Invalid values fall back to UTC.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check whether a timezone string is a valid IANA identifier
|
||||
* that Intl.DateTimeFormat can use.
|
||||
*/
|
||||
export function isValidTimezone(tz: string): boolean {
|
||||
try {
|
||||
Intl.DateTimeFormat(undefined, { timeZone: tz });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the given timezone if valid IANA, otherwise fall back to UTC.
|
||||
*/
|
||||
export function resolveTimezone(tz: string): string {
|
||||
return isValidTimezone(tz) ? tz : 'UTC';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a UTC ISO timestamp to a localized display string.
|
||||
* Uses the Intl API (no external dependencies).
|
||||
* Falls back to UTC if the timezone is invalid.
|
||||
*/
|
||||
export function formatLocalTime(utcIso: string, timezone: string): string {
|
||||
const date = new Date(utcIso);
|
||||
return date.toLocaleString('en-US', {
|
||||
timeZone: resolveTimezone(timezone),
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveContainerTimezone(): string {
|
||||
const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone];
|
||||
for (const tz of candidates) {
|
||||
if (tz && isValidTimezone(tz)) return tz;
|
||||
}
|
||||
return 'UTC';
|
||||
}
|
||||
|
||||
export const TIMEZONE = resolveContainerTimezone();
|
||||
|
||||
/**
|
||||
* Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock
|
||||
* time in `tz` and return the corresponding UTC Date. Strings that already carry
|
||||
* offset info (`Z` or `±HH:MM`) are passed through to the Date constructor
|
||||
* unchanged.
|
||||
*
|
||||
* Algorithm: treat the naive string as UTC, ask Intl.DateTimeFormat what that
|
||||
* UTC instant is called in `tz`, then invert the offset. Near DST boundaries
|
||||
* this can be off by an hour for ~1h of wall-clock time per year; acceptable
|
||||
* for scheduling where the agent normally picks round-hour targets.
|
||||
*/
|
||||
export function parseZonedToUtc(input: string, tz: string): Date {
|
||||
const hasOffset = /Z$|[+-]\d{2}:?\d{2}$/.test(input.trim());
|
||||
if (hasOffset) return new Date(input);
|
||||
|
||||
const zone = resolveTimezone(tz);
|
||||
const asIfUtc = new Date(input + 'Z');
|
||||
if (Number.isNaN(asIfUtc.getTime())) return asIfUtc;
|
||||
|
||||
const fmt = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: zone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = Object.fromEntries(
|
||||
fmt
|
||||
.formatToParts(asIfUtc)
|
||||
.filter((p) => p.type !== 'literal')
|
||||
.map((p) => [p.type, p.value]),
|
||||
);
|
||||
const hour = parts.hour === '24' ? '00' : parts.hour;
|
||||
const zonedAsUtcMs = Date.UTC(
|
||||
Number(parts.year),
|
||||
Number(parts.month) - 1,
|
||||
Number(parts.day),
|
||||
Number(hour),
|
||||
Number(parts.minute),
|
||||
Number(parts.second),
|
||||
);
|
||||
const offsetMs = zonedAsUtcMs - asIfUtc.getTime();
|
||||
return new Date(asIfUtc.getTime() - offsetMs);
|
||||
}
|
||||
@@ -11,9 +11,9 @@ You can modify your own environment. Different kinds of changes have different w
|
||||
|
||||
**What needs to change?**
|
||||
|
||||
- **`CLAUDE.local.md` or files in your workspace** → Edit directly, no approval needed. Your workspace (`/workspace/agent/`) is persisted on the host. (Note: the composed `CLAUDE.md` itself is read-only and regenerated every spawn — write to `CLAUDE.local.md` instead.)
|
||||
- **System package (apt) or global npm package** → `install_packages`. Requires admin approval. On approval, image rebuild + container restart happen automatically.
|
||||
- **MCP server** → `add_mcp_server`. Requires admin approval. On approval, container restarts with the new server wired up (no rebuild — bun runs TS directly).
|
||||
- **Your CLAUDE.md or files in your workspace** → Edit directly, no approval needed. Your workspace (`/workspace/agent/`) is persisted on the host.
|
||||
- **System package (apt) or global npm package** → `install_packages` → `request_rebuild`. Requires admin approval.
|
||||
- **MCP server** → `add_mcp_server` → `request_rebuild`. No approval needed, but rebuild required to apply.
|
||||
- **Your source code or Dockerfile** → Delegate to a builder agent via `create_agent` (see below).
|
||||
- **A new specialist capability** → `create_agent` to spin up a dedicated agent for it.
|
||||
|
||||
@@ -25,7 +25,7 @@ For anything that requires editing source files (your own code, Dockerfile, etc.
|
||||
2. Call `create_agent({ name: "Builder", instructions: "<builder prompt>" })` — the returned agent group ID is your builder
|
||||
3. Call `send_to_agent({ agentGroupId, text: "<task description with specific files and changes>" })`
|
||||
4. The builder works in its own container, makes the changes, and reports back
|
||||
5. You review the builder's summary and confirm with the user. Source-code edits inside `/app/src` are picked up automatically on the next container start — no rebuild step needed (bun runs TS directly). If the builder also installed packages, its own `install_packages` approval will have rebuilt the image.
|
||||
5. You review the builder's summary, confirm with the user, then call `request_rebuild` if the changes require it
|
||||
|
||||
### Builder Agent Instructions (use as CLAUDE.md when creating)
|
||||
|
||||
@@ -64,11 +64,12 @@ The limits are **per builder task**, not per session. A 500-line feature is fine
|
||||
User: "Can you add a tool for reading RSS feeds?"
|
||||
|
||||
1. Check [mcp.so](https://mcp.so) for an existing RSS MCP server
|
||||
2. If one exists → `add_mcp_server({ name: "rss", command: "npx", args: ["some-rss-mcp"] })` → admin approves → container restarts with the new server → done
|
||||
2. If one exists → `add_mcp_server({ name: "rss", command: "npx", args: ["some-rss-mcp"] })` → `request_rebuild` → done
|
||||
3. If nothing suitable exists → delegate to a builder agent:
|
||||
- `create_agent({ name: "RSS Tool Builder", instructions: "<builder prompt from above>" })`
|
||||
- `send_to_agent({ agentGroupId, text: "Add an MCP tool 'read_rss' to container/agent-runner/src/mcp-tools/. It should fetch an RSS URL and return the latest N items. Register it in mcp-tools/index.ts. Target: <200 new lines." })`
|
||||
- Wait for builder's report — new tool code is picked up on the next container start (bun runs TS directly)
|
||||
- Wait for builder's report
|
||||
- `request_rebuild` if needed
|
||||
|
||||
## Example: Installing a System Tool
|
||||
|
||||
@@ -77,8 +78,10 @@ User: "Can you transcribe audio?"
|
||||
1. Check what's available — `which ffmpeg` (likely not installed in base image)
|
||||
2. Decide approach: `@xenova/transformers` (npm, workspace-local) or `whisper.cpp` (apt + compile)
|
||||
3. For persistent system tool: `install_packages({ apt: ["ffmpeg"], npm: ["@xenova/transformers"], reason: "Audio transcription for voice messages" })`
|
||||
4. Wait for admin approval — on approve, the image is rebuilt and your container is restarted automatically
|
||||
5. Test the new capability once the container restarts
|
||||
4. Wait for admin approval
|
||||
5. `request_rebuild({ reason: "Apply audio transcription packages" })`
|
||||
6. Wait for admin approval
|
||||
7. Test the new capability once the container restarts
|
||||
|
||||
## When NOT to Self-Customize
|
||||
|
||||
|
||||
@@ -3,83 +3,26 @@ name: welcome
|
||||
description: Introduce yourself to a newly connected channel. Triggered automatically when a channel is first wired. Send a friendly greeting and brief overview of what you can do.
|
||||
---
|
||||
|
||||
# /welcome — Channel Onboarding (Updated)
|
||||
# /welcome — Channel Onboarding
|
||||
|
||||
You've just been connected to a new user. This your time to shine and make a strong first impression. Introduce yourself and guide the user through what you can do. you got this!
|
||||
You've just been connected to a new messaging channel. Introduce yourself to the user.
|
||||
|
||||
## What to do
|
||||
|
||||
1. Send a short, warm greeting using `send_message`
|
||||
2. State your name (from your system prompt / CLAUDE.md)
|
||||
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
|
||||
4. Ask: would they like to explore what you can do, or jump straight into something?
|
||||
2. Mention your name (from your CLAUDE.md)
|
||||
3. Make it clear you can do a lot — but do NOT list your tools or skills upfront. Keep it open-ended and intriguing
|
||||
4. End by asking: would they like to explore what you can do, or jump straight into building/creating something?
|
||||
|
||||
**If they want to explore:** drip-feed one capability at a time. Briefly explain it, offer to demo a compelling example or let them try it. Never dump a full list.
|
||||
**If they want to explore:** show one skill or capability at a time. Briefly explain what it does, offer to demo it or let them try it, then ask if they want to see the next one or move on. Drip-feed — never dump a list.
|
||||
|
||||
**If they want to jump in:** just go.
|
||||
|
||||
---
|
||||
|
||||
## Capabilities to reveal (in order)
|
||||
|
||||
Reveal these one at a time, in this sequence. Each should be 2–4 sentences max.
|
||||
|
||||
### 1. Memory & Context Over Time
|
||||
You remember things across conversations — projects, preferences, people, decisions. Users don't have to re-explain context every session. The more they work with you, the more situationally aware you become.
|
||||
|
||||
### 2. Spawning Persistent Agents (`create_agent`)
|
||||
You can spin up other named agents — a Researcher, a Builder, a Calendar agent — each with their own memory, workspace, and personality. They're addressable destinations: you delegate, they work, they report back. These aren't one-shot tasks; they accumulate context across sessions.
|
||||
|
||||
### 3. Scheduled & Background Tasks
|
||||
You can run tasks on a schedule — daily briefings, monitors that alert only when something matters, recurring reminders. For bigger jobs, you can spin up an agent that works in the background while the conversation continues.
|
||||
|
||||
### 4. Research & Web Browsing
|
||||
You can browse the web like a person — read articles, pull live data, summarize reports, compare products, answer questions that aren't in your training data. Ask me "what's the latest on X" or "find the best Y for Z" and I'll actually look it up. Very powerful when combined with scheduled tasks.
|
||||
|
||||
### 5. Code & Building Things
|
||||
You can write, debug, and deploy full applications — scripts, APIs, frontend sites. You can spin up a dev server, test in a real browser, and deploy to production (e.g. Vercel). Concept to live URL.
|
||||
|
||||
### 6. Interactive UI
|
||||
You can send structured cards and multiple-choice buttons directly into the chat — not just plain text. Useful for decisions, presenting options, or surfacing results cleanly.
|
||||
|
||||
### 7. Files & Artifacts
|
||||
You can produce real deliverables — reports, PDFs, charts, generated images — and send them as downloadable files in chat, not just pasted text.
|
||||
|
||||
### 8. Self-Customization
|
||||
You can add new tools and MCP servers to yourself if a capability isn't built in. You can extend your own toolkit when the task requires it.
|
||||
|
||||
---
|
||||
|
||||
## Trust & Control — always include these
|
||||
|
||||
After the capabilities tour (or woven in naturally), cover these two points. Frame them positively — users stay in control.
|
||||
|
||||
### Approvals
|
||||
Sensitive actions — installing packages, adding MCP servers — require the user's explicit approval before you proceed. They'll get a prompt; nothing happens automatically. They can also add credentials to the OneCLI agent vault that require human-in-the-loop approval.
|
||||
|
||||
### Access Control
|
||||
The user owns who can talk to you. Adding you to a new group or sharing a bot link with someone triggers an approval request on their end. Nobody interacts with you without their say-so.
|
||||
|
||||
---
|
||||
|
||||
## How to interact — always mention this
|
||||
|
||||
There are no special commands. Users just talk naturally. If they want something done, they say so. That's it.
|
||||
|
||||
---
|
||||
|
||||
## Wrapping up
|
||||
|
||||
After the tour, finish with an open invitation. Ask if they want help with something specific. Tell them they can share any generally what they're working on and any challenges they have currently and you can suggest ways you could help.
|
||||
|
||||
---
|
||||
**If they want to jump in:** just go. Help them with whatever they ask.
|
||||
|
||||
## Tone
|
||||
|
||||
Warm, confident, inviting. Make the user feel like they just unlocked something powerful. Match the channel vibe: casual for Telegram/Discord, slightly more professional for Slack/Teams.
|
||||
Warm, confident, and inviting. Make the user feel like they just unlocked something powerful. Match the channel's vibe (casual for Telegram/Discord, slightly more professional for Slack/Teams/email).
|
||||
|
||||
## Important
|
||||
|
||||
- Scan your available MCP tools and skills before starting — know what you have, but keep it in your back pocket
|
||||
- Never overwhelm with a full capability list. Discovery should feel like unwrapping, not reading a manual
|
||||
- Confirmations and corrections from the user during onboarding are feedback — save them to memory for future sessions
|
||||
- Scan your available MCP tools and skills so you know what you have — but keep that knowledge in your back pocket. Reveal capabilities naturally, one at a time, only when relevant or when the user asks to explore.
|
||||
- Never overwhelm with a full list. Discovery should feel like unwrapping, not reading a manual.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
|
||||
### 4. Kubernetes image garbage collection deletes nanoclaw-agent image
|
||||
|
||||
**Symptoms**: `Container exited with code 125: pull access denied for nanoclaw-agent` — the container image disappears overnight or after a few hours, even though you just built it.
|
||||
|
||||
**Cause**: If your container runtime has Kubernetes enabled (Rancher Desktop enables it by default), the kubelet runs image garbage collection when disk usage exceeds 85%. NanoClaw containers are ephemeral (run and exit), so `nanoclaw-agent:latest` is never protected by a running container. The kubelet sees it as unused and deletes it — often overnight when no messages are being processed. Other images (docker-compose services) survive because they have long-running containers referencing them.
|
||||
|
||||
**Fix**: Disable Kubernetes if you don't need it:
|
||||
```bash
|
||||
# Rancher Desktop
|
||||
rdctl set --kubernetes-enabled=false
|
||||
|
||||
# Then rebuild the container image
|
||||
./container/build.sh
|
||||
```
|
||||
|
||||
**Diagnosis**: Check the k3s log for image GC activity:
|
||||
```bash
|
||||
grep -i "nanoclaw" ~/Library/Logs/rancher-desktop/k3s.log
|
||||
# Look for: "Removing image to free bytes" with the nanoclaw-agent image ID
|
||||
```
|
||||
|
||||
Check NanoClaw logs for image status:
|
||||
```bash
|
||||
grep -E "image found|image NOT found|image missing" logs/nanoclaw.log
|
||||
```
|
||||
|
||||
If you need Kubernetes enabled, set `CONTAINER_IMAGE` to an image stored in a registry that the kubelet won't GC, or raise the GC thresholds.
|
||||
|
||||
## 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?
|
||||
docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
|
||||
|
||||
# 3. Any stopped/orphaned containers?
|
||||
docker ps -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. Are channels connected? (look for last connection event)
|
||||
grep -E 'Connected|Connection closed|connection.*close|channel.*ready' 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 channels
|
||||
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
|
||||
docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
|
||||
```
|
||||
|
||||
## Channel 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
|
||||
pnpm 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
|
||||
pnpm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
```
|
||||
@@ -10,5 +10,6 @@ The files in this directory are original design documents and developer referenc
|
||||
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
|
||||
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
|
||||
| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) |
|
||||
| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) |
|
||||
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
|
||||
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |
|
||||
|
||||
@@ -332,6 +332,8 @@ Configuration constants are in `src/config.ts`:
|
||||
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();
|
||||
@@ -342,6 +344,7 @@ 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);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ flowchart TB
|
||||
direction TB
|
||||
PollLoop["Poll Loop<br/>(container/agent-runner)"]
|
||||
Provider["Agent providers<br/>(claude, opencode, mock; todo: codex)"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server"]
|
||||
MCP["MCP Tools<br/>send_message, send_file, edit_message,<br/>add_reaction, send_card, ask_user_question,<br/>schedule_task, create_agent,<br/>install_packages, add_mcp_server, request_rebuild"]
|
||||
Skills["Container Skills<br/>(container/skills/)"]
|
||||
InDB[("inbound.db<br/>host writes<br/>even seq<br/>messages_in<br/>destinations<br/>processing_ack")]
|
||||
OutDB[("outbound.db<br/>container writes<br/>odd seq<br/>messages_out<br/>heartbeat file")]
|
||||
|
||||
@@ -876,7 +876,7 @@ Messages starting with `/` are checked against three lists:
|
||||
- Commands that don't make sense in the NanoClaw context or could cause issues
|
||||
- Silently dropped — no error, no forwarding
|
||||
|
||||
The command lists are hardcoded in the agent-runner. Admin verification happens host-side before the message ever reaches the container: `src/command-gate.ts` queries `user_roles` (owner / global admin / scoped-admin-of-this-agent-group) and either passes the message through, drops it, or routes it elsewhere. The container has no notion of admin identity — no env var, no DB query, no per-message check.
|
||||
The command lists are hardcoded in the agent-runner. Admin verification: the host passes `NANOCLAW_ADMIN_USER_IDS` (a comma-separated list of owner + global-admin + scoped-admin user ids for the current agent group, see `src/container-runner.ts`) to the container. The agent-runner membership-tests the inbound `senderId` against that set before forwarding admin commands.
|
||||
|
||||
### Recurring Tasks
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# NanoClaw Checklist
|
||||
|
||||
Status: [x] done, [~] partial, [ ] not started
|
||||
|
||||
---
|
||||
|
||||
## Core Architecture
|
||||
|
||||
- [x] Session DB replaces IPC (messages_in / messages_out as sole IO)
|
||||
- [x] Central DB (agent groups, messaging groups, sessions, routing)
|
||||
- [x] Host sweep (stale detection via heartbeat file, retry with backoff, recurrence scheduling)
|
||||
- [x] Active delivery polling (1s for running sessions)
|
||||
- [x] Sweep delivery polling (60s across all sessions)
|
||||
- [x] Container runner with session DB mounting
|
||||
- [x] Per-session container lifecycle and idle timeout
|
||||
- [ ] Replace hard Idle and Timeout with work aware prompts to user to kill stuck processes
|
||||
- [x] Session resume (sessionId + resumeAt across queries)
|
||||
- [x] Graceful shutdown (SIGTERM/SIGINT handlers)
|
||||
- [x] Orphan container cleanup on startup
|
||||
|
||||
## Agent Runner (Container)
|
||||
|
||||
- [x] Poll loop (pending messages, status transitions, idle detection)
|
||||
- [x] Concurrent follow-up polling while agent is thinking
|
||||
- [x] Message formatter (chat, task, webhook, system kinds)
|
||||
- [x] Command categorization (admin, filtered, passthrough)
|
||||
- [x] Transcript archiving (pre-compact hook)
|
||||
- [x] XML message formatting with sender, timestamp
|
||||
- [~] Media handling inbound (native files support for claude)
|
||||
|
||||
## Agent Providers
|
||||
|
||||
- [x] Claude provider (Agent SDK, tool allowlist, message stream, session resume)
|
||||
- [x] Mock provider (testing)
|
||||
- [x] Provider factory
|
||||
- [ ] Codex provider
|
||||
- [x] OpenCode provider
|
||||
|
||||
## Channel Adapters
|
||||
|
||||
- [x] Channel adapter interface (setup, deliver, teardown, typing)
|
||||
- [x] Chat SDK bridge (generic, works with any Chat SDK adapter)
|
||||
- [x] Chat SDK SQLite state adapter (KV, subscriptions, locks, lists)
|
||||
- [x] Discord via Chat SDK
|
||||
- [~] Slack via Chat SDK (adapter + skill written, not tested)
|
||||
- [x] Telegram via Chat SDK (E2E verified: inbound, routing, typing, delivery)
|
||||
- [~] Microsoft Teams via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] Google Chat via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] Linear via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] GitHub via Chat SDK (adapter + skill written, not tested)
|
||||
- [x] WhatsApp Cloud API via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] Resend (email) via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] Matrix via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] Webex via Chat SDK (adapter + skill written, not tested)
|
||||
- [~] iMessage via Chat SDK (adapter + skill written, not tested)
|
||||
- [x] Backward compatibility with native channels (old adapters still work)
|
||||
- [x] Channel barrel wired (src/index.ts imports barrel, skills uncomment)
|
||||
- [x] Setup flow wired to channels (channel skills + /manage-channels for registration + verify.ts checks all tokens)
|
||||
- [x] Channel Info metadata in each channel skill (type, terminology, how-to-find-id, isolation defaults)
|
||||
- [x] /manage-channels skill (wire channels to agent groups with three isolation levels)
|
||||
- [x] /init-first-agent skill (standalone first-agent bootstrap; walks the operator through channel pick → identity lookup → DM platform_id resolution → wire → welcome DM; fallback to telegram pair-code or "DM the bot first" lookup for channels without cold DM)
|
||||
- [x] Cold-DM infrastructure — `ChannelAdapter.openDM?(handle)` optional method, resolved via Chat SDK `chat.openDM` for resolution-required channels (Discord, Slack, Teams, Webex, gChat) and fall-through to the handle directly for direct-addressable channels (Telegram, WhatsApp, iMessage, Matrix, Resend). `src/user-dm.ts::ensureUserDm` caches every resolution in `user_dms` so subsequent cold DMs are a DB read.
|
||||
- [x] Agent-shared session mode (cross-channel shared sessions, e.g. GitHub + Slack)
|
||||
- [x] Auto-onboarding on channel registration (/welcome skill triggered on first wiring)
|
||||
- [ ] Wire different chat modes - mentions, whitelist, approve, etc
|
||||
|
||||
## Chat-First Setup Flow
|
||||
|
||||
**Goal:** get the user out of Claude Code and into their messaging app as quickly as possible, then enable every part of customization, configuration, and setup from inside the chat app. Claude Code is the bootstrap, not the home.
|
||||
|
||||
- [~] Minimum-viable bootstrap in Claude Code: install deps, pick one channel, authenticate it, wire it to a default agent group, hand off — nothing else required before the user can leave Claude Code. `/setup` handles deps/auth, `/init-first-agent` handles the first-agent wiring + welcome DM. Still TODO: single top-level entrypoint that composes both, and a true "nothing else required" handoff (today `/setup` still runs through `/manage-channels` for additional channels).
|
||||
- [~] Post-handoff welcome message in the chat app guides the user through remaining setup (channels, skills, integrations, memory, scheduling, etc.) — `/init-first-agent` stages a `kind:'chat'` / `sender:'system'` welcome prompt that the agent DMs back to the operator via the normal delivery path. Current prompt just introduces the agent; TODO: expand the prompt (or follow-up flow) to walk through remaining setup tasks from within the chat.
|
||||
- [ ] Add more channels from chat (currently requires returning to Claude Code to run `/add-*` skills)
|
||||
- [ ] Self-register agent into a new chat room from chat: user gives the agent a channel/group name + approval, and the agent joins via the underlying adapter (e.g. Baileys for WhatsApp), wires the room to an agent group, and posts a first "hi, I'm here" message — no manual invite, no `/add-*` skill, no terminal
|
||||
- [ ] Authenticate channels from chat (OAuth/token entry via cards, no terminal required)
|
||||
- [ ] Add credentials / secrets to the OneCLI vault from chat via rich card (agent collects API keys, OAuth tokens, and other secrets through a card flow and writes them into the vault — no `.env` editing, no terminal)
|
||||
- [ ] Wire channels to agent groups from chat (today lives in `/manage-channels` Claude Code skill — port to in-chat flow with isolation-level question cards)
|
||||
- [ ] Create new agent groups from chat (`create_agent` exists — expose via user-facing flow, not just agent-called tool)
|
||||
- [ ] Edit agent group CLAUDE.md / instructions from chat
|
||||
- [ ] Install / uninstall / configure skills from chat (see Skills & Marketplace section)
|
||||
- [ ] Install / configure MCP servers from chat (see Skills & Marketplace section)
|
||||
- [ ] Install packages from chat (today agent can request install_packages — expose a direct user-facing "install X" flow)
|
||||
- [ ] Manage scheduled tasks from chat (list, pause, cancel, edit recurrence)
|
||||
- [ ] Manage destinations from chat (list, rename, revoke)
|
||||
- [ ] Manage permissions from chat (admin list, role assignment, approval policies)
|
||||
- [ ] Trigger /setup, /debug, /customize, /migrate-nanoclaw from chat (today all require Claude Code)
|
||||
- [ ] View and edit memory from chat
|
||||
- [ ] Visualize current setup from chat (ties into Container Skills: installation diagram)
|
||||
- [ ] Export / share setup from chat (ties into Container Skills: end-of-setup diagram + share)
|
||||
- [ ] Fallback to Claude Code only when a change requires a code edit the agent can't self-apply (and even then, agent should offer to open Claude Code on the user's behalf)
|
||||
|
||||
## Product Focus
|
||||
|
||||
**North star:** prioritize skills, flows, and custom setups. Platform work (channels, routing, session DBs, approval flows, MCP tools) is plumbing — it should reach a "boring and reliable" state and then stop absorbing attention. The interesting surface area is what users can *build on top* of that plumbing: skills that add capabilities, conversational flows that orchestrate those skills, and custom per-user setups that compose channels/agents/skills/memory into something personal.
|
||||
|
||||
- [ ] Every new feature request should be answered first with "is this a skill?" before being answered with "is this a platform change?"
|
||||
- [ ] Skills should be the primary extension mechanism users and agents reach for — adding, removing, browsing, editing, debugging
|
||||
- [ ] Flows (multi-step interactive sequences: setup, onboarding, migration, customize, debug) should be authorable as skills rather than hardcoded into the platform
|
||||
- [ ] Custom setups (diverging from defaults: multiple agents, cross-channel routing, per-group memory, specialist sub-agents) should be composable from existing primitives without touching core platform code
|
||||
- [ ] Platform-level work gets budgeted against the question: "does this unblock a class of skills/flows/setups that's otherwise impossible?"
|
||||
|
||||
## Routing
|
||||
|
||||
- [x] Inbound routing (platform ID + thread ID -> agent group -> session)
|
||||
- [x] Auto-create messaging group on first message
|
||||
- [x] Session resolution (shared vs per-thread modes)
|
||||
- [x] Message writing to session DB with seq numbering
|
||||
- [x] Container waking on new message
|
||||
- [x] Typing indicator triggered on message route
|
||||
- [~] Trigger rule matching (router picks highest-priority agent, regex/mention matching TODO)
|
||||
|
||||
## Rich Messaging
|
||||
|
||||
- [x] Interactive cards with buttons (ask_user_question)
|
||||
- [x] Native platform rendering (Discord embeds, buttons)
|
||||
- [x] Message editing
|
||||
- [x] Emoji reactions
|
||||
- [x] File sending from agent (outbox -> delivery)
|
||||
- [x] File upload delivery (buffer-based via adapter)
|
||||
- [x] Markdown formatting
|
||||
- [~] Formatted /usage, /context, /cost output (commands pass through, no rich card formatting)
|
||||
- [ ] Context window visibility: show position in context, approaching compaction, when compaction happens, post-compaction state
|
||||
- [ ] Threading and replies support
|
||||
- [ ] Auto-compact on idle before cache expires
|
||||
|
||||
## MCP Tools (Container)
|
||||
|
||||
- [x] send_message (routes via named destinations; `to` field resolved against agent's local map)
|
||||
- [x] send_file (copy to outbox, write messages_out)
|
||||
- [x] edit_message (routed via destinations)
|
||||
- [x] add_reaction (routed via destinations)
|
||||
- [x] send_card
|
||||
- [x] ask_user_question (blocking poll for response)
|
||||
- [x] schedule_task (with process_after and recurrence)
|
||||
- [x] list_tasks
|
||||
- [x] cancel_task / pause_task / resume_task
|
||||
- [x] create_agent (any agent, creates agent group + folder + bidirectional destinations; host re-normalizes the name, deduplicates folder, path-traversal guarded)
|
||||
- [x] install_packages (apt/npm, owner/admin approval required via `pickApprover`, strict name validation)
|
||||
- [x] add_mcp_server (owner/admin approval required via `pickApprover`)
|
||||
- [x] request_rebuild (rebuilds per-agent-group Docker image)
|
||||
|
||||
## Scheduling
|
||||
|
||||
- [x] One-shot scheduled messages (process_after / deliver_after)
|
||||
- [x] Recurring tasks via cron expressions
|
||||
- [x] Host sweep picks up due messages and advances recurrence
|
||||
- [x] Scheduled outbound messages (no container wake needed)
|
||||
- [ ] Pre-agent scripts (formatter references scriptOutput but no execution logic)
|
||||
|
||||
## Permissions and Approval Flows
|
||||
|
||||
- [x] User-level privilege model — `users` + `user_roles` (owner / admin, global or scoped to an agent group). Replaces the old `agent_groups.is_admin` / `messaging_groups.admin_user_id` coupling. See `src/db/users.ts`, `src/db/user-roles.ts`, `src/access.ts`.
|
||||
- [x] Admin-only command filtering in container — host passes `NANOCLAW_ADMIN_USER_IDS` (owners + global admins + scoped admins for the agent group) to the agent-runner; `poll-loop.ts` gates slash commands against that set.
|
||||
- [x] Approval routing — `pickApprover` (scoped admin → global admin → owner, dedup) + `pickApprovalDelivery` (first reachable, same-channel-kind tie-break); delivery lands in the approver's DM via `ensureUserDm` / `user_dms` cache. See `src/access.ts`, `src/onecli-approvals.ts`.
|
||||
- [x] Per-messaging-group unknown-sender gating — `messaging_groups.unknown_sender_policy` (`strict` | `request_approval` | `public`), enforced in `src/router.ts`.
|
||||
- [x] Approval flow (sensitive action -> card to admin -> approve/reject -> execute) — `pending_approvals` table, `requestApproval()` helper, reuses interactive card infra
|
||||
- [x] Agent requests dependency/package install (install_packages, admin approval, rebuild on approval)
|
||||
- [x] Self-modification — direct tools:
|
||||
- [x] install_packages (apt/npm, admin approval, name validation both sides, max 20 per request)
|
||||
- [x] add_mcp_server (admin approval)
|
||||
- [x] request_rebuild (builds per-agent-group Docker image with approved packages)
|
||||
- [x] Fire-and-forget model (write request, return immediately; chat notification on approval; container killed so next wake picks up new config/image)
|
||||
- [~] OneCLI integration for human-loop approvals on credentialed requests (agent touching a credentialed resource → OneCLI gates → approval card to admin → OneCLI releases credential) — SDK 0.3.1 `configureManualApproval` wired into host, routes to admin via existing `pending_approvals` infra
|
||||
- [ ] Tunneled OneCLI dashboard for credential addition (Telegram Mini Apps aside, iMessage without Apple Business Register, Matrix, email). Signed short-lived URL → browser form served by OneCLI at 10254 → tunnel via cloudflare durable object. Value never touches the chat surface.
|
||||
- [ ] Self-modification via direct source edits — planned draft/activate flow: RO baseline mount at `/app/src`, RW draft at `/workspace/src-draft`, atomic snapshot into `pending`, admin approval, `cp -a` into baseline, restart + deadman rollback. Unifies runner src, host src, migrations, package.json, container config through one edit path. Collapses the abandoned `create_dev_agent`/`request_swap` dev-agent-in-worktree approach.
|
||||
|
||||
## Named Destinations + ACL
|
||||
|
||||
- [x] `agent_destinations` table (agent_group_id, local_name, target_type, target_id) — migration 004
|
||||
- [x] Per-agent local-name routing map (channels and peer agents referenced by local names)
|
||||
- [x] Destinations stored in inbound.db `destinations` table (moved from JSON file in `b591d7c`) — single source of truth, no separate file
|
||||
- [x] Host writes the destination map into inbound.db before every container wake; container queries it live on every lookup so admin changes take effect mid-session
|
||||
- [x] Container loads map at startup, appends system-prompt addendum listing destinations + `<message to="name">` syntax
|
||||
- [x] Agent main output parsed for `<message to="...">` blocks; `<internal>...</internal>` treated as scratchpad
|
||||
- [x] Host re-validates every outbound route via `hasDestination()` — unauthorized drops logged
|
||||
- [x] Inbound formatter adds `from="name"` via reverse-lookup (consistent namespace both directions)
|
||||
- [x] Single-destination shortcut — agents with one destination don't need `<message>` wrapping
|
||||
- [x] Backfill from existing `messaging_group_agents` on migration
|
||||
- [x] Removed `NANOCLAW_PLATFORM_ID` / `CHANNEL_TYPE` / `THREAD_ID` env-var routing entirely
|
||||
|
||||
## Agent-to-Agent Communication
|
||||
|
||||
- [x] Host delivery to target agent's session DB (`channel_type='agent'` routing in `src/delivery.ts`)
|
||||
- [x] Agent spawning a new sub-agent (`create_agent` MCP tool, available to any agent, path-traversal guarded)
|
||||
- [x] Dynamic agent group creation (folder + optional CLAUDE.md at runtime)
|
||||
- [x] Internal-only agents (agents created without a channel attached)
|
||||
- [x] Permission delegation from parent to child (bidirectional destination rows inserted at creation)
|
||||
- [x] Bidirectional routing via inherited routing context; sender info enriched on the target side
|
||||
- [ ] Specialist sub-agents (browser agent, dev agent — user's agent delegates with request/approval)
|
||||
- [ ] Browser agent with per-destination permissions between main agent and browser agent (main requests navigation/interaction; browser agent executes in isolated container)
|
||||
- [ ] Sanitization of browser agent responses before handing back to main agent (strip scripts, inline images, untrusted HTML; prevent prompt injection from web content)
|
||||
- [ ] Same permission + sanitization model for any sub-agent that accesses sensitive data sources (files, DBs, third-party APIs)
|
||||
|
||||
## In-Chat Agent Management
|
||||
|
||||
- [x] /clear (resets session)
|
||||
- [x] /compact (triggers context compaction)
|
||||
- [~] /context (passes through, no rich formatting)
|
||||
- [~] /usage (passes through, no rich formatting)
|
||||
- [~] /cost (passes through, no rich formatting)
|
||||
- [ ] Smooth session transitions: load context into new sessions, solve cold start problem
|
||||
- [x] MCP/package installation from chat
|
||||
- [ ] Browse MCP marketplace / skills repository from chat
|
||||
|
||||
## Skills & Marketplace
|
||||
|
||||
- [ ] Install skills from chat (agent requests, admin approves, skill dropped into container skills dir)
|
||||
- [ ] Scan skills before install (lint SKILL.md, sandbox-check shell commands, require approval for network/FS-heavy skills)
|
||||
- [ ] Scan marketplace npm packages before install (supply-chain check, typo-squat detection, known-bad list)
|
||||
- [ ] MCP server marketplace — discover, preview, install
|
||||
- [ ] Browse skills / MCP marketplace from chat (cards with search, preview, install)
|
||||
- [ ] Local voice transcription skill — "just works" install flow: when the user sends a voice message and no transcription backend is installed, the agent asks once ("Install local voice transcription?"), and on approval the skill installs a fully-local speech-to-text model (no cloud calls). Subsequent voice messages transcribe automatically.
|
||||
- [ ] Fully local NanoClaw — OpenCode + Gemma 4 as the agent provider instead of Claude Code, so an entire install can run with zero cloud inference. Requires wiring OpenCode as an agent provider (see Agent Providers) and a setup path that picks local models, pulls weights, and verifies everything runs offline.
|
||||
|
||||
## Container Skills
|
||||
|
||||
Container skills live inside agent containers at runtime (`container/skills/`) and are loaded into every agent session. These are distinct from feature/operational skills that ship with the host.
|
||||
|
||||
- [ ] Customize container skill — agent-driven customization flow (add channel, integration, behavior change) usable from inside any agent session, not just the main repo
|
||||
- [ ] Debug container skill — inspect logs, session DB, MCP server state, container env, recent errors from inside the agent
|
||||
- [ ] Build-system container skills:
|
||||
- [ ] Karpathy LLM Wiki builder (agent scaffolds a persistent wiki knowledge base for a group)
|
||||
- [ ] Generic build-system framework for agent-authored sub-systems
|
||||
- [ ] NanoClaw installation diagram skill — agent generates a visual diagram of the user's current setup (agent groups, channels, wirings, destinations, sub-agents, installed packages/MCP servers)
|
||||
- [ ] Video replay skill — generate Remotion (or similar) videos that replay chat flows and sessions, referencing good UI patterns to produce shareable clips
|
||||
- [ ] Excitement trigger skill — detects when the user expresses excitement about the agent's capabilities or their setup, and proactively encourages generating a diagram + sharing it
|
||||
- [ ] End-of-migration diagram skill — at the end of `/migrate-nanoclaw` (or any migration flow), agent generates a visual diagram of the resulting setup and suggests sharing
|
||||
- [ ] End-of-setup diagram skill — at the end of first-time `/setup`, agent generates a visual diagram and suggests sharing (merges the old "Generate visual diagram of customized instance at end of setup" line from Channel Adapters)
|
||||
|
||||
## Webhook Ingestion
|
||||
|
||||
- [ ] Generic webhook endpoint for external events
|
||||
- [ ] GitHub webhook handling
|
||||
- [ ] CI/CD notification handling
|
||||
- [ ] Webhook -> messages_in routing
|
||||
|
||||
## System Actions
|
||||
|
||||
- [ ] register_group from inside agent
|
||||
- [ ] reset_session from inside agent
|
||||
- [ ] Delivery failures should round-trip back to the agent as system messages so it can decide how to recover (retry as plain text, simplify, give up), with a hard retry cap + poison-pill backstop in delivery.ts to keep the queue healthy
|
||||
|
||||
## Integrations
|
||||
|
||||
- [x] Vercel CLI integration in setup process
|
||||
- [x] Skills for deploying and managing Vercel websites from chat
|
||||
- [ ] Office 365 integration (create/edit documents with inline suggestions)
|
||||
|
||||
## Memory
|
||||
|
||||
- [ ] Shared memory with approval flow (write to global memory requires admin approval)
|
||||
- [ ] Agent memory system skills — skills for building and managing memory systems for an agent: archive/index large collections of files and data, then expose a memory interface the agent can query and update (e.g. QMD-style systems)
|
||||
|
||||
## Migration
|
||||
|
||||
- [ ] Custom skill/code porting
|
||||
- [ ] OneCLI migration check — determine if existing installs need OneCLI re-init (credentials re-scoped to new `agent_group.id` identifier, new SDK version, approval handler registered). If needed, add a migration step to `/update-nanoclaw` or a dedicated skill.
|
||||
|
||||
## Testing
|
||||
|
||||
- [x] DB layer tests (agent groups, messaging groups, sessions, pending questions)
|
||||
- [x] Channel registry tests
|
||||
- [x] Poll loop / formatter tests
|
||||
- [x] Integration test (container agent-runner)
|
||||
- [x] Host core tests
|
||||
- [ ] End-to-end flow tests (message in -> agent -> message out -> delivery)
|
||||
- [ ] Delivery polling tests
|
||||
- [ ] Host sweep tests (stale detection, recurrence)
|
||||
- [ ] Multi-channel integration tests
|
||||
|
||||
## Rollout
|
||||
|
||||
- [ ] Internal testing across all channels
|
||||
- [ ] Migration skill built and tested
|
||||
- [ ] PR factory migrated as validation
|
||||
- [ ] Blog post / announcement
|
||||
- [ ] Video demos of key flows
|
||||
- [ ] Vercel coordination
|
||||
+1
-1
@@ -201,7 +201,7 @@ Access layer: `src/db/agent-destinations.ts`.
|
||||
|
||||
Two workflows share this table:
|
||||
|
||||
- **Session-bound MCP approvals** — `install_packages`, `add_mcp_server`. `session_id` is set.
|
||||
- **Session-bound MCP approvals** — `install_packages`, `request_rebuild`, `add_mcp_server`. `session_id` is set.
|
||||
- **OneCLI credential approvals** — `session_id` may be NULL; `agent_group_id` + `channel_type` + `platform_id` route the admin card.
|
||||
|
||||
```sql
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Module Contract
|
||||
|
||||
This doc is the authoritative reference for how core and modules connect. Everything downstream — extraction PRs, install skills, module authors — keys off these signatures and defaults. See [REFACTOR_PLAN.md](../REFACTOR_PLAN.md) for the broader plan; this doc is the narrow interface spec.
|
||||
|
||||
## Principles
|
||||
|
||||
- Core runs standalone (modulo default modules — see tiers below). The optional-module portion of the `src/modules/index.ts` barrel can be empty and NanoClaw still routes messages in and delivers responses out.
|
||||
- Optional modules are independent. No optional module imports from another optional module. Cross-module coordination goes through a core registry (delivery action, response handler, etc.).
|
||||
- Registries exist only when multiple modules plug into the same decision point. Single-consumer integrations use skill edits (`MODULE-HOOK` markers) or stay inline with `sqlite_master` guards.
|
||||
- Removing an optional module = delete files + remove barrel imports + revert any `MODULE-HOOK` content. Migration files stay (data is preserved). Removing a default module is more invasive: it requires editing the core files that import from it.
|
||||
|
||||
## Module taxonomy
|
||||
|
||||
Three categories. All three live under `src/modules/` (or equivalent adapter dirs) with the same folder layout; the distinction is about **shipping** and **who can depend on them**.
|
||||
|
||||
### 1. Default modules
|
||||
|
||||
Ship with `main` in `src/modules/`. Imported by the default `src/modules/index.ts` barrel from day one. They are not really core — they live under `src/modules/` specifically to signal "not really core, rippable if needed" — but they're always present on a `main` install. Core imports from them directly. No hook, no registry indirection for the exports themselves.
|
||||
|
||||
Current: `typing`, `mount-security`.
|
||||
|
||||
### 2. Optional modules
|
||||
|
||||
Live on the `modules` branch. Installed via `/add-<name>` skills that cherry-pick files. Register into core via one of the four registries (or `MODULE-HOOK` skill edits). Core and other optional modules must not statically import an optional module's code.
|
||||
|
||||
Current: `interactive`, `approvals`, `scheduling`, `permissions`. Pending: `agent-to-agent`.
|
||||
|
||||
### 3. Channel adapters
|
||||
|
||||
Live on the `channels` branch, installed via `/add-<channel>` skills. Not covered by this contract; they use the pre-existing `ChannelAdapter` interface and `registerChannelAdapter()`.
|
||||
|
||||
## Dependency rule
|
||||
|
||||
```
|
||||
core ← default modules ← optional modules
|
||||
```
|
||||
|
||||
- **Core** may import from core and from default modules.
|
||||
- **Default modules** may import from core and from other default modules. They must not import from optional modules.
|
||||
- **Optional modules** may import from core and from default modules. They must not import from each other.
|
||||
|
||||
Peer-to-peer coupling between optional modules goes through a core registry — see "The four registries" below. This keeps the module dependency graph a DAG and install order irrelevant.
|
||||
|
||||
### Known transitional violations
|
||||
|
||||
- `src/access.ts` (core) imports from `src/modules/permissions/` (optional). Shim left from PR #5; resolved in the planned approvals re-tier (PR #7) which moves approver-picking into a new default `approvals-primitive` module that may then depend on permissions however it likes — at which point `src/access.ts` ceases to exist.
|
||||
|
||||
## The four registries
|
||||
|
||||
Each registry has an explicit default for when no module registers. Core must run when all four are empty.
|
||||
|
||||
### 1. Delivery action handlers
|
||||
|
||||
```typescript
|
||||
// src/delivery.ts
|
||||
type ActionHandler = (
|
||||
content: Record<string, unknown>,
|
||||
session: Session,
|
||||
inDb: Database.Database,
|
||||
) => Promise<void>;
|
||||
|
||||
export function registerDeliveryAction(action: string, handler: ActionHandler): void;
|
||||
```
|
||||
|
||||
**Purpose:** system-kind outbound messages (`msg.kind === 'system'`) carry an `action` string. Core dispatches to the registered handler.
|
||||
|
||||
**Default when action is unknown:** log `"Unknown system action"` at `warn` and return. Message is still marked delivered (it was consumed by the host, not sent to a channel).
|
||||
|
||||
**Current consumers:** scheduling (5 actions — `schedule_task`, `cancel_task`, `pause_task`, `resume_task`, `update_task`), approvals (3 actions — `install_packages`, `request_rebuild`, `add_mcp_server`), agent-to-agent (`create_agent`, and the agent-routing branch keyed as a pseudo-action `agent_route`).
|
||||
|
||||
### 2. Router sender resolver + access gate
|
||||
|
||||
Two separate setters, called at different points in `routeInbound`. Preserves the pre-refactor ordering: sender-upsert side effects fire even when the message is ultimately dropped by wiring or trigger rules.
|
||||
|
||||
```typescript
|
||||
// src/router.ts
|
||||
type SenderResolverFn = (event: InboundEvent) => string | null;
|
||||
|
||||
export function setSenderResolver(fn: SenderResolverFn): void;
|
||||
|
||||
type AccessGateResult =
|
||||
| { allowed: true }
|
||||
| { allowed: false; reason: string };
|
||||
|
||||
type AccessGateFn = (
|
||||
event: InboundEvent,
|
||||
userId: string | null,
|
||||
mg: MessagingGroup,
|
||||
agentGroupId: string,
|
||||
) => AccessGateResult;
|
||||
|
||||
export function setAccessGate(fn: AccessGateFn): void;
|
||||
```
|
||||
|
||||
**Call order in `routeInbound`:**
|
||||
1. Resolve messaging group.
|
||||
2. **Sender resolver** (if set). Permissions upserts the users row here so the record exists even if agent resolution drops the message.
|
||||
3. Resolve wired agents; `no_agent_wired` → record + drop. (Core writes the dropped_messages row.)
|
||||
4. Pick agent by trigger rules; `no_trigger_match` → record + drop.
|
||||
5. **Access gate** (if set). On refusal it writes its own `dropped_messages` row keyed by policy reason.
|
||||
|
||||
**Defaults when unset:** resolver returns null; gate defaults to `{ allowed: true }`. Every message routes through, no users table is needed, downstream tolerates `userId=null`.
|
||||
|
||||
**Current consumer:** permissions module (registers both).
|
||||
|
||||
**Not registries, setters.** There is one sender and one access decision per inbound message and one module that owns both. Calling `setSenderResolver` / `setAccessGate` twice overwrites; core does not iterate.
|
||||
|
||||
### 3. Response dispatcher
|
||||
|
||||
```typescript
|
||||
// src/index.ts (or src/response-dispatch.ts if it grows)
|
||||
interface ResponsePayload {
|
||||
questionId: string;
|
||||
value: string;
|
||||
userId: string | null;
|
||||
channelType: string;
|
||||
platformId: string;
|
||||
threadId: string | null;
|
||||
}
|
||||
|
||||
type ResponseHandler = (payload: ResponsePayload) => Promise<boolean>;
|
||||
|
||||
export function registerResponseHandler(handler: ResponseHandler): void;
|
||||
```
|
||||
|
||||
**Purpose:** button-click / question responses arrive via the channel adapter's `onAction` callback. Core iterates registered handlers in registration order. The first one that returns `true` claims the response.
|
||||
|
||||
**Default when empty:** log `"Unclaimed response"` at `warn` and drop.
|
||||
|
||||
**Current consumers:** interactive (matches `pending_questions`), approvals (matches `pending_approvals`). The two tables have disjoint `question_id` / `approval_id` namespaces in practice (`q-*` vs `appr-*`), so first-match-wins is safe.
|
||||
|
||||
### 4. Container MCP tool self-registration
|
||||
|
||||
```typescript
|
||||
// container/agent-runner/src/mcp-tools/server.ts
|
||||
export function registerTools(tools: McpToolDefinition[]): void;
|
||||
```
|
||||
|
||||
**Purpose:** each tool module calls `registerTools([...])` at import time. The MCP server uses whatever was registered.
|
||||
|
||||
**Default:** only `mcp-tools/core.ts` (`send_message`) registered.
|
||||
|
||||
**Current consumers:** all container-side modules (scheduling, interactive, agents, self-mod).
|
||||
|
||||
## Skill edits to core
|
||||
|
||||
For one-off integrations with a single consumer, install skills edit core directly between `MODULE-HOOK` markers. No registry.
|
||||
|
||||
Marker format:
|
||||
|
||||
```typescript
|
||||
// MODULE-HOOK:<module>-<site>:start
|
||||
// MODULE-HOOK:<module>-<site>:end
|
||||
```
|
||||
|
||||
The skill inserts between markers on install and clears between them on uninstall. Markers live in core from day one (empty until a skill fills them).
|
||||
|
||||
**Current uses:**
|
||||
|
||||
- `src/host-sweep.ts` → `MODULE-HOOK:scheduling-recurrence` — call to scheduling module's `handleRecurrence`.
|
||||
- `container/agent-runner/src/poll-loop.ts` → `MODULE-HOOK:scheduling-pre-task` — call to scheduling module's `applyPreTaskScripts`.
|
||||
|
||||
**Promotion rule:** if a third consumer appears for any marker, promote to a registry.
|
||||
|
||||
## Guarded inline (core)
|
||||
|
||||
Some code stays in core but references module-owned tables. These use `sqlite_master` checks to degrade cleanly when the owning module isn't installed.
|
||||
|
||||
| Site | Owning module | Fallback |
|
||||
|------|---------------|----------|
|
||||
| `container-runner.ts` admin-ID query (`user_roles`, `agent_group_members`) | permissions | returns `[]` |
|
||||
| `container-runner.ts` `writeDestinations` (`agent_destinations`) | agent-to-agent | no-op |
|
||||
| `delivery.ts` channel-permission check (`agent_destinations`) | agent-to-agent | permit (origin-chat always OK) |
|
||||
| `delivery.ts` `createPendingQuestion` (`pending_questions`) | interactive | no-op (log warning) |
|
||||
|
||||
`container/agent-runner/src/formatter.ts` has a related non-DB fallback: when `NANOCLAW_ADMIN_USER_IDS` is empty, every sender is treated as admin (permissionless mode). This is the one-line change from the current deny-all behavior.
|
||||
|
||||
## Migrations
|
||||
|
||||
All migrations live in `src/db/migrations/` as TypeScript files exporting a `Migration` object:
|
||||
|
||||
```typescript
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
```
|
||||
|
||||
The barrel `src/db/migrations/index.ts` imports each and lists them in an ordered array.
|
||||
|
||||
**Uniqueness key is `name`, not `version`.** The migrator applies any migration whose `name` isn't in `schema_version`. Version stays as an ordering hint; integer collisions across modules are allowed.
|
||||
|
||||
**Module migration naming:**
|
||||
|
||||
- File: `src/db/migrations/module-<module>-<short>.ts`
|
||||
- `Migration.name`: `'<module>-<short>'` (e.g. `'approvals-pending-approvals'`)
|
||||
|
||||
**Uninstall behavior:** migration files and barrel entries stay. Tables persist across reinstalls. No down migrations.
|
||||
|
||||
## What a registry-based module provides
|
||||
|
||||
Each `src/modules/<name>/` module must supply:
|
||||
|
||||
- `index.ts` — imported by `src/modules/index.ts` for side-effect registration (calls `registerDeliveryAction` / `setInboundGate` / `registerResponseHandler` at module load time).
|
||||
- `project.md` — appended to project `CLAUDE.md` by the install skill. Describes module architecture for anyone reading the codebase.
|
||||
- `agent.md` — appended to `groups/global/CLAUDE.md` by the install skill. Describes the module's tools for the agent.
|
||||
- Migration file in `src/db/migrations/` if the module owns any tables.
|
||||
- Barrel entry in `src/db/migrations/index.ts` for that migration.
|
||||
|
||||
Optionally:
|
||||
|
||||
- Container-side additions to `container/agent-runner/src/mcp-tools/<name>.ts` that call `registerTools([...])`, with a barrel entry in `container/agent-runner/src/mcp-tools/index.ts`.
|
||||
- `MODULE-HOOK` edits to specific core files, applied by the install skill.
|
||||
|
||||
## What a module must not do
|
||||
|
||||
- Import from another module.
|
||||
- Write to core-owned tables (`sessions`, `agent_groups`, `messaging_groups`, `schema_version`, etc.) outside of migrations.
|
||||
- Depend on a specific channel adapter being installed.
|
||||
- Break core behavior when unloaded. If a module's absence leaves a core feature non-functional, that feature belongs in core, not the module.
|
||||
@@ -1,88 +0,0 @@
|
||||
# Running Agents on Local Ollama
|
||||
|
||||
NanoClaw agents can be routed to a local [Ollama](https://ollama.com) instance instead of the Anthropic API. This cuts API costs to zero and keeps all inference on your hardware.
|
||||
|
||||
## How It Works
|
||||
|
||||
Ollama exposes an Anthropic-compatible `/v1/messages` endpoint. The Claude Code CLI (which runs inside agent containers) uses the Anthropic SDK, which reads `ANTHROPIC_BASE_URL` to find the API host. Pointing that variable at Ollama is all that's needed — no new provider code, no changes to the agent runtime.
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Agent container │
|
||||
│ │
|
||||
│ Claude Code CLI │
|
||||
│ ↓ ANTHROPIC_BASE_URL │
|
||||
│ http://host.docker. │ ┌──────────────────┐
|
||||
│ internal:11434 ───────┼─────▶│ Ollama :11434 │
|
||||
│ │ │ gemma4:latest │
|
||||
└─────────────────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
`host.docker.internal` is Docker's magic hostname that resolves to the host machine from inside a container — so Ollama running on your Mac or Linux box is reachable at that address.
|
||||
|
||||
## The OneCLI Complication
|
||||
|
||||
NanoClaw normally runs API calls through an OneCLI HTTPS proxy that injects real credentials in place of a placeholder key. When redirecting to Ollama you need to bypass that proxy so requests go direct. Two env vars handle this:
|
||||
|
||||
- `NO_PROXY=host.docker.internal` — tells the Anthropic SDK's HTTP client to skip the proxy for that hostname
|
||||
- `no_proxy=host.docker.internal` — lowercase variant for tools that check the lowercase form
|
||||
|
||||
Both are set in the agent group's `container.json` alongside `ANTHROPIC_BASE_URL`.
|
||||
|
||||
## Network Isolation
|
||||
|
||||
Setting `ANTHROPIC_BASE_URL` redirects requests but doesn't prevent a misconfigured agent from accidentally reaching `api.anthropic.com` directly. The `blockedHosts` field in `container.json` adds a Docker `--add-host` flag that resolves the domain to `0.0.0.0`, making it physically unreachable from inside the container:
|
||||
|
||||
```json
|
||||
"blockedHosts": ["api.anthropic.com"]
|
||||
```
|
||||
|
||||
With this in place, even if the model setting drifts back to a Claude model name, the API call will fail immediately rather than silently billing your account.
|
||||
|
||||
## Model Selection
|
||||
|
||||
The Claude Code CLI reads its model from `~/.claude/settings.json` inside the container, which NanoClaw bind-mounts from `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`. Set `"model": "gemma4:latest"` (or whatever Ollama model you've pulled) there. Use the exact name from `ollama list`.
|
||||
|
||||
Model selection considerations for Apple Silicon:
|
||||
|
||||
| Model | Size | Quality | Speed (M4 Pro) |
|
||||
|-------|------|---------|----------------|
|
||||
| `gemma4:latest` | 12B | Good general-purpose | Fast |
|
||||
| `qwen3-coder:latest` | 32B | Excellent for coding tasks | Moderate |
|
||||
| `llama3.2:latest` | 3B | Basic | Very fast |
|
||||
|
||||
The agent uses tool calls extensively (read/write files, shell commands). Models that support tool use reliably work best. Gemma 4 and Qwen 3 Coder both handle structured tool calls well.
|
||||
|
||||
## What Changes at the Code Level
|
||||
|
||||
Three files need to support this feature. See `/add-ollama-provider` for the exact changes.
|
||||
|
||||
**`src/container-config.ts`** — `ContainerConfig` interface needs `env` and `blockedHosts` fields so the per-group JSON can carry them.
|
||||
|
||||
**`src/container-runner.ts`** — At container spawn time, `env` entries become `-e KEY=VAL` Docker flags (applied after OneCLI's injected vars so they win), and `blockedHosts` entries become `--add-host HOST:0.0.0.0` flags.
|
||||
|
||||
**`container/Dockerfile`** — The container runs as the host user's uid (e.g. 501 on macOS), not as the `node` user (uid 1000). The home directory must be `chmod 777` so any uid can write `~/.claude.json` and `~/.claude/settings.json`.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
| | Ollama (local) | Anthropic API |
|
||||
|---|---|---|
|
||||
| Cost | Free | Pay-per-token |
|
||||
| Privacy | Fully local | Data sent to Anthropic |
|
||||
| Model quality | Good (open-weight) | Excellent (Claude) |
|
||||
| Cold start | 5–30s (model load) | ~1s |
|
||||
| Context window | Varies by model | 200k tokens (Sonnet) |
|
||||
| Tool use reliability | Good (large models) | Excellent |
|
||||
| Hardware req. | 16GB+ RAM | None |
|
||||
|
||||
For personal automation on capable hardware, the tradeoff favors local. For complex multi-step tasks requiring large context or high reliability, Claude is still ahead.
|
||||
|
||||
## Reverting to Claude
|
||||
|
||||
Remove the `env` and `blockedHosts` keys from `groups/<folder>/container.json`, remove `"model"` from the shared settings file, and restart the service. No rebuild needed.
|
||||
|
||||
## See Also
|
||||
|
||||
- `/add-ollama-provider` — step-by-step skill to configure any agent group for Ollama
|
||||
- [Ollama Anthropic compatibility docs](https://ollama.com/blog/openai-compatibility) — upstream docs on the API bridge
|
||||
- `docs/architecture.md` — how the container spawn and env injection pipeline works
|
||||
@@ -1,226 +0,0 @@
|
||||
# Setup flow
|
||||
|
||||
This document is the contract for NanoClaw's end-to-end scripted setup
|
||||
(`bash nanoclaw.sh` → `pnpm run setup:auto`). Read it before adding a new
|
||||
step, fixing a regression, or changing how output is rendered.
|
||||
|
||||
## The three output levels
|
||||
|
||||
Every setup step produces output at **three distinct levels**. They have
|
||||
different audiences, go to different places, and are formatted differently.
|
||||
Don't conflate them.
|
||||
|
||||
| Level | Audience | Destination | Format |
|
||||
|---|---|---|---|
|
||||
| 1. User-facing | The operator running setup | Terminal (via clack) | Branded, concise, informational — "product content" |
|
||||
| 2. Progression | Future debuggers, AI agents reviewing a failed run, release support | `logs/setup.log` (one file, append-only) | Structured per-step blocks, linear chronology, human + machine readable |
|
||||
| 3. Raw | Whoever is deep-debugging a specific step | `logs/setup-steps/NN-step-name.log` (one file per step) | Full raw child stdout + stderr, verbatim |
|
||||
|
||||
Think of it as: the user sees a **summary**, the progression log is an
|
||||
**index with key facts**, the raw logs are the **evidence**.
|
||||
|
||||
### Level 1: user-facing (clack)
|
||||
|
||||
Rendered by `setup/auto.ts` via `@clack/prompts`. This is our *product
|
||||
surface* for setup — every line should read as if we designed it for a
|
||||
stranger on day one.
|
||||
|
||||
- Clack spinners for in-progress work. Show elapsed time.
|
||||
- `p.log.success` / `p.log.step` / `p.log.warn` for permanent status
|
||||
markers.
|
||||
- `p.note` for multi-line information (pairing code, next steps).
|
||||
- `p.text` / `p.select` / `p.password` for prompts.
|
||||
- Brand palette: `brand()` / `brandBold()` / `brandChip()` helpers in
|
||||
`setup/auto.ts`. Truecolor when the terminal supports it, 16-color
|
||||
cyan fallback otherwise, plain text when piped / `NO_COLOR`.
|
||||
|
||||
Rules:
|
||||
- **No discontinuity.** Every sub-step belongs to the same visual flow.
|
||||
The only exception is Anthropic credential registration (see below).
|
||||
- **No raw child output.** Never `stdio: 'inherit'` a child whose output
|
||||
wasn't written by us. Capture it and show it on failure only.
|
||||
- **No debug-style prefixes** (`[add-telegram] …`, `INFO …`, timestamps).
|
||||
Those belong in levels 2 and 3.
|
||||
- **No emoji** unless the clack glyph requires it.
|
||||
|
||||
### Level 2: progression log
|
||||
|
||||
`logs/setup.log` — one file per setup run, append-only, cumulative across
|
||||
a multi-run install (if a run fails midway and is re-attempted, the new
|
||||
entries append). It's the thing you'd ask an operator to paste when they
|
||||
report a setup bug, and the thing an AI agent would read to understand
|
||||
what happened.
|
||||
|
||||
Entry format:
|
||||
|
||||
```
|
||||
=== [2026-04-22T22:14:12Z] bootstrap [45.1s] → success ===
|
||||
platform: linux
|
||||
is_wsl: false
|
||||
node_version: 22.22.2
|
||||
deps_ok: true
|
||||
native_ok: true
|
||||
raw: logs/setup-steps/01-bootstrap.log
|
||||
|
||||
=== [2026-04-22T22:14:57Z] environment [2.3s] → success ===
|
||||
docker: running
|
||||
apple_container: not_found
|
||||
raw: logs/setup-steps/02-environment.log
|
||||
|
||||
=== [2026-04-22T22:15:00Z] container [92.4s] → success ===
|
||||
runtime: docker
|
||||
image: nanoclaw-agent:latest
|
||||
build_ok: true
|
||||
raw: logs/setup-steps/03-container.log
|
||||
```
|
||||
|
||||
Design constraints:
|
||||
- Start-time timestamp (UTC, ISO-8601) on the opening line so a `grep`
|
||||
gives you the sequence.
|
||||
- Duration in seconds with one decimal — fast steps read as "0.5s", not
|
||||
"0ms".
|
||||
- Status is one of: `success`, `skipped`, `failed`, `aborted`.
|
||||
- Fields are step-specific but **must** be short scalar values. No JSON,
|
||||
no multi-line. If a value is long, put it in the raw log and reference
|
||||
it.
|
||||
- Always emit a `raw:` pointer, even on success — makes debugging the
|
||||
second failure easier.
|
||||
- **User choices** are their own entries, not nested inside a step:
|
||||
|
||||
```
|
||||
=== [2026-04-22T22:17:44Z] user-input → display_name ===
|
||||
value: gav
|
||||
|
||||
=== [2026-04-22T22:17:51Z] user-input → channel_choice ===
|
||||
value: telegram
|
||||
```
|
||||
|
||||
These matter because the path through the setup flow depends on them.
|
||||
|
||||
The log opens with a header block identifying the run, and closes with
|
||||
a completion block:
|
||||
|
||||
```
|
||||
## 2026-04-22T22:14:12Z · setup:auto started
|
||||
user: exedev
|
||||
cwd: /home/exedev/nanoclaw
|
||||
branch: branded-setup
|
||||
commit: 6e0d742
|
||||
|
||||
… (step entries) …
|
||||
|
||||
## 2026-04-22T22:18:54Z · completed (total 4m42s)
|
||||
```
|
||||
|
||||
On failure the completion block names the failing step and its error:
|
||||
|
||||
```
|
||||
## 2026-04-22T22:16:40Z · aborted at container (err=cache_miss)
|
||||
```
|
||||
|
||||
### Level 3: raw per-step logs
|
||||
|
||||
`logs/setup-steps/NN-step-name.log` — one file per step, numbered in
|
||||
execution order (zero-padded 2-digit prefix for natural sorting). Full
|
||||
verbatim stdout + stderr from the child process. Truncated and rewritten
|
||||
on each run (not appended).
|
||||
|
||||
Contents are whatever the step emits: apt output, docker build layers,
|
||||
pnpm install spam, `curl` bodies, etc. This is the evidence plane —
|
||||
"what did the shell actually see?" Nothing is filtered.
|
||||
|
||||
## Contract for a new step
|
||||
|
||||
When you add a step (either a TS step in `setup/<name>.ts` or a bash
|
||||
installer invoked from `auto.ts`), it must:
|
||||
|
||||
1. **Receive a raw-log path** from the caller. Write all stdout + stderr
|
||||
there. Don't write to the terminal directly.
|
||||
2. **Emit a single terminal status block** at the end, containing
|
||||
`STATUS: success|skipped|failed` and any step-specific fields:
|
||||
|
||||
```
|
||||
=== NANOCLAW SETUP: STEP_NAME ===
|
||||
STATUS: success
|
||||
KEY: value
|
||||
KEY: value
|
||||
=== END ===
|
||||
```
|
||||
|
||||
Field names are `UPPER_SNAKE_CASE`. Values are short scalars.
|
||||
|
||||
3. If it's a long-running step, optionally emit **sub-status blocks**
|
||||
mid-stream. `auto.ts` parses them live and can render intermediate
|
||||
UI (as `pair-telegram` does with `PAIR_TELEGRAM_CODE` /
|
||||
`PAIR_TELEGRAM_ATTEMPT`).
|
||||
|
||||
4. **Exit non-zero** on hard failure so `auto.ts` can distinguish
|
||||
"step ran to completion and reported failed" from "step crashed".
|
||||
|
||||
The driver handles the rest: spinner in level 1, structured append to
|
||||
level 2, raw capture to level 3.
|
||||
|
||||
## The Anthropic exception
|
||||
|
||||
Anthropic credential registration (`setup/register-claude-token.sh`) is
|
||||
the **one** permitted break in the visual flow. Why:
|
||||
|
||||
- `claude setup-token` opens a browser, runs its own OAuth prompt, and
|
||||
prints the token. It owns the TTY via `script(1)`.
|
||||
- We don't want to re-implement the OAuth device flow ourselves.
|
||||
- We don't want to intercept / mirror the token (it appears in the
|
||||
user's terminal already — mirroring it adds attack surface).
|
||||
|
||||
So during this step:
|
||||
- The clack flow explicitly pauses (a `p.log.step` marker says "this
|
||||
part is interactive, you're handing off to Anthropic").
|
||||
- The child inherits stdio fully.
|
||||
- When control returns, clack resumes on the next line with a success
|
||||
marker.
|
||||
|
||||
The level-2 log still gets an entry (`auth [interactive] → success`
|
||||
with the method — subscription / oauth-token / api-key). Level-3 captures
|
||||
are optional here; mirroring `script -q` output is tricky and the risk of
|
||||
leaking the token to disk outweighs the debugging value.
|
||||
|
||||
## File reference
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `nanoclaw.sh` | Top-level wrapper. Phase 1 (bootstrap) and phase 2 (setup:auto) orchestration. Writes bootstrap's raw log + progression entry. |
|
||||
| `setup.sh` | Phase 1 bootstrap: Node, pnpm, native-module verify. Emits its own `BOOTSTRAP` status block (historically printed to stdout; now goes to the bootstrap raw log). |
|
||||
| `setup/auto.ts` | Phase 2 driver. Orchestrates the clack UI, step execution, user prompts, and writes to all three log levels for every step it spawns. |
|
||||
| `setup/logs.ts` | The logging primitives (`logStep`, `logUserInput`, `logComplete`, `stepRawLog`, `initSetupLog`). Single source of truth for level 2/3 formatting and file paths. |
|
||||
| `setup/<step>.ts` | Individual step implementations. Must emit one terminal status block; must not write directly to the terminal. |
|
||||
| `setup/register-claude-token.sh` | The Anthropic exception. Inherits stdio, prints its own UI, returns a status to the driver. |
|
||||
| `setup/add-telegram.sh` | Non-interactive adapter installer. Reads `TELEGRAM_BOT_TOKEN` from env; never prompts. User-facing bits live in `auto.ts`. |
|
||||
| `setup/pair-telegram.ts` | Emits `PAIR_TELEGRAM_CODE` / `PAIR_TELEGRAM_ATTEMPT` / `PAIR_TELEGRAM` status blocks. Never prints UI. The driver renders it via clack notes. |
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Printing debug output from inside a step.** Tempting during
|
||||
development; forbidden in checked-in code. All runtime messaging goes
|
||||
through status blocks (level 2) or raw log writes (level 3).
|
||||
- **Adding a `console.log` that "just this once" goes to the terminal.**
|
||||
It breaks the clack flow — the spinner line gets torn. Use
|
||||
`log.info` / `log.error` from `src/log.ts` (writes to the raw log)
|
||||
instead.
|
||||
- **`stdio: 'inherit'` for a non-exception child.** See Anthropic above.
|
||||
Anything else needs `pipe` + explicit capture.
|
||||
- **Tee-ing to stderr.** Clack's spinner owns the terminal during a step.
|
||||
Even stderr writes tear the frame. Pipe everything, then choose what
|
||||
to surface.
|
||||
- **UTF-8 in bash `$VAR…` positions.** Bash's lexer can pull the first
|
||||
byte of a multi-byte character into the variable name and trip
|
||||
`set -u`. Always brace: `${VAR}…`.
|
||||
|
||||
## Future work (not yet implemented)
|
||||
|
||||
- **Progression log rotation.** Today's implementation truncates on each
|
||||
run. Future: roll prior runs to `logs/setup.log.1`, `.2`, etc.
|
||||
- **Raw log rotation for multi-run installs.** Currently each run
|
||||
overwrites. Fine for now; revisit if support needs to compare
|
||||
successive attempts.
|
||||
- **Structured output from `register-claude-token.sh`.** The interactive
|
||||
step emits no machine-readable status today. Future could add a
|
||||
post-interaction status block with the method used.
|
||||
-251
@@ -1,251 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# NanoClaw — end-to-end setup entry point.
|
||||
#
|
||||
# Runs two parts from the user's perspective as one continuous flow:
|
||||
# - bash-side: install the basics (Node + pnpm + native modules) under a
|
||||
# bash-rendered clack-alike spinner. Can't use setup/auto.ts here since
|
||||
# tsx isn't available until pnpm install completes.
|
||||
# - hand off to `pnpm run setup:auto`, which renders the rest with
|
||||
# @clack/prompts. The wordmark is printed once here so setup:auto can
|
||||
# skip it and the flow reads as a single sequence.
|
||||
#
|
||||
# Obeys the three-level output contract (see docs/setup-flow.md):
|
||||
# 1. User-facing — concise status line with elapsed time
|
||||
# 2. Progression log — logs/setup.log (header + one entry per step)
|
||||
# 3. Raw per-step log — logs/setup-steps/NN-name.log (full verbatim output)
|
||||
#
|
||||
# Config via env — passed through unchanged:
|
||||
# NANOCLAW_SKIP comma-separated setup:auto step names to skip
|
||||
# SECRET_NAME OneCLI secret name (default: Anthropic)
|
||||
# HOST_PATTERN OneCLI host pattern (default: api.anthropic.com)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||||
STEPS_DIR="$LOGS_DIR/setup-steps"
|
||||
PROGRESS_LOG="$LOGS_DIR/setup.log"
|
||||
|
||||
# Diagnostics: persisted install-id + fire-and-forget emit. Sourced early
|
||||
# so `setup_launched` covers dropoff before bootstrap even starts.
|
||||
# shellcheck source=setup/lib/diagnostics.sh
|
||||
source "$PROJECT_ROOT/setup/lib/diagnostics.sh"
|
||||
ph_event setup_launched \
|
||||
platform="$(uname -s | tr 'A-Z' 'a-z')" \
|
||||
is_wsl="$([ -f /proc/version ] && grep -qi 'microsoft\|wsl' /proc/version 2>/dev/null && echo true || echo false)"
|
||||
|
||||
# ─── log helpers ────────────────────────────────────────────────────────
|
||||
|
||||
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
write_header() {
|
||||
local ts
|
||||
ts=$(ts_utc)
|
||||
local branch commit
|
||||
branch=$(git branch --show-current 2>/dev/null || echo unknown)
|
||||
commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
{
|
||||
echo "## ${ts} · setup:auto started"
|
||||
echo " invocation: nanoclaw.sh"
|
||||
echo " user: $(whoami)"
|
||||
echo " cwd: ${PROJECT_ROOT}"
|
||||
echo " branch: ${branch}"
|
||||
echo " commit: ${commit}"
|
||||
echo ""
|
||||
} > "$PROGRESS_LOG"
|
||||
}
|
||||
|
||||
# grep_field FIELD FILE — first value of FIELD: from a status block.
|
||||
grep_field() {
|
||||
grep "^$1:" "$2" 2>/dev/null | head -1 | sed "s/^$1: *//" || true
|
||||
}
|
||||
|
||||
write_bootstrap_entry() {
|
||||
local status=$1 dur=$2 raw=$3
|
||||
local ts
|
||||
ts=$(ts_utc)
|
||||
local platform is_wsl node_version deps_ok native_ok has_build_tools
|
||||
platform=$(grep_field PLATFORM "$raw")
|
||||
is_wsl=$(grep_field IS_WSL "$raw")
|
||||
node_version=$(grep_field NODE_VERSION "$raw" | head -1)
|
||||
deps_ok=$(grep_field DEPS_OK "$raw")
|
||||
native_ok=$(grep_field NATIVE_OK "$raw")
|
||||
has_build_tools=$(grep_field HAS_BUILD_TOOLS "$raw")
|
||||
{
|
||||
echo "=== [${ts}] bootstrap [${dur}s] → ${status} ==="
|
||||
[ -n "$platform" ] && echo " platform: ${platform}"
|
||||
[ -n "$is_wsl" ] && echo " is_wsl: ${is_wsl}"
|
||||
[ -n "$node_version" ] && echo " node_version: ${node_version}"
|
||||
[ -n "$deps_ok" ] && echo " deps_ok: ${deps_ok}"
|
||||
[ -n "$native_ok" ] && echo " native_ok: ${native_ok}"
|
||||
[ -n "$has_build_tools" ] && echo " has_build_tools: ${has_build_tools}"
|
||||
# Emit the raw path relative to PROJECT_ROOT so the progression log
|
||||
# is portable and matches the TS-side format (logs/setup-steps/NN-…).
|
||||
echo " raw: ${raw#${PROJECT_ROOT}/}"
|
||||
echo ""
|
||||
} >> "$PROGRESS_LOG"
|
||||
}
|
||||
|
||||
write_abort_entry() {
|
||||
local step=$1 error=$2
|
||||
local ts
|
||||
ts=$(ts_utc)
|
||||
echo "## ${ts} · aborted at ${step} (${error})" >> "$PROGRESS_LOG"
|
||||
}
|
||||
|
||||
# ─── bash-side "clack-alike" status line ────────────────────────────────
|
||||
|
||||
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
|
||||
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
gray() { use_ansi && printf '\033[90m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||||
# brand cyan (≈ #2BB7CE) — truecolor when supported, 16-color cyan fallback.
|
||||
brand_bold() {
|
||||
if use_ansi; then
|
||||
if [ "${COLORTERM:-}" = "truecolor" ] || [ "${COLORTERM:-}" = "24bit" ]; then
|
||||
printf '\033[1;38;2;43;183;206m%s\033[0m' "$1"
|
||||
else
|
||||
printf '\033[1;36m%s\033[0m' "$1"
|
||||
fi
|
||||
else
|
||||
printf '%s' "$1"
|
||||
fi
|
||||
}
|
||||
clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; }
|
||||
|
||||
spinner_start() { printf '%s %s…' "$(gray '◒')" "$1"; }
|
||||
spinner_update() { clear_line; printf '%s %s… %s' "$(gray '◒')" "$1" "$(dim "(${2}s)")"; }
|
||||
spinner_success() { clear_line; printf '%s %s %s\n' "$(gray '◇')" "$1" "$(dim "(${2}s)")"; }
|
||||
spinner_failure() { clear_line; printf '%s %s %s\n' "$(red '✗')" "$1" "$(dim "(${2}s)")"; }
|
||||
|
||||
# ─── fresh-run setup ────────────────────────────────────────────────────
|
||||
|
||||
rm -rf "$STEPS_DIR"
|
||||
rm -f "$PROGRESS_LOG"
|
||||
mkdir -p "$STEPS_DIR" "$LOGS_DIR"
|
||||
write_header
|
||||
|
||||
# NanoClaw wordmark + subtitle — setup:auto will see NANOCLAW_BOOTSTRAPPED=1
|
||||
# and skip printing these again, so the flow stays visually continuous.
|
||||
printf '\n %s%s\n' "$(bold 'Nano')" "$(brand_bold 'Claw')"
|
||||
printf ' %s\n\n' "$(dim 'Setting up your personal AI assistant')"
|
||||
|
||||
# ─── pre-flight: Homebrew on macOS ─────────────────────────────────────
|
||||
# setup/install-node.sh and setup/install-docker.sh both require `brew` on
|
||||
# macOS. On a factory Mac there's no brew, and those helpers would fail
|
||||
# later inside the bootstrap spinner with a cryptic error. Prompt here,
|
||||
# before the spinner starts, so the user knows what's about to happen and
|
||||
# brew's own interactive sudo/CLT prompts stay readable.
|
||||
if [ "$(uname -s)" = "Darwin" ] && ! command -v brew >/dev/null 2>&1; then
|
||||
printf ' %s\n' \
|
||||
"$(dim "Homebrew isn't installed. NanoClaw uses it to install Node and Docker on your Mac.")"
|
||||
printf ' %s\n\n' \
|
||||
"$(dim "This also installs Apple's Command Line Tools, which can take 5-10 minutes.")"
|
||||
read -r -p " $(bold 'Install Homebrew now?') [Y/n] " BREW_ANS </dev/tty
|
||||
|
||||
case "${BREW_ANS:-Y}" in
|
||||
[Yy]*|'')
|
||||
printf '\n'
|
||||
# Official installer. Runs interactively, triggers xcode-select --install
|
||||
# for Command Line Tools, and prompts for the user's password for sudo.
|
||||
# `|| true` so a user-cancelled install doesn't kill us via `set -e`;
|
||||
# the PATH check below is the real gate.
|
||||
/bin/bash -c \
|
||||
"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" \
|
||||
|| true
|
||||
|
||||
# Put brew on PATH for this session (the installer writes to
|
||||
# .zprofile/.bash_profile for future shells, but not this one).
|
||||
if [ -x /opt/homebrew/bin/brew ]; then
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
elif [ -x /usr/local/bin/brew ]; then
|
||||
eval "$(/usr/local/bin/brew shellenv)"
|
||||
fi
|
||||
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
printf '\n %s %s\n' "$(red '✗')" "Homebrew install didn't complete."
|
||||
printf ' %s\n\n' \
|
||||
"$(dim 'Install manually from https://brew.sh and re-run: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
fi
|
||||
printf '\n'
|
||||
;;
|
||||
*)
|
||||
printf '\n %s\n\n' \
|
||||
"$(dim 'NanoClaw needs Homebrew. Install it from https://brew.sh and re-run.')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# ─── first step: install the basics (Node + pnpm + native modules) ─────
|
||||
|
||||
BOOTSTRAP_RAW="${STEPS_DIR}/01-bootstrap.log"
|
||||
BOOTSTRAP_LABEL="Installing the basics"
|
||||
BOOTSTRAP_START=$(date +%s)
|
||||
|
||||
# One-line "why" that teaches a differentiator while the user waits.
|
||||
printf '%s %s\n' "$(gray '│')" \
|
||||
"$(dim "NanoClaw is small and runs entirely on your machine. Yours to modify.")"
|
||||
spinner_start "$BOOTSTRAP_LABEL"
|
||||
|
||||
# Run in the background so we can tick elapsed time. Capture exit code via
|
||||
# a tmpfile (subshell $? is lost after the while loop finishes).
|
||||
BOOTSTRAP_EXIT_FILE=$(mktemp -t nanoclaw-bootstrap-exit.XXXXXX)
|
||||
(
|
||||
# setup.sh's legacy `log()` writes to a file; point it at the raw log
|
||||
# so its verbose entries land alongside the stdout we're capturing.
|
||||
export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW"
|
||||
if bash setup.sh > "$BOOTSTRAP_RAW" 2>&1; then
|
||||
echo 0 > "$BOOTSTRAP_EXIT_FILE"
|
||||
else
|
||||
echo $? > "$BOOTSTRAP_EXIT_FILE"
|
||||
fi
|
||||
) &
|
||||
BOOTSTRAP_PID=$!
|
||||
|
||||
while kill -0 "$BOOTSTRAP_PID" 2>/dev/null; do
|
||||
sleep 1
|
||||
if kill -0 "$BOOTSTRAP_PID" 2>/dev/null; then
|
||||
spinner_update "$BOOTSTRAP_LABEL" "$(( $(date +%s) - BOOTSTRAP_START ))"
|
||||
fi
|
||||
done
|
||||
# `wait` surfaces the child's exit code; we've already captured it.
|
||||
wait "$BOOTSTRAP_PID" 2>/dev/null || true
|
||||
|
||||
BOOTSTRAP_RC=$(cat "$BOOTSTRAP_EXIT_FILE")
|
||||
rm -f "$BOOTSTRAP_EXIT_FILE"
|
||||
BOOTSTRAP_DUR=$(( $(date +%s) - BOOTSTRAP_START ))
|
||||
|
||||
if [ "$BOOTSTRAP_RC" -eq 0 ]; then
|
||||
spinner_success "Basics installed" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry success "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
else
|
||||
spinner_failure "Couldn't install the basics" "$BOOTSTRAP_DUR"
|
||||
write_bootstrap_entry failed "$BOOTSTRAP_DUR" "$BOOTSTRAP_RAW"
|
||||
write_abort_entry bootstrap "exit-${BOOTSTRAP_RC}"
|
||||
|
||||
echo
|
||||
echo "$(dim '── last 40 lines of ')$(dim "$BOOTSTRAP_RAW")$(dim ' ──')"
|
||||
tail -40 "$BOOTSTRAP_RAW"
|
||||
echo
|
||||
echo "$(dim "Full raw log: $BOOTSTRAP_RAW")"
|
||||
echo "$(dim "Progression: $PROGRESS_LOG")"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── hand off to setup:auto ────────────────────────────────────────────
|
||||
|
||||
# NANOCLAW_BOOTSTRAPPED=1 tells setup/auto.ts to skip the wordmark (we
|
||||
# already printed it) and to append to the progression log rather than
|
||||
# wipe it.
|
||||
export NANOCLAW_BOOTSTRAPPED=1
|
||||
|
||||
# --silent suppresses pnpm's `> nanoclaw@2.0.0 setup:auto / > tsx setup/auto.ts`
|
||||
# preamble so the flow continues visually from "Basics installed" straight
|
||||
# into setup:auto's spinner. exec so signals (Ctrl-C) propagate directly.
|
||||
exec pnpm --silent run setup:auto
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.0",
|
||||
"version": "1.2.52",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
@@ -15,7 +15,6 @@
|
||||
"format:check": "prettier --check \"src/**/*.ts\"",
|
||||
"prepare": "husky",
|
||||
"setup": "tsx setup/index.ts",
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
@@ -24,12 +23,10 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0",
|
||||
"kleur": "^4.1.5"
|
||||
"cron-parser": "5.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.35.0",
|
||||
|
||||
Generated
-54
@@ -8,9 +8,6 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@clack/prompts':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1
|
||||
@@ -23,9 +20,6 @@ importers:
|
||||
cron-parser:
|
||||
specifier: 5.5.0
|
||||
version: 5.5.0
|
||||
kleur:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
devDependencies:
|
||||
'@eslint/js':
|
||||
specifier: ^9.35.0
|
||||
@@ -66,12 +60,6 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@clack/core@1.2.0':
|
||||
resolution: {integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==}
|
||||
|
||||
'@clack/prompts@1.2.0':
|
||||
resolution: {integrity: sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w==}
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
@@ -760,15 +748,6 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-string-truncated-width@1.2.1:
|
||||
resolution: {integrity: sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow==}
|
||||
|
||||
fast-string-width@1.1.0:
|
||||
resolution: {integrity: sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ==}
|
||||
|
||||
fast-wrap-ansi@0.1.6:
|
||||
resolution: {integrity: sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -887,10 +866,6 @@ packages:
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
levn@0.4.1:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -1264,9 +1239,6 @@ packages:
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -1490,18 +1462,6 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@clack/core@1.2.0':
|
||||
dependencies:
|
||||
fast-wrap-ansi: 0.1.6
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@clack/prompts@1.2.0':
|
||||
dependencies:
|
||||
'@clack/core': 1.2.0
|
||||
fast-string-width: 1.1.0
|
||||
fast-wrap-ansi: 0.1.6
|
||||
sisteransi: 1.0.5
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -2145,16 +2105,6 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-string-truncated-width@1.2.1: {}
|
||||
|
||||
fast-string-width@1.1.0:
|
||||
dependencies:
|
||||
fast-string-truncated-width: 1.2.1
|
||||
|
||||
fast-wrap-ansi@0.1.6:
|
||||
dependencies:
|
||||
fast-string-width: 1.1.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -2241,8 +2191,6 @@ snapshots:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
levn@0.4.1:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -2787,8 +2735,6 @@ snapshots:
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.8k tokens, 22% of context window">
|
||||
<title>43.8k tokens, 22% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="43.7k tokens, 22% of context window">
|
||||
<title>43.7k tokens, 22% of context window</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,8 +15,8 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||
<text x="26" y="14">tokens</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.8k</text>
|
||||
<text x="74" y="14">43.8k</text>
|
||||
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">43.7k</text>
|
||||
<text x="74" y="14">43.7k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Initialize the scratch CLI agent used during `/new-setup`.
|
||||
*
|
||||
* Creates the synthetic `cli:local` user, grants owner role if no owner
|
||||
* exists yet, builds an agent group with a minimal CLAUDE.md, and wires it
|
||||
* to the CLI messaging group so `pnpm run chat` works immediately.
|
||||
*
|
||||
* No welcome is staged — the operator's first `pnpm run chat` is the
|
||||
* natural wake, and the agent introduces itself on first contact per its
|
||||
* CLAUDE.md.
|
||||
*
|
||||
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
|
||||
* channel adapters, so there's no Gateway conflict.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/init-cli-agent.ts \
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"]
|
||||
*/
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
import { createAgentGroup, getAgentGroupByFolder } from '../src/db/agent-groups.js';
|
||||
import { initDb } from '../src/db/connection.js';
|
||||
import {
|
||||
createMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
getMessagingGroupAgentByPair,
|
||||
getMessagingGroupByPlatform,
|
||||
} from '../src/db/messaging-groups.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
const CLI_CHANNEL = 'cli';
|
||||
const CLI_PLATFORM_ID = 'local';
|
||||
const CLI_SYNTHETIC_USER_ID = `${CLI_CHANNEL}:${CLI_PLATFORM_ID}`;
|
||||
|
||||
interface Args {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const key = argv[i];
|
||||
const val = argv[i + 1];
|
||||
if (key === '--display-name') {
|
||||
displayName = val;
|
||||
i++;
|
||||
} else if (key === '--agent-name') {
|
||||
agentName = val;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
console.error('Missing required arg: --display-name');
|
||||
console.error('See scripts/init-cli-agent.ts header for usage.');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return {
|
||||
displayName,
|
||||
agentName: agentName?.trim() || displayName,
|
||||
};
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const db = initDb(path.join(DATA_DIR, 'v2.db'));
|
||||
runMigrations(db);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 1. Synthetic CLI user + owner grant if none exists.
|
||||
upsertUser({
|
||||
id: CLI_SYNTHETIC_USER_ID,
|
||||
kind: CLI_CHANNEL,
|
||||
display_name: args.displayName,
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
// Owner grant deferred to init-first-agent when the real channel user is
|
||||
// wired — cli:local is a scratch identity, not the operator.
|
||||
const promotedToOwner = false;
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
const folder = `cli-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
const agId = generateId('ag');
|
||||
createAgentGroup({
|
||||
id: agId,
|
||||
name: args.agentName,
|
||||
folder,
|
||||
agent_provider: null,
|
||||
created_at: now,
|
||||
});
|
||||
ag = getAgentGroupByFolder(folder)!;
|
||||
console.log(`Created agent group: ${ag.id} (${folder})`);
|
||||
} else {
|
||||
console.log(`Reusing agent group: ${ag.id} (${folder})`);
|
||||
}
|
||||
initGroupFilesystem(ag, {
|
||||
instructions:
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
});
|
||||
|
||||
// 3. CLI messaging group + wiring.
|
||||
let cliMg: MessagingGroup | undefined = getMessagingGroupByPlatform(CLI_CHANNEL, CLI_PLATFORM_ID);
|
||||
if (!cliMg) {
|
||||
cliMg = {
|
||||
id: generateId('mg'),
|
||||
channel_type: CLI_CHANNEL,
|
||||
platform_id: CLI_PLATFORM_ID,
|
||||
name: 'Local CLI',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(cliMg);
|
||||
console.log(`Created CLI messaging group: ${cliMg.id}`);
|
||||
}
|
||||
|
||||
const existing = getMessagingGroupAgentByPair(cliMg.id, ag.id);
|
||||
if (!existing) {
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: cliMg.id,
|
||||
agent_group_id: ag.id,
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
});
|
||||
console.log(`Wired cli: ${cliMg.id} -> ${ag.id}`);
|
||||
} else {
|
||||
console.log(`Wiring already exists: ${existing.id}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Init complete.');
|
||||
console.log(
|
||||
` owner: ${CLI_SYNTHETIC_USER_ID}${promotedToOwner ? ' (promoted on first owner)' : ''}`,
|
||||
);
|
||||
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
|
||||
console.log(` channel: cli/${CLI_PLATFORM_ID}`);
|
||||
console.log('');
|
||||
console.log('Run `pnpm run chat hi` to talk to your agent.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
+102
-213
@@ -1,21 +1,13 @@
|
||||
/**
|
||||
* Init the first (or Nth) NanoClaw v2 agent for a DM channel.
|
||||
*
|
||||
* Wires a real DM channel (discord, telegram, etc.) to a new agent group,
|
||||
* then hands a welcome message to the running service via the CLI socket
|
||||
* (admin transport). The service routes that message into the DM session,
|
||||
* which wakes the container synchronously — the agent processes the welcome
|
||||
* and DMs the operator through the normal delivery path.
|
||||
*
|
||||
* CLI channel wiring is handled separately by `scripts/init-cli-agent.ts`.
|
||||
*
|
||||
* Creates/reuses: user, owner grant (if none), agent group + filesystem,
|
||||
* messaging group(s), wiring.
|
||||
* DM messaging group, wiring, session. Stages a system welcome message so
|
||||
* the host sweep wakes the container and the agent DMs the operator via
|
||||
* the normal delivery path.
|
||||
*
|
||||
* Runs alongside the service (WAL-mode sqlite + CLI socket IPC) — does NOT
|
||||
* initialize channel adapters, so there's no Gateway conflict. Requires
|
||||
* the service to be running: the welcome hand-off goes over the CLI socket
|
||||
* and fails loudly if the service isn't up.
|
||||
* Runs alongside the service (WAL-mode sqlite) — does NOT initialize
|
||||
* channel adapters, so there's no Gateway conflict.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/init-first-agent.ts \
|
||||
@@ -24,13 +16,11 @@
|
||||
* --platform-id discord:@me:1491573333382523708 \
|
||||
* --display-name "Gavriel" \
|
||||
* [--agent-name "Andy"] \
|
||||
* [--welcome "System instruction: ..."] \
|
||||
* [--role owner|admin|member] # default: owner
|
||||
* [--welcome "System instruction: ..."]
|
||||
*
|
||||
* For direct-addressable channels (telegram, whatsapp, etc.), --platform-id
|
||||
* is typically the same as the handle in --user-id, with the channel prefix.
|
||||
*/
|
||||
import net from 'net';
|
||||
import path from 'path';
|
||||
|
||||
import { DATA_DIR } from '../src/config.js';
|
||||
@@ -44,13 +34,11 @@ import {
|
||||
} from '../src/db/messaging-groups.js';
|
||||
import { runMigrations } from '../src/db/migrations/index.js';
|
||||
import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinations.js';
|
||||
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { grantRole, hasAnyOwner } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
|
||||
type Role = 'owner' | 'admin' | 'member';
|
||||
import { resolveSession, writeSessionMessage } from '../src/session-manager.js';
|
||||
import type { AgentGroup } from '../src/types.js';
|
||||
|
||||
interface Args {
|
||||
channel: string;
|
||||
@@ -59,14 +47,11 @@ interface Args {
|
||||
displayName: string;
|
||||
agentName: string;
|
||||
welcome: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME =
|
||||
'System instruction: run /welcome to introduce yourself to the user on this new channel.';
|
||||
|
||||
const DEFAULT_ROLE: Role = 'owner';
|
||||
|
||||
function parseArgs(argv: string[]): Args {
|
||||
const out: Partial<Args> = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -97,27 +82,13 @@ function parseArgs(argv: string[]): Args {
|
||||
out.welcome = val;
|
||||
i++;
|
||||
break;
|
||||
case '--role': {
|
||||
const raw = (val ?? '').toLowerCase();
|
||||
if (raw !== 'owner' && raw !== 'admin' && raw !== 'member') {
|
||||
console.error(
|
||||
`Invalid --role: ${raw} (expected 'owner', 'admin', or 'member')`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
out.role = raw;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const required: (keyof Args)[] = ['channel', 'userId', 'platformId', 'displayName'];
|
||||
const missing = required.filter((k) => !out[k]);
|
||||
if (missing.length) {
|
||||
console.error(
|
||||
`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`,
|
||||
);
|
||||
console.error(`Missing required args: ${missing.map((k) => `--${k.replace(/([A-Z])/g, '-$1').toLowerCase()}`).join(', ')}`);
|
||||
console.error('See scripts/init-first-agent.ts header for usage.');
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -129,7 +100,6 @@ function parseArgs(argv: string[]): Args {
|
||||
displayName: out.displayName!,
|
||||
agentName: out.agentName?.trim() || out.displayName!,
|
||||
welcome: out.welcome?.trim() || DEFAULT_WELCOME,
|
||||
role: out.role ?? DEFAULT_ROLE,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,43 +108,13 @@ function namespacedUserId(channel: string, raw: string): string {
|
||||
}
|
||||
|
||||
function namespacedPlatformId(channel: string, raw: string): string {
|
||||
if (raw.startsWith(`${channel}:`)) return raw;
|
||||
// Adapters using native JID format (WhatsApp: <phone>@s.whatsapp.net,
|
||||
// <groupId>@g.us) store platform_id without a channel prefix. The '@' is
|
||||
// the discriminator — telegram/discord platform_ids don't contain it
|
||||
// except after a channel prefix, which is already handled above.
|
||||
if (raw.includes('@')) return raw;
|
||||
return `${channel}:${raw}`;
|
||||
return raw.startsWith(`${channel}:`) ? raw : `${channel}:${raw}`;
|
||||
}
|
||||
|
||||
function generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function wireIfMissing(mg: MessagingGroup, ag: AgentGroup, now: string, label: string): void {
|
||||
const existing = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (existing) {
|
||||
console.log(`Wiring already exists: ${existing.id} (${label})`);
|
||||
return;
|
||||
}
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
// DM / CLI (is_group=0) default to "respond to everything" via a '.' regex.
|
||||
// Group chats default to mention-only; admins can upgrade to mention-sticky
|
||||
// via /manage-channels once the agent is in use.
|
||||
engage_mode: mg.is_group === 0 ? 'pattern' : 'mention',
|
||||
engage_pattern: mg.is_group === 0 ? '.' : null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
});
|
||||
console.log(`Wired ${label}: ${mg.id} -> ${ag.id}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -183,7 +123,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 1. User + (conditional) owner grant.
|
||||
// 1. User + (conditional) owner grant
|
||||
const userId = namespacedUserId(args.channel, args.userId);
|
||||
upsertUser({
|
||||
id: userId,
|
||||
@@ -192,10 +132,19 @@ async function main(): Promise<void> {
|
||||
created_at: now,
|
||||
});
|
||||
|
||||
// Owner grant is deferred until after the agent group is resolved, since
|
||||
// an admin grant is scoped to that group. See step 2b.
|
||||
let promotedToOwner = false;
|
||||
if (!hasAnyOwner()) {
|
||||
grantRole({
|
||||
user_id: userId,
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now,
|
||||
});
|
||||
promotedToOwner = true;
|
||||
}
|
||||
|
||||
// 2. Agent group + filesystem.
|
||||
// 2. Agent group + filesystem
|
||||
const folder = `dm-with-${normalizeName(args.displayName)}`;
|
||||
let ag: AgentGroup | undefined = getAgentGroupByFolder(folder);
|
||||
if (!ag) {
|
||||
@@ -216,60 +165,13 @@ async function main(): Promise<void> {
|
||||
instructions:
|
||||
`# ${args.agentName}\n\n` +
|
||||
`You are ${args.agentName}, a personal NanoClaw agent for ${args.displayName}. ` +
|
||||
'When the user first reaches out (or you receive a system welcome prompt), introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
'When you receive a system welcome prompt, introduce yourself briefly and invite them to chat. Keep replies concise.',
|
||||
});
|
||||
|
||||
// 2b. Assign the user a role for this agent group. The caller picks via
|
||||
// --role; the channel drivers default to 'owner' for the self-host case.
|
||||
// - owner: global owner (agent_group_id=null). Cross-channel access.
|
||||
// - admin: scoped admin for this agent group only.
|
||||
// - member: no role grant, just the membership row below.
|
||||
// grantRole inserts a new row per call — idempotence check against
|
||||
// getUserRoles prevents duplicates on re-runs.
|
||||
const existingRoles = getUserRoles(userId);
|
||||
if (args.role === 'owner') {
|
||||
const alreadyOwner = existingRoles.some(
|
||||
(r) => r.role === 'owner' && r.agent_group_id === null,
|
||||
);
|
||||
if (!alreadyOwner) {
|
||||
grantRole({
|
||||
user_id: userId,
|
||||
role: 'owner',
|
||||
agent_group_id: null,
|
||||
granted_by: null,
|
||||
granted_at: now,
|
||||
});
|
||||
}
|
||||
} else if (args.role === 'admin') {
|
||||
const alreadyAdmin = existingRoles.some(
|
||||
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
||||
);
|
||||
if (!alreadyAdmin) {
|
||||
grantRole({
|
||||
user_id: userId,
|
||||
role: 'admin',
|
||||
agent_group_id: ag.id,
|
||||
granted_by: null,
|
||||
granted_at: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Always add a membership row so the access gate has a straightforward
|
||||
// yes/no even for users without a role grant. INSERT OR IGNORE, so this
|
||||
// is a no-op when the row already exists (e.g. re-runs, owners whose
|
||||
// access already passes via role).
|
||||
addMember({
|
||||
user_id: userId,
|
||||
agent_group_id: ag.id,
|
||||
added_by: null,
|
||||
added_at: now,
|
||||
});
|
||||
|
||||
// 3. DM messaging group.
|
||||
// 3. DM messaging group
|
||||
const platformId = namespacedPlatformId(args.channel, args.platformId);
|
||||
let dmMg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||
if (!dmMg) {
|
||||
let mg = getMessagingGroupByPlatform(args.channel, platformId);
|
||||
if (!mg) {
|
||||
const mgId = generateId('mg');
|
||||
createMessagingGroup({
|
||||
id: mgId,
|
||||
@@ -280,106 +182,93 @@ async function main(): Promise<void> {
|
||||
unknown_sender_policy: 'strict',
|
||||
created_at: now,
|
||||
});
|
||||
dmMg = getMessagingGroupByPlatform(args.channel, platformId)!;
|
||||
console.log(`Created messaging group: ${dmMg.id} (${platformId})`);
|
||||
mg = getMessagingGroupByPlatform(args.channel, platformId)!;
|
||||
console.log(`Created messaging group: ${mg.id} (${platformId})`);
|
||||
} else {
|
||||
console.log(`Reusing messaging group: ${dmMg.id} (${platformId})`);
|
||||
console.log(`Reusing messaging group: ${mg.id} (${platformId})`);
|
||||
}
|
||||
|
||||
// 4. Wire DM messaging group to the agent.
|
||||
wireIfMissing(dmMg, ag, now, 'dm');
|
||||
// 4. Wire (auto-creates the companion agent_destinations row)
|
||||
const existingMga = getMessagingGroupAgentByPair(mg.id, ag.id);
|
||||
if (!existingMga) {
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: mg.id,
|
||||
agent_group_id: ag.id,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
});
|
||||
console.log(`Wired ${mg.id} -> ${ag.id}`);
|
||||
} else {
|
||||
console.log(`Wiring already exists: ${existingMga.id}`);
|
||||
}
|
||||
|
||||
// 5. Welcome delivery over the CLI socket. Router picks up the line,
|
||||
// writes the message into the DM session's inbound.db, and wakes the
|
||||
// container synchronously — no sweep wait. The paired user's identity is
|
||||
// passed so the sender resolver sees the real owner, not cli:local.
|
||||
await sendWelcomeViaCliSocket(dmMg, args.welcome, {
|
||||
senderId: userId,
|
||||
sender: args.displayName,
|
||||
// 5. Session + staged welcome message
|
||||
const { session, created } = resolveSession(ag.id, mg.id, null, 'shared');
|
||||
console.log(`${created ? 'Created' : 'Reusing'} session: ${session.id}`);
|
||||
|
||||
writeSessionMessage(ag.id, session.id, {
|
||||
id: generateId('sys-welcome'),
|
||||
kind: 'chat',
|
||||
timestamp: now,
|
||||
platformId: mg.platform_id,
|
||||
channelType: args.channel,
|
||||
threadId: null,
|
||||
content: JSON.stringify({
|
||||
text: args.welcome,
|
||||
sender: 'system',
|
||||
senderId: 'system',
|
||||
}),
|
||||
});
|
||||
|
||||
const roleLabel =
|
||||
args.role === 'owner'
|
||||
? 'owner (global)'
|
||||
: args.role === 'admin'
|
||||
? `admin (scoped to ${ag.id})`
|
||||
: 'member';
|
||||
// 6. Wire the CLI channel to the same agent so the user can `pnpm run chat`
|
||||
// immediately. CLI ships with main and is always available — separate
|
||||
// messaging_group from the DM channel, so the two don't share a session.
|
||||
const CLI_PLATFORM_ID = 'local';
|
||||
let cliMg = getMessagingGroupByPlatform('cli', CLI_PLATFORM_ID);
|
||||
if (!cliMg) {
|
||||
cliMg = {
|
||||
id: generateId('mg'),
|
||||
channel_type: 'cli',
|
||||
platform_id: CLI_PLATFORM_ID,
|
||||
name: 'Local CLI',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now,
|
||||
};
|
||||
createMessagingGroup(cliMg);
|
||||
console.log(`Created CLI messaging group: ${cliMg.id}`);
|
||||
}
|
||||
const existingCliMga = getMessagingGroupAgentByPair(cliMg.id, ag.id);
|
||||
if (!existingCliMga) {
|
||||
createMessagingGroupAgent({
|
||||
id: generateId('mga'),
|
||||
messaging_group_id: cliMg.id,
|
||||
agent_group_id: ag.id,
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: now,
|
||||
});
|
||||
console.log(`Wired cli/${CLI_PLATFORM_ID} -> ${ag.id}`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Init complete.');
|
||||
console.log(` user: ${userId}`);
|
||||
console.log(` role: ${roleLabel}`);
|
||||
console.log(` owner: ${userId}${promotedToOwner ? ' (promoted on first owner)' : ''}`);
|
||||
console.log(` agent: ${ag.name} [${ag.id}] @ groups/${folder}`);
|
||||
console.log(` channel: ${args.channel} ${dmMg.platform_id}`);
|
||||
console.log(` channel: ${args.channel} ${platformId}`);
|
||||
console.log(` session: ${session.id}`);
|
||||
console.log(` cli: cli/${CLI_PLATFORM_ID} wired — try \`pnpm run chat hi\``);
|
||||
console.log('');
|
||||
console.log('Welcome DM queued — the agent will greet you shortly.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the welcome to the running service via its CLI Unix socket. The
|
||||
* service's CLI adapter receives `{text, to}`, builds an InboundEvent
|
||||
* targeting the DM messaging group, and calls routeInbound(). Router writes
|
||||
* the message into inbound.db and wakes the container synchronously.
|
||||
*
|
||||
* Throws if the socket isn't reachable — this script requires the service
|
||||
* to be running.
|
||||
*/
|
||||
async function sendWelcomeViaCliSocket(
|
||||
dmMg: MessagingGroup,
|
||||
welcome: string,
|
||||
identity: { senderId: string; sender: string },
|
||||
): Promise<void> {
|
||||
const sockPath = path.join(DATA_DIR, 'cli.sock');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(sockPath);
|
||||
let settled = false;
|
||||
|
||||
const settle = (err: Error | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try {
|
||||
socket.end();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
};
|
||||
|
||||
socket.once('error', (err) =>
|
||||
settle(
|
||||
new Error(
|
||||
`CLI socket at ${sockPath} not reachable: ${err.message}. Is the NanoClaw service running?`,
|
||||
),
|
||||
),
|
||||
);
|
||||
socket.once('connect', () => {
|
||||
const payload =
|
||||
JSON.stringify({
|
||||
text: welcome,
|
||||
senderId: identity.senderId,
|
||||
sender: identity.sender,
|
||||
to: {
|
||||
channelType: dmMg.channel_type,
|
||||
platformId: dmMg.platform_id,
|
||||
threadId: dmMg.platform_id,
|
||||
},
|
||||
}) + '\n';
|
||||
socket.write(payload, (err) => {
|
||||
if (err) {
|
||||
settle(err);
|
||||
return;
|
||||
}
|
||||
// Brief flush delay so the router picks up the line before we close.
|
||||
// Router handles it synchronously once read, so 50ms is plenty.
|
||||
setTimeout(() => settle(null), 50);
|
||||
});
|
||||
});
|
||||
});
|
||||
console.log('Host sweep (<=60s) will wake the container and the agent will send the welcome DM.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* One-shot migration: wire each existing group up to global memory via
|
||||
* an in-tree symlink + @-import.
|
||||
*
|
||||
* Claude Code's @-import only follows paths inside cwd, so a direct
|
||||
* `@/workspace/global/CLAUDE.md` or `@../global/CLAUDE.md` silently does
|
||||
* nothing (the import line is parsed but the target file is never
|
||||
* loaded into context). The working approach:
|
||||
*
|
||||
* 1. Symlink `groups/<folder>/.claude-global.md` →
|
||||
* `/workspace/global/CLAUDE.md` (container path; dangling on host,
|
||||
* valid inside the container via the /workspace/global mount).
|
||||
* 2. Have the group's CLAUDE.md import the symlink:
|
||||
* `@./.claude-global.md`.
|
||||
*
|
||||
* This script:
|
||||
* - Creates the symlink if missing.
|
||||
* - Replaces any existing broken `@/workspace/global/CLAUDE.md` or
|
||||
* `@../global/CLAUDE.md` import line with the symlink form.
|
||||
* - Prepends the symlink import if neither form is present.
|
||||
* - Skips entirely if `groups/global/CLAUDE.md` doesn't exist.
|
||||
*
|
||||
* Idempotent — safe to re-run.
|
||||
*
|
||||
* Usage: pnpm exec tsx scripts/migrate-group-claude-md.ts
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { GROUPS_DIR } from '../src/config.js';
|
||||
|
||||
const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md');
|
||||
const GLOBAL_MEMORY_CONTAINER_PATH = '/workspace/global/CLAUDE.md';
|
||||
const GLOBAL_MEMORY_LINK_NAME = '.claude-global.md';
|
||||
const IMPORT_LINE = `@./${GLOBAL_MEMORY_LINK_NAME}`;
|
||||
|
||||
// Match any existing @-import that points at global/CLAUDE.md, whether
|
||||
// via absolute path, relative path, or the new symlink form.
|
||||
const EXISTING_IMPORT_REGEX =
|
||||
/^@(?:\/workspace\/global\/CLAUDE\.md|\.\.\/global\/CLAUDE\.md|\.\/\.claude-global\.md)\s*$/m;
|
||||
|
||||
if (!fs.existsSync(GLOBAL_CLAUDE_MD)) {
|
||||
console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(GROUPS_DIR)) {
|
||||
console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true });
|
||||
let updated = 0;
|
||||
let alreadyWired = 0;
|
||||
let missingClaudeMd = 0;
|
||||
let symlinksCreated = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name === 'global') continue;
|
||||
|
||||
const groupDir = path.join(GROUPS_DIR, entry.name);
|
||||
|
||||
// Symlink (idempotent — skip if already present)
|
||||
const linkPath = path.join(groupDir, GLOBAL_MEMORY_LINK_NAME);
|
||||
let linkExists = false;
|
||||
try {
|
||||
fs.lstatSync(linkPath);
|
||||
linkExists = true;
|
||||
} catch {
|
||||
/* missing */
|
||||
}
|
||||
if (!linkExists) {
|
||||
fs.symlinkSync(GLOBAL_MEMORY_CONTAINER_PATH, linkPath);
|
||||
console.log(`[link] ${entry.name}: created ${GLOBAL_MEMORY_LINK_NAME}`);
|
||||
symlinksCreated++;
|
||||
}
|
||||
|
||||
// CLAUDE.md import wiring
|
||||
const claudeMd = path.join(groupDir, 'CLAUDE.md');
|
||||
if (!fs.existsSync(claudeMd)) {
|
||||
console.log(`[skip] ${entry.name}: no CLAUDE.md`);
|
||||
missingClaudeMd++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = fs.readFileSync(claudeMd, 'utf-8');
|
||||
const match = body.match(EXISTING_IMPORT_REGEX);
|
||||
|
||||
if (match && match[0] === IMPORT_LINE) {
|
||||
console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`);
|
||||
alreadyWired++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let newBody: string;
|
||||
if (match) {
|
||||
// Replace the broken import with the working form
|
||||
newBody = body.replace(EXISTING_IMPORT_REGEX, IMPORT_LINE);
|
||||
console.log(`[fix] ${entry.name}: rewrote ${match[0]} → ${IMPORT_LINE}`);
|
||||
} else {
|
||||
// Prepend fresh
|
||||
newBody = `${IMPORT_LINE}\n\n${body}`;
|
||||
console.log(`[ok] ${entry.name}: prepended ${IMPORT_LINE}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(claudeMd, newBody);
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd} symlinksCreated=${symlinksCreated}`,
|
||||
);
|
||||
@@ -58,12 +58,8 @@ try {
|
||||
id: 'mga-discord',
|
||||
messaging_group_id: MESSAGING_GROUP_ID,
|
||||
agent_group_id: AGENT_GROUP_ID,
|
||||
// Discord group channel → mention-sticky default. Mention once, stay
|
||||
// subscribed to the thread. Admins can tune via /manage-channels.
|
||||
engage_mode: 'mention-sticky',
|
||||
engage_pattern: null,
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -53,10 +53,8 @@ createMessagingGroupAgent({
|
||||
id: 'mga-chan',
|
||||
messaging_group_id: 'mg-chan',
|
||||
agent_group_id: 'ag-chan',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
@@ -107,15 +105,7 @@ registerChannelAdapter('mock', { factory: () => mockAdapter });
|
||||
|
||||
// Init channel adapters — this calls setup() with conversation configs from central DB
|
||||
await initChannelAdapters((adapter) => ({
|
||||
conversations: [
|
||||
{
|
||||
platformId: 'mock-channel-1',
|
||||
agentGroupId: 'ag-chan',
|
||||
engageMode: 'pattern',
|
||||
engagePattern: '.',
|
||||
sessionMode: 'shared',
|
||||
},
|
||||
],
|
||||
conversations: [{ platformId: 'mock-channel-1', agentGroupId: 'ag-chan', requiresTrigger: false, sessionMode: 'shared' }],
|
||||
onInbound(platformId, threadId, message) {
|
||||
routeInbound({
|
||||
channelType: adapter.channelType,
|
||||
|
||||
@@ -55,10 +55,8 @@ createMessagingGroupAgent({
|
||||
id: 'mga-e2e',
|
||||
messaging_group_id: 'mg-e2e',
|
||||
agent_group_id: 'ag-e2e',
|
||||
engage_mode: 'pattern',
|
||||
engage_pattern: '.',
|
||||
sender_scope: 'all',
|
||||
ignored_message_policy: 'drop',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all',
|
||||
session_mode: 'shared',
|
||||
priority: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
|
||||
@@ -6,17 +6,9 @@ set -euo pipefail
|
||||
# This is the only bash script in the setup flow.
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOG_FILE="$PROJECT_ROOT/logs/setup.log"
|
||||
|
||||
# Where verbose bootstrap logs go. nanoclaw.sh captures setup.sh's stdout to
|
||||
# the per-step raw log, but legacy code in this script + install-node.sh
|
||||
# also calls `log` which writes to a file. Route those to the raw log so
|
||||
# they don't contaminate the progression log (logs/setup.log).
|
||||
# Default: write to the raw bootstrap log if nanoclaw.sh pointed us there,
|
||||
# else fall back to a dedicated bootstrap log (keeps standalone `bash
|
||||
# setup.sh` invocations working).
|
||||
LOG_FILE="${NANOCLAW_BOOTSTRAP_LOG:-${PROJECT_ROOT}/logs/bootstrap.log}"
|
||||
|
||||
mkdir -p "$(dirname "$LOG_FILE")"
|
||||
mkdir -p "$PROJECT_ROOT/logs"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [bootstrap] $*" >> "$LOG_FILE"; }
|
||||
|
||||
@@ -80,50 +72,9 @@ install_deps() {
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Corepack's first-use "Do you want to continue? [Y/n]" prompt would hang
|
||||
# the script since we redirect stdout/stderr to the log file — the prompt
|
||||
# is invisible but corepack still blocks on stdin. Auto-accept.
|
||||
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
|
||||
# Preferred path: enable corepack so `pnpm` shim lands on PATH.
|
||||
if command -v corepack >/dev/null 2>&1; then
|
||||
log "Enabling corepack"
|
||||
corepack enable >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
# On Linux/WSL with system-wide Node (e.g. apt-installed to /usr/bin),
|
||||
# corepack needs root to symlink /usr/bin/pnpm. macOS Homebrew installs
|
||||
# land in a user-writable prefix, and a sudo retry there would create
|
||||
# root-owned shims inside /opt/homebrew that later break brew — so the
|
||||
# retry is Linux-only.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && [ "$PLATFORM" = "linux" ] \
|
||||
&& command -v sudo >/dev/null 2>&1; then
|
||||
log "pnpm not on PATH after corepack enable — retrying with sudo"
|
||||
sudo corepack enable >> "$LOG_FILE" 2>&1 || true
|
||||
fi
|
||||
else
|
||||
log "corepack not available — will fall back to npm-install pnpm"
|
||||
fi
|
||||
|
||||
# Fallback: some Node installs (older nvm, node@22 keg-only, minimal
|
||||
# distro packages) don't include corepack. Install pnpm directly at the
|
||||
# version pinned via package.json's `packageManager` field.
|
||||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||||
local pinned
|
||||
pinned=$(grep -E '"packageManager"' "$PROJECT_ROOT/package.json" 2>/dev/null \
|
||||
| head -1 \
|
||||
| sed -E 's/.*"pnpm@([^"]+)".*/\1/')
|
||||
[ -z "$pinned" ] && pinned="latest"
|
||||
log "Installing pnpm@${pinned} via npm"
|
||||
npm install -g "pnpm@${pinned}" >> "$LOG_FILE" 2>&1 \
|
||||
|| ([ "$PLATFORM" = "linux" ] && command -v sudo >/dev/null 2>&1 \
|
||||
&& sudo npm install -g "pnpm@${pinned}" >> "$LOG_FILE" 2>&1) \
|
||||
|| true
|
||||
fi
|
||||
|
||||
if ! command -v pnpm >/dev/null 2>&1; then
|
||||
log "pnpm not on PATH after corepack + npm fallback"
|
||||
return
|
||||
fi
|
||||
# Enable corepack for pnpm
|
||||
log "Enabling corepack"
|
||||
corepack enable >> "$LOG_FILE" 2>&1 || true
|
||||
|
||||
log "Running pnpm install --frozen-lockfile"
|
||||
if pnpm install --frozen-lockfile >> "$LOG_FILE" 2>&1; then
|
||||
@@ -169,16 +120,6 @@ log "=== Bootstrap started ==="
|
||||
detect_platform
|
||||
|
||||
check_node
|
||||
if [ "$NODE_OK" = "false" ]; then
|
||||
log "Node missing or too old — running setup/install-node.sh"
|
||||
echo "Node not found — installing via setup/install-node.sh"
|
||||
if bash "$PROJECT_ROOT/setup/install-node.sh" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
hash -r 2>/dev/null || true
|
||||
check_node
|
||||
else
|
||||
log "install-node.sh failed"
|
||||
fi
|
||||
fi
|
||||
install_deps
|
||||
check_build_tools
|
||||
|
||||
@@ -192,20 +133,11 @@ elif [ "$NATIVE_OK" = "false" ]; then
|
||||
STATUS="native_failed"
|
||||
fi
|
||||
|
||||
# Anonymous setup start event (non-blocking, best-effort). Uses the
|
||||
# persisted distinct_id from data/install-id so bash-side events and the
|
||||
# node-side funnel share one id.
|
||||
# shellcheck source=setup/lib/diagnostics.sh
|
||||
source "$PROJECT_ROOT/setup/lib/diagnostics.sh"
|
||||
ph_event setup_start \
|
||||
platform="$PLATFORM" \
|
||||
is_wsl="$IS_WSL" \
|
||||
is_root="$IS_ROOT" \
|
||||
node_version="$NODE_VERSION" \
|
||||
deps_ok="$DEPS_OK" \
|
||||
native_ok="$NATIVE_OK" \
|
||||
has_build_tools="$HAS_BUILD_TOOLS" \
|
||||
status="$STATUS"
|
||||
# Anonymous setup start event (non-blocking, best-effort)
|
||||
curl -sS --max-time 3 -X POST https://us.i.posthog.com/capture/ \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"api_key\":\"phc_fx1Hhx9ucz8GuaJC8LVZWO8u03yXZZJJ6ObS4yplnaP\",\"event\":\"setup_start\",\"distinct_id\":\"$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo unknown)\",\"properties\":{\"platform\":\"$PLATFORM\",\"is_wsl\":\"$IS_WSL\",\"is_root\":\"$IS_ROOT\",\"node_version\":\"$NODE_VERSION\",\"deps_ok\":\"$DEPS_OK\",\"native_ok\":\"$NATIVE_OK\",\"has_build_tools\":\"$HAS_BUILD_TOOLS\"}}" \
|
||||
>/dev/null 2>&1 &
|
||||
|
||||
cat <<EOF
|
||||
=== NANOCLAW SETUP: BOOTSTRAP ===
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Discord adapter, persist DISCORD_BOT_TOKEN / APPLICATION_ID /
|
||||
# PUBLIC_KEY to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing "Create a bot" walkthrough, owner confirmation, and
|
||||
# server-invite step live in setup/channels/discord.ts. Credentials come in via
|
||||
# env vars: DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, DISCORD_PUBLIC_KEY.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_DISCORD) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the full
|
||||
# story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-discord/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/discord@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_DISCORD ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-discord] $*" >&2; }
|
||||
|
||||
if [ -z "${DISCORD_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "DISCORD_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_APPLICATION_ID:-}" ]; then
|
||||
emit_status failed "DISCORD_APPLICATION_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${DISCORD_PUBLIC_KEY:-}" ]; then
|
||||
emit_status failed "DISCORD_PUBLIC_KEY env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/discord.ts ] && return 0
|
||||
! grep -q "^import './discord.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/discord.ts" > src/channels/discord.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './discord.js';" src/channels/index.ts; then
|
||||
echo "import './discord.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials. auto.ts validates before this point, so bad values here
|
||||
# would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env DISCORD_BOT_TOKEN "$DISCORD_BOT_TOKEN"
|
||||
upsert_env DISCORD_APPLICATION_ID "$DISCORD_APPLICATION_ID"
|
||||
upsert_env DISCORD_PUBLIC_KEY "$DISCORD_PUBLIC_KEY"
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Discord adapter a moment to finish gateway handshake before
|
||||
# init-first-agent attempts delivery.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -1,131 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Teams adapter, persist TEAMS_APP_ID / _PASSWORD / _TENANT_ID /
|
||||
# _TYPE to .env + data/env/env, and restart the service. Non-interactive —
|
||||
# the operator-facing Azure portal walkthroughs live in
|
||||
# setup/channels/teams.ts. Credentials come in via env vars:
|
||||
# TEAMS_APP_ID (required)
|
||||
# TEAMS_APP_PASSWORD (required — client secret value from Azure)
|
||||
# TEAMS_APP_TYPE (required — SingleTenant | MultiTenant)
|
||||
# TEAMS_APP_TENANT_ID (required when type=SingleTenant)
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_TEAMS) at the end. All chatty
|
||||
# progress messages go to stderr so setup:auto's raw-log capture sees the
|
||||
# full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-teams/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/teams@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_TEAMS ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-teams] $*" >&2; }
|
||||
|
||||
if [ -z "${TEAMS_APP_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_ID env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_PASSWORD:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_PASSWORD env var not set"
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TEAMS_APP_TYPE:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TYPE env var not set (SingleTenant|MultiTenant)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "${TEAMS_APP_TYPE}" = "SingleTenant" ] && [ -z "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
emit_status failed "TEAMS_APP_TENANT_ID required when TEAMS_APP_TYPE=SingleTenant"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/teams.ts ] && return 0
|
||||
! grep -q "^import './teams.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Copying adapter from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/teams.ts" > src/channels/teams.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './teams.js';" src/channels/index.ts; then
|
||||
echo "import './teams.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist credentials.
|
||||
touch .env
|
||||
upsert_env() {
|
||||
local key=$1 value=$2
|
||||
if grep -q "^${key}=" .env; then
|
||||
awk -v k="$key" -v v="$value" \
|
||||
'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "${key}=${value}" >> .env
|
||||
fi
|
||||
}
|
||||
upsert_env TEAMS_APP_ID "$TEAMS_APP_ID"
|
||||
upsert_env TEAMS_APP_PASSWORD "$TEAMS_APP_PASSWORD"
|
||||
upsert_env TEAMS_APP_TYPE "$TEAMS_APP_TYPE"
|
||||
if [ -n "${TEAMS_APP_TENANT_ID:-}" ]; then
|
||||
upsert_env TEAMS_APP_TENANT_ID "$TEAMS_APP_TENANT_ID"
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
log "Restarting service so the new adapter picks up the credentials…"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Teams adapter a moment to register its webhook before the driver
|
||||
# continues.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the Telegram adapter, persist the bot token to .env + data/env/env,
|
||||
# restart the service, and open the bot's chat page in the local Telegram
|
||||
# client. Non-interactive — the operator-facing "Create a bot" instructions
|
||||
# and token paste live in setup/auto.ts. The token comes in via the
|
||||
# TELEGRAM_BOT_TOKEN env var.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_TELEGRAM) at the end. All
|
||||
# chatty progress messages go to stderr so setup:auto's raw-log capture
|
||||
# sees the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-telegram/SKILL.md.
|
||||
ADAPTER_VERSION="@chat-adapter/telegram@4.26.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
local username=${BOT_USERNAME:-}
|
||||
echo "=== NANOCLAW SETUP: ADD_TELEGRAM ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_VERSION: ${ADAPTER_VERSION}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$username" ] && echo "BOT_USERNAME: ${username}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-telegram] $*" >&2; }
|
||||
|
||||
if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then
|
||||
emit_status failed "TELEGRAM_BOT_TOKEN env var not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$TELEGRAM_BOT_TOKEN" =~ ^[0-9]+:[A-Za-z0-9_-]{35,}$ ]]; then
|
||||
emit_status failed "token format invalid (expected <digits>:<chars>)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/telegram.ts ] && return 0
|
||||
! grep -q "^import './telegram.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# pair-telegram.ts is maintained in this branch (setup-auto), so it's NOT
|
||||
# in this list — do not overwrite the local version with the channels copy.
|
||||
log "Copying adapter files from ${CHANNELS_BRANCH}…"
|
||||
for f in \
|
||||
src/channels/telegram.ts \
|
||||
src/channels/telegram-pairing.ts \
|
||||
src/channels/telegram-pairing.test.ts \
|
||||
src/channels/telegram-markdown-sanitize.ts \
|
||||
src/channels/telegram-markdown-sanitize.test.ts
|
||||
do
|
||||
git show "${CHANNELS_BRANCH}:$f" > "$f"
|
||||
done
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './telegram.js';" src/channels/index.ts; then
|
||||
echo "import './telegram.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
# Register pair-telegram step if not already in the STEPS map.
|
||||
# Uses node (not sed) since sed's in-place + escape semantics differ
|
||||
# between BSD (macOS) and GNU.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = "setup/index.ts";
|
||||
let s = fs.readFileSync(p, "utf-8");
|
||||
if (!s.includes("\047pair-telegram\047")) {
|
||||
s = s.replace(
|
||||
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
|
||||
"$1\n \x27pair-telegram\x27: () => import(\x27./pair-telegram.js\x27),"
|
||||
);
|
||||
fs.writeFileSync(p, s);
|
||||
}
|
||||
'
|
||||
|
||||
log "Installing ${ADAPTER_VERSION}…"
|
||||
pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install ${ADAPTER_VERSION} failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter files already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# Persist token. auto.ts validates before this point, so a bad token here
|
||||
# would be an internal bug rather than operator input.
|
||||
touch .env
|
||||
if grep -q '^TELEGRAM_BOT_TOKEN=' .env; then
|
||||
awk -v tok="$TELEGRAM_BOT_TOKEN" \
|
||||
'/^TELEGRAM_BOT_TOKEN=/{print "TELEGRAM_BOT_TOKEN=" tok; next} {print}' \
|
||||
.env > .env.tmp && mv .env.tmp .env
|
||||
else
|
||||
echo "TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}" >> .env
|
||||
fi
|
||||
|
||||
# Look up the bot username (auto.ts already validated; we re-query here so
|
||||
# standalone invocations still work — BOT_USERNAME is emitted in the status
|
||||
# block for parent drivers to display).
|
||||
INFO=$(curl -fsS --max-time 8 \
|
||||
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" 2>/dev/null || true)
|
||||
BOT_USERNAME=""
|
||||
if echo "$INFO" | grep -q '"ok":true'; then
|
||||
BOT_USERNAME=$(echo "$INFO" | sed -nE 's/.*"username":"([^"]+)".*/\1/p')
|
||||
fi
|
||||
|
||||
# Container reads from data/env/env (the host mounts it).
|
||||
mkdir -p data/env
|
||||
cp .env data/env/env
|
||||
|
||||
# Browser/app deep-link is done by the parent driver (setup/channels/telegram.ts)
|
||||
# BEFORE this script runs — gated on a clack confirm so focus-stealing doesn't
|
||||
# surprise the user. Keeping it out of here means this script stays pure
|
||||
# non-interactive install.
|
||||
|
||||
log "Restarting service so the new adapter picks up the token…"
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
launchctl kickstart -k "gui/$(id -u)/com.nanoclaw" >&2 2>/dev/null || true
|
||||
;;
|
||||
Linux)
|
||||
systemctl --user restart nanoclaw >&2 2>/dev/null \
|
||||
|| sudo systemctl restart nanoclaw >&2 2>/dev/null \
|
||||
|| true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Give the Telegram adapter a moment to finish starting before pair-telegram
|
||||
# begins polling for the user's code message.
|
||||
sleep 5
|
||||
|
||||
emit_status success
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the native WhatsApp (Baileys) adapter and its whatsapp-auth + groups
|
||||
# setup steps. No credentials in env — WhatsApp uses linked-device auth, run
|
||||
# by the whatsapp-auth step as a separate process. The adapter's factory
|
||||
# returns null until store/auth/creds.json exists, so it's safe to install
|
||||
# this before auth runs; the driver restarts the service *after* auth
|
||||
# succeeds.
|
||||
#
|
||||
# Emits exactly one status block on stdout (ADD_WHATSAPP) at the end. All
|
||||
# chatty progress messages go to stderr so setup:auto's raw-log capture sees
|
||||
# the full story without cluttering the final block for the parser.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Keep in sync with .claude/skills/add-whatsapp/SKILL.md.
|
||||
BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16"
|
||||
QRCODE_VERSION="qrcode@1.5.4"
|
||||
QRCODE_TYPES_VERSION="@types/qrcode@1.5.6"
|
||||
PINO_VERSION="pino@9.6.0"
|
||||
CHANNELS_BRANCH="origin/channels"
|
||||
|
||||
emit_status() {
|
||||
local status=$1 error=${2:-}
|
||||
local already=${ADAPTER_ALREADY_INSTALLED:-false}
|
||||
echo "=== NANOCLAW SETUP: ADD_WHATSAPP ==="
|
||||
echo "STATUS: ${status}"
|
||||
echo "ADAPTER_ALREADY_INSTALLED: ${already}"
|
||||
[ -n "$error" ] && echo "ERROR: ${error}"
|
||||
echo "=== END ==="
|
||||
}
|
||||
|
||||
log() { echo "[add-whatsapp] $*" >&2; }
|
||||
|
||||
need_install() {
|
||||
[ ! -f src/channels/whatsapp.ts ] && return 0
|
||||
[ ! -f setup/groups.ts ] && return 0
|
||||
! grep -q "^import './whatsapp.js';" src/channels/index.ts 2>/dev/null && return 0
|
||||
! grep -q "'whatsapp-auth':" setup/index.ts 2>/dev/null && return 0
|
||||
! grep -q "^ groups:" setup/index.ts 2>/dev/null && return 0
|
||||
return 1
|
||||
}
|
||||
|
||||
ADAPTER_ALREADY_INSTALLED=true
|
||||
if need_install; then
|
||||
ADAPTER_ALREADY_INSTALLED=false
|
||||
log "Fetching channels branch…"
|
||||
git fetch origin channels >&2 2>/dev/null || {
|
||||
emit_status failed "git fetch origin channels failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# whatsapp-auth.ts is maintained in this branch (setup-auto) — do not copy
|
||||
# from channels. Matches the pair-telegram.ts pattern.
|
||||
log "Copying adapter + group step from ${CHANNELS_BRANCH}…"
|
||||
git show "${CHANNELS_BRANCH}:src/channels/whatsapp.ts" > src/channels/whatsapp.ts
|
||||
git show "${CHANNELS_BRANCH}:setup/groups.ts" > setup/groups.ts
|
||||
|
||||
# Append self-registration import if missing.
|
||||
if ! grep -q "^import './whatsapp.js';" src/channels/index.ts; then
|
||||
echo "import './whatsapp.js';" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
# Register the setup steps in setup/index.ts's STEPS map. node (not sed) —
|
||||
# sed's in-place + escape semantics differ between BSD (macOS) and GNU.
|
||||
node -e '
|
||||
const fs = require("fs");
|
||||
const p = "setup/index.ts";
|
||||
let s = fs.readFileSync(p, "utf-8");
|
||||
let changed = false;
|
||||
if (!s.includes("\047whatsapp-auth\047:")) {
|
||||
s = s.replace(
|
||||
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
|
||||
"$1\n \x27whatsapp-auth\x27: () => import(\x27./whatsapp-auth.js\x27),"
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (!/^\s*groups:\s/m.test(s)) {
|
||||
s = s.replace(
|
||||
/(register: \(\) => import\(\x27\.\/register\.js\x27\),)/,
|
||||
"$1\n groups: () => import(\x27./groups.js\x27),"
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) fs.writeFileSync(p, s);
|
||||
'
|
||||
|
||||
log "Installing Baileys + QR + pino (pinned)…"
|
||||
pnpm install \
|
||||
"${BAILEYS_VERSION}" \
|
||||
"${QRCODE_VERSION}" \
|
||||
"${QRCODE_TYPES_VERSION}" \
|
||||
"${PINO_VERSION}" \
|
||||
>&2 2>/dev/null || {
|
||||
emit_status failed "pnpm install failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
log "Building…"
|
||||
pnpm run build >&2 2>/dev/null || {
|
||||
emit_status failed "pnpm run build failed"
|
||||
exit 1
|
||||
}
|
||||
else
|
||||
log "Adapter + setup steps already installed — skipping install phase."
|
||||
fi
|
||||
|
||||
# No service restart here — the adapter factory returns null without
|
||||
# store/auth/creds.json, so restarting now would no-op. The driver restarts
|
||||
# the service AFTER whatsapp-auth completes so the adapter picks up creds.
|
||||
|
||||
emit_status success
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* Step: auth — Verify or register an Anthropic credential in OneCLI.
|
||||
*
|
||||
* Modes:
|
||||
* --check (default) Verify an Anthropic secret exists.
|
||||
* --create --value <token> Create an Anthropic secret. Errors if one
|
||||
* already exists unless --force is passed.
|
||||
*
|
||||
* The actual user-facing prompt (subscription vs API key, paste the token)
|
||||
* stays in the /new-setup SKILL.md. This step is just the machine side:
|
||||
* it calls `onecli secrets list` / `onecli secrets create` and emits a
|
||||
* structured status block. The token value is never logged.
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
const LOCAL_BIN = path.join(os.homedir(), '.local', 'bin');
|
||||
|
||||
interface Args {
|
||||
mode: 'check' | 'create';
|
||||
value?: string;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
function childEnv(): NodeJS.ProcessEnv {
|
||||
const parts = [LOCAL_BIN];
|
||||
if (process.env.PATH) parts.push(process.env.PATH);
|
||||
return { ...process.env, PATH: parts.join(path.delimiter) };
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): Args {
|
||||
let mode: 'check' | 'create' = 'check';
|
||||
let value: string | undefined;
|
||||
let force = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
const val = args[i + 1];
|
||||
switch (key) {
|
||||
case '--check':
|
||||
mode = 'check';
|
||||
break;
|
||||
case '--create':
|
||||
mode = 'create';
|
||||
break;
|
||||
case '--value':
|
||||
value = val;
|
||||
i++;
|
||||
break;
|
||||
case '--force':
|
||||
force = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'create' && !value) {
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_value_for_create',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { mode, value, force };
|
||||
}
|
||||
|
||||
interface OnecliSecret {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
hostPattern: string | null;
|
||||
}
|
||||
|
||||
function listSecrets(): OnecliSecret[] {
|
||||
const out = execFileSync('onecli', ['secrets', 'list'], {
|
||||
encoding: 'utf-8',
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const parsed = JSON.parse(out) as { data?: unknown };
|
||||
return Array.isArray(parsed.data) ? (parsed.data as OnecliSecret[]) : [];
|
||||
}
|
||||
|
||||
function findAnthropicSecret(secrets: OnecliSecret[]): OnecliSecret | undefined {
|
||||
return secrets.find((s) => s.type === 'anthropic');
|
||||
}
|
||||
|
||||
function createAnthropicSecret(value: string): void {
|
||||
// `value` is a credential — do not log it, do not echo, do not pass through a shell.
|
||||
execFileSync(
|
||||
'onecli',
|
||||
[
|
||||
'secrets',
|
||||
'create',
|
||||
'--name',
|
||||
'Anthropic',
|
||||
'--type',
|
||||
'anthropic',
|
||||
'--value',
|
||||
value,
|
||||
'--host-pattern',
|
||||
'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
env: childEnv(),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { mode, value, force } = parseArgs(args);
|
||||
|
||||
let secrets: OnecliSecret[];
|
||||
try {
|
||||
secrets = listSecrets();
|
||||
} catch (err) {
|
||||
log.error('onecli secrets list failed', { err });
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_list_failed',
|
||||
HINT: 'Is OneCLI running? Run `/new-setup` from the onecli step.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = findAnthropicSecret(secrets);
|
||||
|
||||
if (mode === 'check') {
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: !!existing,
|
||||
ANTHROPIC_OK: !!existing,
|
||||
STATUS: existing ? 'success' : 'missing',
|
||||
...(existing ? { SECRET_NAME: existing.name, SECRET_ID: existing.id } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// mode === 'create'
|
||||
if (existing && !force) {
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: true,
|
||||
STATUS: 'skipped',
|
||||
REASON: 'anthropic_secret_already_exists',
|
||||
SECRET_NAME: existing.name,
|
||||
SECRET_ID: existing.id,
|
||||
HINT: 'Re-run with --force to replace, or delete the existing secret first.',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
createAnthropicSecret(value!);
|
||||
} catch (err) {
|
||||
const e = err as { stderr?: string | Buffer; status?: number };
|
||||
const stderr = typeof e.stderr === 'string' ? e.stderr : e.stderr?.toString('utf-8') ?? '';
|
||||
log.error('onecli secrets create failed', { status: e.status, stderr });
|
||||
emitStatus('AUTH', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'onecli_create_failed',
|
||||
EXIT_CODE: e.status ?? -1,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Re-verify
|
||||
const updated = findAnthropicSecret(listSecrets());
|
||||
|
||||
emitStatus('AUTH', {
|
||||
SECRET_PRESENT: !!updated,
|
||||
ANTHROPIC_OK: !!updated,
|
||||
CREATED: true,
|
||||
STATUS: updated ? 'success' : 'failed',
|
||||
...(updated ? { SECRET_NAME: updated.name, SECRET_ID: updated.id } : {}),
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
-782
@@ -1,782 +0,0 @@
|
||||
/**
|
||||
* Non-interactive setup driver — the step sequencer for `pnpm run setup:auto`.
|
||||
*
|
||||
* Responsibility: orchestrate the sequence of steps end-to-end and route
|
||||
* between them. The runner, spawning, status parsing, spinner, abort, and
|
||||
* prompt primitives live in `setup/lib/runner.ts`; theming in
|
||||
* `setup/lib/theme.ts`; Telegram's full flow in `setup/channels/telegram.ts`.
|
||||
*
|
||||
* Config via env:
|
||||
* NANOCLAW_DISPLAY_NAME how the agents address the operator — skips the
|
||||
* prompt. Defaults to $USER.
|
||||
* NANOCLAW_AGENT_NAME messaging-channel agent name (consumed by the
|
||||
* channel flow). The CLI scratch agent is always
|
||||
* "Terminal Agent".
|
||||
* NANOCLAW_SKIP comma-separated step names to skip
|
||||
* (environment|container|onecli|auth|mounts|
|
||||
* service|cli-agent|timezone|channel|verify|
|
||||
* first-chat)
|
||||
*
|
||||
* Timezone is auto-detected after the CLI agent step. UTC resolves are
|
||||
* confirmed with the user, and free-text replies fall through to a
|
||||
* headless `claude -p` call for IANA-zone resolution.
|
||||
*/
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { runDiscordChannel } from './channels/discord.js';
|
||||
import { runTeamsChannel } from './channels/teams.js';
|
||||
import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import {
|
||||
claudeCliAvailable,
|
||||
resolveTimezoneViaClaude,
|
||||
} from './lib/tz-from-claude.js';
|
||||
import * as setupLog from './logs.js';
|
||||
import { ensureAnswer, fail, runQuietChild, runQuietStep } from './lib/runner.js';
|
||||
import { emit as phEmit } from './lib/diagnostics.js';
|
||||
import { brandBold, brandChip, dimWrap, fitToWidth, wrapForGutter } from './lib/theme.js';
|
||||
import { isValidTimezone } from '../src/timezone.js';
|
||||
|
||||
const CLI_AGENT_NAME = 'Terminal Agent';
|
||||
const RUN_START = Date.now();
|
||||
|
||||
async function main(): Promise<void> {
|
||||
printIntro();
|
||||
initProgressionLog();
|
||||
phEmit('auto_started');
|
||||
|
||||
const skip = new Set(
|
||||
(process.env.NANOCLAW_SKIP ?? '')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
if (!skip.has('environment')) {
|
||||
const res = await runQuietStep('environment', {
|
||||
running: 'Checking your system…',
|
||||
done: 'Your system looks good.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'environment',
|
||||
"Your system doesn't look quite right.",
|
||||
'See logs/setup-steps/ for details, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('container')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant lives in its own sandbox. It can only see what you explicitly share.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('container', {
|
||||
running: "Preparing your assistant's sandbox…",
|
||||
done: 'Sandbox ready.',
|
||||
failed: "Couldn't prepare the sandbox.",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'runtime_not_available') {
|
||||
await fail(
|
||||
'container',
|
||||
"Docker isn't available.",
|
||||
'Install Docker Desktop (or start it if already installed), then retry.',
|
||||
);
|
||||
}
|
||||
if (err === 'docker_group_not_active') {
|
||||
await fail(
|
||||
'container',
|
||||
"Docker was just installed but your shell doesn't know yet.",
|
||||
'Log out and back in (or run `newgrp docker` in a new shell), then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'container',
|
||||
"Couldn't build the sandbox.",
|
||||
'If Docker has a stale cache, try: `docker builder prune -f`, then retry.',
|
||||
);
|
||||
}
|
||||
maybeReexecUnderSg();
|
||||
}
|
||||
|
||||
if (!skip.has('onecli')) {
|
||||
p.log.message(
|
||||
dimWrap(
|
||||
'Your assistant never gets your API keys directly. The vault adds them to approved requests as they leave the sandbox.',
|
||||
4,
|
||||
),
|
||||
);
|
||||
const res = await runQuietStep('onecli', {
|
||||
running: "Setting up OneCLI, your agent's vault…",
|
||||
done: 'OneCLI vault ready.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = res.terminal?.fields.ERROR;
|
||||
if (err === 'onecli_not_on_path_after_install') {
|
||||
await fail(
|
||||
'onecli',
|
||||
'OneCLI was installed but your shell needs to refresh to see it.',
|
||||
'Open a new shell or run `export PATH="$HOME/.local/bin:$PATH"`, then retry.',
|
||||
);
|
||||
}
|
||||
await fail(
|
||||
'onecli',
|
||||
`Couldn't set up OneCLI (${err ?? 'unknown error'}).`,
|
||||
'Make sure curl is installed and ~/.local/bin is writable, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('auth')) {
|
||||
await runAuthStep();
|
||||
}
|
||||
|
||||
if (!skip.has('mounts')) {
|
||||
const res = await runQuietStep(
|
||||
'mounts',
|
||||
{
|
||||
running: "Setting your assistant's access rules…",
|
||||
done: 'Access rules set.',
|
||||
skipped: 'Access rules already set.',
|
||||
},
|
||||
['--empty'],
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail('mounts', "Couldn't write access rules.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('service')) {
|
||||
const res = await runQuietStep('service', {
|
||||
running: 'Starting NanoClaw in the background…',
|
||||
done: 'NanoClaw is running.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'service',
|
||||
"Couldn't start NanoClaw.",
|
||||
'See logs/nanoclaw.error.log for details.',
|
||||
);
|
||||
}
|
||||
if (res.terminal?.fields.DOCKER_GROUP_STALE === 'true') {
|
||||
p.log.warn(
|
||||
"NanoClaw's permissions need a tweak before it can reach Docker.",
|
||||
);
|
||||
p.log.message(
|
||||
k.dim(
|
||||
' sudo setfacl -m u:$(whoami):rw /var/run/docker.sock\n' +
|
||||
' systemctl --user restart nanoclaw',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let displayName: string | undefined;
|
||||
const needsDisplayName = !skip.has('cli-agent') || !skip.has('channel');
|
||||
if (needsDisplayName) {
|
||||
const fallback = process.env.USER?.trim() || 'Operator';
|
||||
const preset = process.env.NANOCLAW_DISPLAY_NAME?.trim();
|
||||
displayName = preset || (await askDisplayName(fallback));
|
||||
}
|
||||
|
||||
if (!skip.has('cli-agent')) {
|
||||
const res = await runQuietStep(
|
||||
'cli-agent',
|
||||
{
|
||||
running: 'Bringing your assistant online…',
|
||||
done: 'Assistant wired up.',
|
||||
},
|
||||
['--display-name', displayName!, '--agent-name', CLI_AGENT_NAME],
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'cli-agent',
|
||||
"Couldn't bring your assistant online.",
|
||||
`You can retry later with \`pnpm exec tsx scripts/init-cli-agent.ts --display-name "${displayName!}" --agent-name "${CLI_AGENT_NAME}"\`.`,
|
||||
);
|
||||
}
|
||||
if (!skip.has('first-chat')) {
|
||||
const ping = await confirmAssistantResponds();
|
||||
if (ping === 'ok') {
|
||||
phEmit('first_chat_ready');
|
||||
await runFirstChat();
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
await offerClaudeAssist({
|
||||
stepName: 'cli-agent',
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
? "NanoClaw service isn't listening on its CLI socket."
|
||||
: "No reply from the assistant within 30 seconds.",
|
||||
hint:
|
||||
ping === 'socket_error'
|
||||
? 'Socket at data/cli.sock did not accept a connection.'
|
||||
: 'Agent container may be failing to start or authenticate.',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('timezone')) {
|
||||
await runTimezoneStep();
|
||||
}
|
||||
|
||||
if (!skip.has('channel')) {
|
||||
const choice = await askChannelChoice();
|
||||
if (choice === 'telegram') {
|
||||
await runTelegramChannel(displayName!);
|
||||
} else if (choice === 'discord') {
|
||||
await runDiscordChannel(displayName!);
|
||||
} else if (choice === 'whatsapp') {
|
||||
await runWhatsAppChannel(displayName!);
|
||||
} else if (choice === 'teams') {
|
||||
await runTeamsChannel(displayName!);
|
||||
} else {
|
||||
p.log.info(
|
||||
wrapForGutter(
|
||||
'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).',
|
||||
4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!skip.has('verify')) {
|
||||
const res = await runQuietStep('verify', {
|
||||
running: 'Making sure everything works together…',
|
||||
done: "Everything's connected.",
|
||||
failed: 'A few things still need your attention.',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const notes: string[] = [];
|
||||
if (res.terminal?.fields.CREDENTIALS !== 'configured') {
|
||||
notes.push('• Your Claude account isn\'t connected. Re-run setup and try again.');
|
||||
}
|
||||
const service = res.terminal?.fields.SERVICE;
|
||||
if (service === 'running_other_checkout') {
|
||||
notes.push(
|
||||
wrapForGutter(
|
||||
[
|
||||
'• Your NanoClaw service is running from a different folder on this machine.',
|
||||
' Point it at this checkout with:',
|
||||
' launchctl bootout gui/$(id -u)/com.nanoclaw',
|
||||
' launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.nanoclaw.plist',
|
||||
].join('\n'),
|
||||
6,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const agentPing = res.terminal?.fields.AGENT_PING;
|
||||
if (agentPing && agentPing !== 'ok' && agentPing !== 'skipped') {
|
||||
notes.push(
|
||||
"• Your assistant didn't reply to a test message. " +
|
||||
'Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!res.terminal?.fields.CONFIGURED_CHANNELS) {
|
||||
notes.push('• Want to chat from your phone? Add a messaging app with `/add-telegram`, `/add-slack`, or `/add-discord`.');
|
||||
}
|
||||
if (notes.length > 0) {
|
||||
p.note(notes.join('\n'), "What's left");
|
||||
}
|
||||
// "What's left" is a soft failure — we don't abort like fail(), but the
|
||||
// user is still stuck and a fix is exactly what claude-assist is for.
|
||||
const summary = notes
|
||||
.map((n) => n.replace(/^•\s*/, '').split('\n')[0].trim())
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
phEmit('setup_incomplete', {
|
||||
unresolved_count: notes.length,
|
||||
service_running: res.terminal?.fields.SERVICE === 'running',
|
||||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||||
agent_responds: res.terminal?.fields.AGENT_PING === 'ok',
|
||||
});
|
||||
await offerClaudeAssist({
|
||||
stepName: 'verify',
|
||||
msg: summary || 'Verification completed with unresolved issues.',
|
||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||
rawLogPath: res.rawLog,
|
||||
});
|
||||
p.outro(k.yellow('Almost there. A few things still need your attention.'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rows: [string, string][] = [
|
||||
['Chat in the terminal:', 'pnpm run chat hi'],
|
||||
["See what's happening:", 'tail -f logs/nanoclaw.log'],
|
||||
['Open Claude Code:', 'claude'],
|
||||
];
|
||||
const labelWidth = Math.max(...rows.map(([l]) => l.length));
|
||||
const nextSteps = rows
|
||||
.map(([l, c]) => `${k.cyan(l.padEnd(labelWidth))} ${c}`)
|
||||
.join('\n');
|
||||
p.note(nextSteps, 'Try these');
|
||||
setupLog.complete(Date.now() - RUN_START);
|
||||
phEmit('setup_completed', { duration_ms: Date.now() - RUN_START });
|
||||
p.outro(k.green("You're ready! Enjoy NanoClaw."));
|
||||
}
|
||||
|
||||
// ─── first-chat step ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Round-trip ping against the CLI socket before we ask the user to chat.
|
||||
* Renders its own spinner with elapsed time because a cold-start container
|
||||
* boot can take 30–60s — the elapsed counter is the difference between
|
||||
* "patient" and "is this hung?". Returns the raw result so the caller can
|
||||
* branch between the chat loop (ok) and a diagnostic note (anything else).
|
||||
*/
|
||||
async function confirmAssistantResponds(): Promise<PingResult> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
const label = 'Waking your assistant…';
|
||||
s.start(fitToWidth(label, ' (999s)'));
|
||||
const tick = setInterval(() => {
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
s.message(`${fitToWidth(label, suffix)}${k.dim(suffix)}`);
|
||||
}, 1000);
|
||||
|
||||
const result = await pingCliAgent();
|
||||
|
||||
clearInterval(tick);
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
const suffix = ` (${elapsed}s)`;
|
||||
if (result === 'ok') {
|
||||
s.stop(`${fitToWidth('Your assistant is ready.', suffix)}${k.dim(suffix)}`);
|
||||
} else {
|
||||
const msg =
|
||||
result === 'socket_error'
|
||||
? "Couldn't reach the NanoClaw service."
|
||||
: "Your assistant didn't reply in time.";
|
||||
s.stop(`${fitToWidth(msg, suffix)}${k.dim(suffix)}`, 1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function renderPingFailureNote(result: PingResult): void {
|
||||
const body =
|
||||
result === 'socket_error'
|
||||
? [
|
||||
wrapForGutter(
|
||||
"The NanoClaw service isn't listening on its local socket. Try restarting it, then chat with `pnpm run chat hi`:",
|
||||
6,
|
||||
),
|
||||
'',
|
||||
k.dim(' macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw'),
|
||||
k.dim(' Linux: systemctl --user restart nanoclaw'),
|
||||
].join('\n')
|
||||
: wrapForGutter(
|
||||
'No reply from your assistant within 30 seconds. Check `logs/nanoclaw.log` for clues, then try `pnpm run chat hi`.',
|
||||
6,
|
||||
);
|
||||
p.note(body, 'Skipping the first chat');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat loop. Each message is piped through `pnpm run chat`, which uses
|
||||
* the same Unix-socket path the ping just exercised, so output streams
|
||||
* back inline as the agent replies. An empty input ends the loop.
|
||||
*/
|
||||
async function runFirstChat(): Promise<void> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Say something to your assistant',
|
||||
placeholder: 'press Enter with nothing to continue',
|
||||
}),
|
||||
);
|
||||
const text = ((answer as string | undefined) ?? '').trim();
|
||||
if (!text) return;
|
||||
await sendChatMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
function sendChatMessage(message: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
// `pnpm --silent` suppresses the `> nanoclaw@… chat` preamble so the
|
||||
// agent's reply reads as a clean block under the prompt. Splitting on
|
||||
// whitespace mirrors `pnpm run chat hello world` — chat.ts joins argv
|
||||
// with spaces on the far side.
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['--silent', 'run', 'chat', ...message.split(/\s+/)],
|
||||
{ stdio: ['ignore', 'inherit', 'inherit'] },
|
||||
);
|
||||
child.on('close', () => resolve());
|
||||
child.on('error', () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
// ─── auth step (select → branch) ────────────────────────────────────────
|
||||
|
||||
async function runAuthStep(): Promise<void> {
|
||||
if (anthropicSecretExists()) {
|
||||
p.log.success('Your Claude account is already connected.');
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'secret-already-present' });
|
||||
return;
|
||||
}
|
||||
|
||||
const method = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'How would you like to connect to Claude?',
|
||||
options: [
|
||||
{
|
||||
value: 'subscription',
|
||||
label: 'Sign in with my Claude subscription',
|
||||
hint: 'recommended if you have Pro or Max',
|
||||
},
|
||||
{
|
||||
value: 'oauth',
|
||||
label: 'Paste an OAuth token I already have',
|
||||
hint: 'sk-ant-oat…',
|
||||
},
|
||||
{
|
||||
value: 'api',
|
||||
label: 'Paste an Anthropic API key',
|
||||
hint: 'pay-per-use via console.anthropic.com',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'subscription' | 'oauth' | 'api';
|
||||
setupLog.userInput('auth_method', method);
|
||||
phEmit('auth_method_chosen', { method });
|
||||
|
||||
if (method === 'subscription') {
|
||||
await runSubscriptionAuth();
|
||||
} else {
|
||||
await runPasteAuth(method);
|
||||
}
|
||||
}
|
||||
|
||||
async function runSubscriptionAuth(): Promise<void> {
|
||||
p.log.step("Opening the Claude sign-in flow…");
|
||||
console.log(
|
||||
k.dim(' (a browser will open for sign-in; this part is interactive)'),
|
||||
);
|
||||
console.log();
|
||||
const start = Date.now();
|
||||
const code = await runInheritScript('bash', [
|
||||
'setup/register-claude-token.sh',
|
||||
]);
|
||||
const durationMs = Date.now() - start;
|
||||
console.log();
|
||||
if (code !== 0) {
|
||||
setupLog.step('auth', 'failed', durationMs, {
|
||||
EXIT_CODE: code,
|
||||
METHOD: 'subscription',
|
||||
});
|
||||
await fail(
|
||||
'auth',
|
||||
"Couldn't complete the Claude sign-in.",
|
||||
'Re-run setup and try again, or choose a paste option instead.',
|
||||
);
|
||||
}
|
||||
setupLog.step('auth', 'interactive', durationMs, { METHOD: 'subscription' });
|
||||
p.log.success('Claude account connected.');
|
||||
}
|
||||
|
||||
async function runPasteAuth(method: 'oauth' | 'api'): Promise<void> {
|
||||
const label = method === 'oauth' ? 'OAuth token' : 'API key';
|
||||
const prefix = method === 'oauth' ? 'sk-ant-oat' : 'sk-ant-api';
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: `Paste your ${label}`,
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return 'Required';
|
||||
if (!v.trim().startsWith(prefix)) {
|
||||
return `Should start with ${prefix}…`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
|
||||
const res = await runQuietChild(
|
||||
'auth',
|
||||
'onecli',
|
||||
[
|
||||
'secrets', 'create',
|
||||
'--name', 'Anthropic',
|
||||
'--type', 'anthropic',
|
||||
'--value', token,
|
||||
'--host-pattern', 'api.anthropic.com',
|
||||
],
|
||||
{
|
||||
running: `Saving your ${label} to your OneCLI vault…`,
|
||||
done: 'Claude account connected.',
|
||||
},
|
||||
{
|
||||
extraFields: { METHOD: method },
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
await fail(
|
||||
'auth',
|
||||
`Couldn't save your ${label} to the vault.`,
|
||||
'Make sure OneCLI is running (`onecli version`), then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── timezone step ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Auto-detect TZ, confirm with the user when it comes back as UTC (a
|
||||
* common sign we're on a VPS that wasn't localised), and persist through
|
||||
* the usual `--step timezone -- --tz <zone>` path. Free-text answers get
|
||||
* a headless `claude -p` pass to resolve them to a real IANA zone.
|
||||
*/
|
||||
async function runTimezoneStep(): Promise<void> {
|
||||
const res = await runQuietStep('timezone', {
|
||||
running: 'Checking your timezone…',
|
||||
done: 'Timezone set.',
|
||||
});
|
||||
if (!res.ok && res.terminal?.fields.NEEDS_USER_INPUT !== 'true') {
|
||||
await fail('timezone', "Couldn't determine your timezone.");
|
||||
}
|
||||
|
||||
const fields = res.terminal?.fields ?? {};
|
||||
const resolvedTz = fields.RESOLVED_TZ;
|
||||
const needsInput = fields.NEEDS_USER_INPUT === 'true';
|
||||
const isUtc =
|
||||
resolvedTz === 'UTC' ||
|
||||
resolvedTz === 'Etc/UTC' ||
|
||||
resolvedTz === 'Universal';
|
||||
|
||||
if (!needsInput && !isUtc && resolvedTz && resolvedTz !== 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Either autodetect failed outright, or it landed on UTC and we should
|
||||
// check that's really what the user wants before leaving it there.
|
||||
const message = needsInput
|
||||
? "Your system didn't expose a timezone. Which one are you in?"
|
||||
: "Your system reports UTC as the timezone. Is that right, or are you somewhere else?";
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message,
|
||||
options: needsInput
|
||||
? [
|
||||
{ value: 'answer', label: "I'll tell you where I am" },
|
||||
{ value: 'keep', label: 'Leave it as UTC' },
|
||||
]
|
||||
: [
|
||||
{ value: 'keep', label: 'Keep UTC', hint: 'remote server / happy with UTC' },
|
||||
{ value: 'answer', label: "I'm somewhere else" },
|
||||
],
|
||||
}),
|
||||
) as 'keep' | 'answer';
|
||||
setupLog.userInput('timezone_choice', choice);
|
||||
|
||||
if (choice === 'keep') return;
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: "Where are you? (city, region, or IANA zone)",
|
||||
placeholder: 'e.g. New York, London, Asia/Tokyo',
|
||||
validate: (v) => (v && v.trim() ? undefined : 'Required'),
|
||||
}),
|
||||
);
|
||||
const raw = (answer as string).trim();
|
||||
setupLog.userInput('timezone_input', raw);
|
||||
|
||||
let tz: string | null = isValidTimezone(raw) ? raw : null;
|
||||
if (!tz) {
|
||||
if (claudeCliAvailable()) {
|
||||
tz = await resolveTimezoneViaClaude(raw);
|
||||
} else {
|
||||
p.log.warn(
|
||||
wrapForGutter(
|
||||
"That's not a standard IANA zone and I can't call Claude to interpret it here — try again with a zone like `America/New_York` or `Europe/London`.",
|
||||
4,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tz) {
|
||||
// One retry with a direct-IANA ask; if that fails too, leave the
|
||||
// previously-detected value in .env and move on rather than looping.
|
||||
const retryAnswer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Enter an IANA timezone string',
|
||||
placeholder: 'e.g. America/New_York',
|
||||
validate: (v) => {
|
||||
const s = (v ?? '').trim();
|
||||
if (!s) return 'Required';
|
||||
if (!isValidTimezone(s)) return 'Not a valid IANA zone';
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
tz = (retryAnswer as string).trim();
|
||||
setupLog.userInput('timezone_retry', tz);
|
||||
}
|
||||
|
||||
const persist = await runQuietStep(
|
||||
'timezone',
|
||||
{
|
||||
running: `Saving timezone ${tz}…`,
|
||||
done: `Timezone set to ${tz}.`,
|
||||
},
|
||||
['--tz', tz],
|
||||
);
|
||||
if (!persist.ok) {
|
||||
await fail('timezone', `Couldn't save timezone ${tz}.`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── prompts owned by the sequencer ────────────────────────────────────
|
||||
|
||||
async function askDisplayName(fallback: string): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant call you?',
|
||||
placeholder: fallback,
|
||||
defaultValue: fallback,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || fallback;
|
||||
setupLog.userInput('display_name', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function askChannelChoice(): Promise<
|
||||
'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'
|
||||
> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Want to chat with your assistant from your phone?',
|
||||
options: [
|
||||
{ value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' },
|
||||
{ value: 'discord', label: 'Yes, connect Discord' },
|
||||
{ value: 'whatsapp', label: 'Yes, connect WhatsApp' },
|
||||
{ value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' },
|
||||
{ value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
setupLog.userInput('channel_choice', String(choice));
|
||||
phEmit('channel_chosen', { channel: String(choice) });
|
||||
return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip';
|
||||
}
|
||||
|
||||
// ─── interactive / env helpers ─────────────────────────────────────────
|
||||
|
||||
function anthropicSecretExists(): boolean {
|
||||
try {
|
||||
const res = spawnSync('onecli', ['secrets', 'list'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (res.status !== 0) return false;
|
||||
return /anthropic/i.test(res.stdout ?? '');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runInheritScript(cmd: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { stdio: 'inherit' });
|
||||
child.on('close', (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* After installing Docker, this process's supplementary groups are still
|
||||
* frozen from login — subsequent steps that talk to /var/run/docker.sock
|
||||
* (onecli install, service start, …) fail with EACCES even though the
|
||||
* daemon is up. Detect that and re-exec the whole driver under `sg docker`
|
||||
* so the rest of the run inherits the docker group without a re-login.
|
||||
*/
|
||||
function maybeReexecUnderSg(): void {
|
||||
if (process.env.NANOCLAW_REEXEC_SG === '1') return;
|
||||
if (process.platform !== 'linux') return;
|
||||
const info = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||||
if (info.status === 0) return;
|
||||
const err = `${info.stderr ?? ''}\n${info.stdout ?? ''}`;
|
||||
if (!/permission denied/i.test(err)) return;
|
||||
if (spawnSync('which', ['sg'], { stdio: 'ignore' }).status !== 0) return;
|
||||
|
||||
p.log.warn('Docker socket not accessible in current group. Re-executing under `sg docker`.');
|
||||
const res = spawnSync('sg', ['docker', '-c', 'pnpm run setup:auto'], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, NANOCLAW_REEXEC_SG: '1' },
|
||||
});
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
// ─── intro + progression-log init ──────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
const isReexec = process.env.NANOCLAW_REEXEC_SG === '1';
|
||||
const wordmark = `${k.bold('Nano')}${brandBold('Claw')}`;
|
||||
|
||||
if (isReexec) {
|
||||
p.intro(
|
||||
`${brandChip(' Welcome ')} ${wordmark} ${k.dim('· picking up where we left off')}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always include the wordmark inside the clack intro line. When bash ran
|
||||
// first (NANOCLAW_BOOTSTRAPPED=1) it already printed its own wordmark
|
||||
// above us; the small repeat is worth it to keep the brand anchored at
|
||||
// the visible top of the clack session once the bash output scrolls away.
|
||||
p.intro(`${wordmark} ${k.dim("Let's get you set up.")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap (nanoclaw.sh) normally initializes logs/setup.log and writes
|
||||
* the bootstrap entry before we even boot. If someone runs `pnpm run
|
||||
* setup:auto` directly, start a fresh progression log here so we don't
|
||||
* append to a stale one from a previous run.
|
||||
*/
|
||||
function initProgressionLog(): void {
|
||||
if (process.env.NANOCLAW_BOOTSTRAPPED === '1') return;
|
||||
let commit = '';
|
||||
try {
|
||||
commit = spawnSync('git', ['rev-parse', '--short', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
}).stdout.trim();
|
||||
} catch {
|
||||
// git not available or not a repo — skip
|
||||
}
|
||||
let branch = '';
|
||||
try {
|
||||
branch = spawnSync('git', ['branch', '--show-current'], {
|
||||
encoding: 'utf-8',
|
||||
}).stdout.trim();
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
setupLog.reset({
|
||||
invocation: 'setup:auto (standalone)',
|
||||
user: process.env.USER ?? 'unknown',
|
||||
cwd: process.cwd(),
|
||||
branch: branch || 'unknown',
|
||||
commit: commit || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
p.log.error(err instanceof Error ? err.message : String(err));
|
||||
p.cancel('Setup aborted.');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,445 +0,0 @@
|
||||
/**
|
||||
* Discord channel flow for setup:auto.
|
||||
*
|
||||
* `runDiscordChannel(displayName)` owns the full branch from "do you have a
|
||||
* bot?" through the welcome DM:
|
||||
*
|
||||
* 1. Ask if they have a bot already; walk them through Dev Portal creation
|
||||
* if not
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. GET /users/@me to confirm the token and resolve bot username
|
||||
* 4. GET /oauth2/applications/@me to derive application_id, verify_key
|
||||
* (public key), and owner — no separate paste needed in the common case
|
||||
* 5. Confirm owner identity (falls back to a manual user-id prompt with
|
||||
* Developer Mode instructions if declined or if the app is team-owned)
|
||||
* 6. Print the OAuth invite URL, open it, wait for "I've added the bot"
|
||||
* 7. Install the adapter via setup/add-discord.sh (non-interactive)
|
||||
* 8. POST /users/@me/channels to open the DM channel (yields dm channel id)
|
||||
* 9. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 10. Wire the agent via scripts/init-first-agent.ts, which sends the welcome
|
||||
* DM through the normal delivery path
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const DISCORD_API = 'https://discord.com/api/v10';
|
||||
|
||||
// Send Messages (0x800) + Add Reactions (0x40) + Attach Files (0x8000)
|
||||
// + Read Message History (0x10000) = 100416.
|
||||
// Matches the permissions set documented in .claude/skills/add-discord/SKILL.md.
|
||||
const INVITE_PERMISSIONS = '100416';
|
||||
|
||||
interface AppInfo {
|
||||
applicationId: string;
|
||||
publicKey: string;
|
||||
owner: { id: string; username: string } | null;
|
||||
}
|
||||
|
||||
export async function runDiscordChannel(displayName: string): Promise<void> {
|
||||
if (!(await askHasBotToken())) {
|
||||
await walkThroughBotCreation();
|
||||
}
|
||||
|
||||
const token = await collectDiscordToken();
|
||||
const botUsername = await validateDiscordToken(token);
|
||||
const app = await fetchApplicationInfo(token);
|
||||
|
||||
const ownerUserId = await resolveOwnerUserId(app.owner);
|
||||
|
||||
await promptInviteBot(app.applicationId, botUsername);
|
||||
|
||||
const install = await runQuietChild(
|
||||
'discord-install',
|
||||
'bash',
|
||||
['setup/add-discord.sh'],
|
||||
{
|
||||
running: `Connecting Discord to @${botUsername}…`,
|
||||
done: 'Discord connected.',
|
||||
},
|
||||
{
|
||||
env: {
|
||||
DISCORD_BOT_TOKEN: token,
|
||||
DISCORD_APPLICATION_ID: app.applicationId,
|
||||
DISCORD_PUBLIC_KEY: app.publicKey,
|
||||
},
|
||||
extraFields: {
|
||||
BOT_USERNAME: botUsername,
|
||||
APPLICATION_ID: app.applicationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'discord-install',
|
||||
"Couldn't connect Discord.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const dmChannelId = await openDmChannel(token, ownerUserId);
|
||||
const platformId = `discord:@me:${dmChannelId}`;
|
||||
|
||||
const role = await askOperatorRole('Discord');
|
||||
setupLog.userInput('discord_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'discord',
|
||||
'--user-id', `discord:${ownerUserId}`,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Discord DMs…`,
|
||||
done: `${agentName} is ready. Check Discord for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'discord',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'Most likely the bot and you don\'t share a server yet — invite the bot, then retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askHasBotToken(): Promise<boolean> {
|
||||
const answer = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Do you already have a Discord bot?',
|
||||
options: [
|
||||
{ value: 'yes', label: 'Yes, I have a bot token ready' },
|
||||
{ value: 'no', label: "No, walk me through creating one" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return answer === 'yes';
|
||||
}
|
||||
|
||||
async function walkThroughBotCreation(): Promise<void> {
|
||||
const url = 'https://discord.com/developers/applications';
|
||||
p.note(
|
||||
[
|
||||
"You'll create a Discord bot in the Developer Portal. It's free and takes about a minute.",
|
||||
'',
|
||||
' 1. Click "New Application", give it a name (e.g. "NanoClaw")',
|
||||
' 2. In the "Bot" tab, click "Reset Token" and copy the token',
|
||||
' 3. On the same tab, enable "Message Content Intent"',
|
||||
' (under Privileged Gateway Intents)',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
'Create a Discord bot',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "Got your bot token?",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function collectDiscordToken(): Promise<string> {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Token is required';
|
||||
// Discord bot tokens are base64url segments separated by dots.
|
||||
// Be lenient on length; the real check is /users/@me.
|
||||
if (!/^[A-Za-z0-9._-]{50,}$/.test(t)) {
|
||||
return "That doesn't look like a Discord bot token";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'discord_token',
|
||||
`${token.slice(0, 10)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateDiscordToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
username?: string;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (res.ok && data.username) {
|
||||
s.stop(`Found your bot: @${data.username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: data.username,
|
||||
BOT_ID: data.id ?? '',
|
||||
});
|
||||
return data.username;
|
||||
}
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Discord didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Discord didn't accept that token.",
|
||||
'Copy the token again from the Developer Portal and retry setup.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'discord-validate',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchApplicationInfo(token: string): Promise<AppInfo> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Looking up your bot application…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
});
|
||||
const data = (await res.json()) as {
|
||||
id?: string;
|
||||
verify_key?: string;
|
||||
owner?: { id: string; username: string } | null;
|
||||
team?: unknown;
|
||||
message?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id || !data.verify_key) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't read application info: ${reason}`, 1);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't read your Discord application details.",
|
||||
'Re-run setup. If it keeps failing, check the bot token has the right scopes.',
|
||||
);
|
||||
}
|
||||
s.stop(`Got your application details. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
// owner is populated for solo applications; team-owned apps return a
|
||||
// team object instead and we'll fall back to a manual user-id prompt.
|
||||
const owner =
|
||||
data.owner && data.owner.id && data.owner.username
|
||||
? { id: data.owner.id, username: data.owner.username }
|
||||
: null;
|
||||
setupLog.step('discord-app-info', 'success', Date.now() - start, {
|
||||
APPLICATION_ID: data.id,
|
||||
OWNER_USERNAME: owner?.username ?? '',
|
||||
TEAM_OWNED: data.team ? 'true' : 'false',
|
||||
});
|
||||
return {
|
||||
applicationId: data.id,
|
||||
publicKey: data.verify_key,
|
||||
owner,
|
||||
};
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-app-info', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'discord-app-info',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveOwnerUserId(
|
||||
owner: { id: string; username: string } | null,
|
||||
): Promise<string> {
|
||||
if (owner) {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: `Is @${owner.username} your Discord account?`,
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (confirmed === true) {
|
||||
setupLog.userInput('discord_owner_confirmed', owner.username);
|
||||
return owner.id;
|
||||
}
|
||||
} else {
|
||||
p.log.info(
|
||||
"Your bot is owned by a Developer Team, so we need your Discord user ID directly.",
|
||||
);
|
||||
}
|
||||
return await promptForUserIdWithDevMode();
|
||||
}
|
||||
|
||||
async function promptForUserIdWithDevMode(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"To get your Discord user ID:",
|
||||
'',
|
||||
' 1. Open Discord → Settings (⚙️) → Advanced',
|
||||
' 2. Turn on "Developer Mode"',
|
||||
' 3. Right-click your own name/avatar → "Copy User ID"',
|
||||
].join('\n'),
|
||||
'Find your Discord user ID',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your Discord user ID',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'User ID is required';
|
||||
if (!/^\d{17,20}$/.test(t)) {
|
||||
return "That doesn't look like a Discord user ID (17-20 digits)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const id = (answer as string).trim();
|
||||
setupLog.userInput('discord_user_id', id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function promptInviteBot(
|
||||
applicationId: string,
|
||||
botUsername: string,
|
||||
): Promise<void> {
|
||||
const url =
|
||||
`https://discord.com/api/oauth2/authorize` +
|
||||
`?client_id=${applicationId}` +
|
||||
`&scope=bot` +
|
||||
`&permissions=${INVITE_PERMISSIONS}`;
|
||||
|
||||
p.note(
|
||||
[
|
||||
`@${botUsername} needs to share a server with you before it can DM you.`,
|
||||
'',
|
||||
' 1. Pick any server you\'re in (a personal one is fine)',
|
||||
' 2. Click "Authorize"',
|
||||
'',
|
||||
k.dim(url),
|
||||
].join('\n'),
|
||||
'Add bot to a server',
|
||||
);
|
||||
await confirmThenOpen(url, 'Press Enter to open the invite page');
|
||||
|
||||
ensureAnswer(
|
||||
await p.confirm({
|
||||
message: "I've added the bot to a server",
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function openDmChannel(token: string, userId: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Opening a DM channel…');
|
||||
try {
|
||||
const res = await fetch(`${DISCORD_API}/users/@me/channels`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bot ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ recipient_id: userId }),
|
||||
});
|
||||
const data = (await res.json()) as { id?: string; message?: string };
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (!res.ok || !data.id) {
|
||||
const reason = data.message ?? `HTTP ${res.status}`;
|
||||
s.stop(`Couldn't open a DM channel: ${reason}`, 1);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't open a DM channel with you.",
|
||||
'Make sure the bot is in a server you\'re also in, then retry setup.',
|
||||
);
|
||||
}
|
||||
s.stop(`DM channel ready. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('discord-open-dm', 'success', Date.now() - start, {
|
||||
DM_CHANNEL_ID: data.id,
|
||||
});
|
||||
return data.id;
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Discord. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('discord-open-dm', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'discord-open-dm',
|
||||
"Couldn't reach Discord.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,672 +0,0 @@
|
||||
/**
|
||||
* Microsoft Teams channel flow for setup:auto.
|
||||
*
|
||||
* Teams is the most complex channel NanoClaw supports — the Slack/Discord
|
||||
* "paste a token" shortcut doesn't exist. The operator has to walk through
|
||||
* ~7 Azure portal steps (app registration, client secret, Azure Bot
|
||||
* resource, messaging endpoint, Teams channel enable, manifest, sideload).
|
||||
*
|
||||
* This driver's job is to make each of those steps as guided as possible
|
||||
* inside the terminal:
|
||||
* 1. Print a clack note with the exact sub-steps and the portal URL.
|
||||
* 2. Ask for the value(s) that step yields (App ID, secret, tenant, etc.).
|
||||
* 3. At every step boundary, offer `stepGate` — a Done / Stuck / Show-again
|
||||
* select. "Stuck" hands off to interactive Claude with full context.
|
||||
*
|
||||
* Text/password prompts also accept `?` as an answer to trigger the handoff,
|
||||
* so the operator can escape at any paste point without scrolling back to a
|
||||
* step boundary.
|
||||
*
|
||||
* What's deferred (known limitation, instruct user how to finish manually):
|
||||
* - Wait-for-first-DM to capture the auto-generated Teams platformId.
|
||||
* Unlike Discord/Telegram, the Teams platform_id is only discoverable
|
||||
* after the first inbound activity. The driver installs the adapter and
|
||||
* stops there; the operator DMs the bot, NanoClaw auto-creates the
|
||||
* messaging group, and they wire an agent via `/manage-channels`.
|
||||
*/
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import {
|
||||
isHelpEscape,
|
||||
offerClaudeHandoff,
|
||||
validateWithHelpEscape,
|
||||
type HandoffContext,
|
||||
} from '../lib/claude-handoff.js';
|
||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||
import { buildTeamsAppPackage } from '../lib/teams-manifest.js';
|
||||
import * as setupLog from '../logs.js';
|
||||
|
||||
const CHANNEL = 'teams';
|
||||
const MANIFEST_DIR = path.join(process.cwd(), 'data', 'teams');
|
||||
const AZURE_PORTAL_URL = 'https://portal.azure.com';
|
||||
|
||||
interface Collected {
|
||||
publicUrl?: string;
|
||||
appId?: string;
|
||||
tenantId?: string;
|
||||
appType?: 'SingleTenant' | 'MultiTenant';
|
||||
appPassword?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export async function runTeamsChannel(_displayName: string): Promise<void> {
|
||||
const collected: Collected = {};
|
||||
const completed: string[] = [];
|
||||
|
||||
printIntro();
|
||||
|
||||
await confirmPrereqs({ collected, completed });
|
||||
await stepPublicUrl({ collected, completed });
|
||||
await stepAppRegistration({ collected, completed });
|
||||
await stepClientSecret({ collected, completed });
|
||||
await stepAzureBot({ collected, completed });
|
||||
await stepEnableTeamsChannel({ collected, completed });
|
||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
||||
|
||||
await installAdapter(collected);
|
||||
completed.push('Adapter installed and service restarted.');
|
||||
|
||||
await finishWithHandoff(collected, completed);
|
||||
}
|
||||
|
||||
// ─── step: intro / prereqs ──────────────────────────────────────────────
|
||||
|
||||
function printIntro(): void {
|
||||
p.note(
|
||||
[
|
||||
'Setting up Teams is more involved than the other channels — about',
|
||||
'7 steps across the Azure portal and Teams admin.',
|
||||
'',
|
||||
k.dim("At any prompt you can type '?' and press Enter to hand off"),
|
||||
k.dim("to Claude interactive mode with your current progress."),
|
||||
k.dim("You can also pick 'Stuck' at any Done/Stuck/Show-again prompt."),
|
||||
].join('\n'),
|
||||
'Microsoft Teams setup',
|
||||
);
|
||||
}
|
||||
|
||||
async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'Before we start, confirm you have:',
|
||||
'',
|
||||
' • A Microsoft 365 tenant where you can sideload custom apps',
|
||||
' (free personal Teams does NOT support this — you need a',
|
||||
' Microsoft 365 Business / EDU / developer tenant)',
|
||||
' • Teams admin or developer tenant rights',
|
||||
' • A way to expose an HTTPS endpoint from this machine',
|
||||
' (ngrok, Cloudflare Tunnel, or a reverse-proxied VPS)',
|
||||
].join('\n'),
|
||||
'Prereqs',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-prereqs',
|
||||
stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel',
|
||||
reshow: () => confirmPrereqs(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Prereqs confirmed.');
|
||||
}
|
||||
|
||||
// ─── step: public URL ──────────────────────────────────────────────────
|
||||
|
||||
async function stepPublicUrl(args: { collected: Collected; completed: string[] }): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
"Azure Bot Service delivers messages to an HTTPS endpoint you",
|
||||
"control. The endpoint needs to reach this machine's webhook",
|
||||
"server at /api/webhooks/teams.",
|
||||
'',
|
||||
k.dim('Examples:'),
|
||||
k.dim(' ngrok http 3000 → https://abcd1234.ngrok.io'),
|
||||
k.dim(' cloudflared tunnel … → https://<tunnel>.trycloudflare.com'),
|
||||
k.dim(' or a reverse proxy on your own domain'),
|
||||
'',
|
||||
"If you don't have a tunnel running yet, start one in another",
|
||||
"terminal, then come back here.",
|
||||
].join('\n'),
|
||||
'Public HTTPS URL',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Paste your public base URL (e.g. https://abcd1234.ngrok.io)',
|
||||
placeholder: 'https://…',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^https:\/\/[^\s/]+/.test(t)) {
|
||||
return 'Must be an https:// URL (Azure rejects http)';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-public-url',
|
||||
stepDescription:
|
||||
'setting up a public HTTPS tunnel to reach this machine on port 3000',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const url = (answer as string).trim().replace(/\/$/, '');
|
||||
args.collected.publicUrl = url;
|
||||
setupLog.userInput('teams_public_url', url);
|
||||
break;
|
||||
}
|
||||
|
||||
args.completed.push(`Public URL: ${args.collected.publicUrl}`);
|
||||
}
|
||||
|
||||
// ─── step: Azure App Registration ──────────────────────────────────────
|
||||
|
||||
async function stepAppRegistration(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
||||
'2. Name it (e.g. "NanoClaw")',
|
||||
'3. Supported account types: Single tenant (your org only) OR',
|
||||
' Multi tenant (any Microsoft 365 tenant can add the bot)',
|
||||
'4. Click Register',
|
||||
'5. On the Overview page, copy:',
|
||||
' • Application (client) ID',
|
||||
' • Directory (tenant) ID',
|
||||
].join('\n'),
|
||||
'Step 1 of 6 — Create Azure App Registration',
|
||||
);
|
||||
await confirmThenOpen(
|
||||
AZURE_PORTAL_URL,
|
||||
'Press Enter to open the Azure portal',
|
||||
);
|
||||
|
||||
args.collected.appType = await askAppType(args);
|
||||
args.collected.appId = await askUuid(
|
||||
'Paste the Application (client) ID',
|
||||
'teams-app-id',
|
||||
args,
|
||||
);
|
||||
if (args.collected.appType === 'SingleTenant') {
|
||||
args.collected.tenantId = await askUuid(
|
||||
'Paste the Directory (tenant) ID',
|
||||
'teams-tenant-id',
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-app-registration',
|
||||
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
||||
reshow: () => stepAppRegistration(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push(
|
||||
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function askAppType(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<'SingleTenant' | 'MultiTenant'> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Which account type did you pick?',
|
||||
options: [
|
||||
{
|
||||
value: 'SingleTenant',
|
||||
label: 'Single tenant',
|
||||
hint: 'your org only — most common for self-host',
|
||||
},
|
||||
{
|
||||
value: 'MultiTenant',
|
||||
label: 'Multi tenant',
|
||||
hint: 'any Microsoft 365 tenant can install the bot',
|
||||
},
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: 'teams-app-type',
|
||||
stepDescription: "deciding between Single tenant and Multi tenant for their Azure app",
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
return choice as 'SingleTenant' | 'MultiTenant';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: client secret ───────────────────────────────────────────────
|
||||
|
||||
async function stepClientSecret(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
`1. In your app registration, open "Certificates & secrets"`,
|
||||
'2. Click "New client secret"',
|
||||
' Description: nanoclaw',
|
||||
' Expires: 180 days (recommended) or longer',
|
||||
'3. Click Add',
|
||||
'4. ' + k.yellow('COPY THE VALUE NOW — Azure only shows it once'),
|
||||
' (the Value column, not the Secret ID)',
|
||||
].join('\n'),
|
||||
'Step 2 of 6 — Create a client secret',
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste the client secret Value',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (t.length < 20) return "That looks too short — make sure you copied the Value, not the Secret ID";
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret value from Azure',
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
args.collected.appPassword = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'teams_client_secret',
|
||||
`${args.collected.appPassword.slice(0, 4)}…${args.collected.appPassword.slice(-4)}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-client-secret',
|
||||
stepDescription: 'creating and copying the client secret',
|
||||
reshow: () => stepClientSecret(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Client secret captured.');
|
||||
}
|
||||
|
||||
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
||||
|
||||
async function stepAzureBot(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||
const tenantFlag =
|
||||
args.collected.appType === 'SingleTenant'
|
||||
? `--tenant-id ${args.collected.tenantId} `
|
||||
: '';
|
||||
const cliCommand =
|
||||
`az bot create \\\n` +
|
||||
` --resource-group nanoclaw-rg \\\n` +
|
||||
` --name nanoclaw-bot \\\n` +
|
||||
` --app-type ${args.collected.appType} \\\n` +
|
||||
` --appid ${args.collected.appId} \\\n` +
|
||||
` ${tenantFlag}--endpoint "${endpoint}"`;
|
||||
|
||||
p.note(
|
||||
[
|
||||
`In ${AZURE_PORTAL_URL}, search "Azure Bot" → Create.`,
|
||||
'',
|
||||
' • Bot handle: unique name, e.g. nanoclaw-bot',
|
||||
` • Type of App: ${args.collected.appType}`,
|
||||
' • Creation type: Use existing app registration',
|
||||
` • App ID: ${args.collected.appId ?? '<pending>'}`,
|
||||
...(args.collected.appType === 'SingleTenant'
|
||||
? [` • App tenant ID: ${args.collected.tenantId ?? '<pending>'}`]
|
||||
: []),
|
||||
'',
|
||||
'After creating, open the bot → Configuration and set:',
|
||||
` Messaging endpoint: ${k.cyan(endpoint)}`,
|
||||
'',
|
||||
k.dim('Or via Azure CLI (if you have az installed):'),
|
||||
k.dim(cliCommand),
|
||||
].join('\n'),
|
||||
'Step 3 of 6 — Create Azure Bot resource',
|
||||
);
|
||||
|
||||
await stepGate({
|
||||
stepName: 'teams-azure-bot',
|
||||
stepDescription:
|
||||
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
||||
reshow: () => stepAzureBot(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
||||
}
|
||||
|
||||
// ─── step: enable Teams channel ────────────────────────────────────────
|
||||
|
||||
async function stepEnableTeamsChannel(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open your Azure Bot resource → Channels',
|
||||
'2. Click Microsoft Teams → Accept terms → Apply',
|
||||
'',
|
||||
k.dim('CLI alternative:'),
|
||||
k.dim(' az bot msteams create --resource-group nanoclaw-rg --name nanoclaw-bot'),
|
||||
].join('\n'),
|
||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||
);
|
||||
await stepGate({
|
||||
stepName: 'teams-enable-channel',
|
||||
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
||||
reshow: () => stepEnableTeamsChannel(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('Teams channel enabled on the bot.');
|
||||
}
|
||||
|
||||
// ─── step: manifest zip ────────────────────────────────────────────────
|
||||
|
||||
async function stepGenerateManifest(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
}): Promise<{ zipPath: string }> {
|
||||
if (!args.collected.appId) {
|
||||
fail(
|
||||
'teams-manifest',
|
||||
'Missing Azure App ID.',
|
||||
"That's an internal bug — open an issue or retry setup.",
|
||||
);
|
||||
}
|
||||
const shortName =
|
||||
process.env.NANOCLAW_AGENT_NAME?.trim() || 'NanoClaw';
|
||||
|
||||
const s = p.spinner();
|
||||
s.start('Generating your Teams app package…');
|
||||
try {
|
||||
const result = buildTeamsAppPackage({
|
||||
appId: args.collected.appId!,
|
||||
shortName,
|
||||
longDescription: `${shortName} personal assistant powered by NanoClaw.`,
|
||||
websiteUrl: args.collected.publicUrl!,
|
||||
outDir: MANIFEST_DIR,
|
||||
});
|
||||
s.stop(`Package ready: ${k.cyan(shortPath(result.zipPath))}`);
|
||||
setupLog.step('teams-manifest', 'success', 0, {
|
||||
ZIP: result.zipPath,
|
||||
});
|
||||
args.completed.push(`Generated manifest zip at ${shortPath(result.zipPath)}.`);
|
||||
return { zipPath: result.zipPath };
|
||||
} catch (err) {
|
||||
s.stop("Couldn't build the manifest zip.", 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('teams-manifest', 'failed', 0, { ERROR: message });
|
||||
fail(
|
||||
'teams-manifest',
|
||||
"Couldn't generate the Teams app package.",
|
||||
'Make sure `zip` is available on your PATH, then retry.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── step: sideload ────────────────────────────────────────────────────
|
||||
|
||||
async function stepSideload(args: {
|
||||
collected: Collected;
|
||||
completed: string[];
|
||||
zipPath: string;
|
||||
}): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'1. Open Microsoft Teams',
|
||||
'2. Go to Apps → Manage your apps → Upload an app',
|
||||
'3. Click "Upload a custom app" (or "Upload for me or my teams")',
|
||||
`4. Select: ${k.cyan(args.zipPath)}`,
|
||||
'5. Click Add',
|
||||
'',
|
||||
k.dim('If "Upload a custom app" is missing, your tenant admin has'),
|
||||
k.dim('disabled sideloading. Enable it in Teams Admin Center →'),
|
||||
k.dim('Teams apps → Setup policies → Global → Upload custom apps = On'),
|
||||
].join('\n'),
|
||||
'Step 5 of 6 — Sideload the app into Teams',
|
||||
);
|
||||
await stepGate({
|
||||
stepName: 'teams-sideload',
|
||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||
reshow: () => stepSideload(args),
|
||||
args,
|
||||
});
|
||||
args.completed.push('App sideloaded into Teams.');
|
||||
}
|
||||
|
||||
// ─── step: install adapter ─────────────────────────────────────────────
|
||||
|
||||
async function installAdapter(collected: Collected): Promise<void> {
|
||||
const env: Record<string, string> = {
|
||||
TEAMS_APP_ID: collected.appId!,
|
||||
TEAMS_APP_PASSWORD: collected.appPassword!,
|
||||
TEAMS_APP_TYPE: collected.appType!,
|
||||
};
|
||||
if (collected.appType === 'SingleTenant') {
|
||||
env.TEAMS_APP_TENANT_ID = collected.tenantId!;
|
||||
}
|
||||
|
||||
const install = await runQuietChild(
|
||||
'teams-install',
|
||||
'bash',
|
||||
['setup/add-teams.sh'],
|
||||
{
|
||||
running: 'Installing the Teams adapter and restarting the service…',
|
||||
done: 'Teams adapter installed.',
|
||||
},
|
||||
{
|
||||
env,
|
||||
extraFields: {
|
||||
APP_ID: collected.appId!,
|
||||
APP_TYPE: collected.appType!,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'teams-install',
|
||||
"Couldn't install the Teams adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── post-install: hand off to Claude for the final wiring ────────────
|
||||
|
||||
async function finishWithHandoff(
|
||||
collected: Collected,
|
||||
completed: string[],
|
||||
): Promise<void> {
|
||||
p.note(
|
||||
[
|
||||
'The Teams adapter is live and the service is running.',
|
||||
'',
|
||||
"One thing left: your Teams bot's platform ID (which NanoClaw needs",
|
||||
'to wire to an agent group) only becomes known after you DM the bot',
|
||||
'for the first time. Claude can walk you through that interactively —',
|
||||
'watch the logs for your first inbound, find the auto-created',
|
||||
'messaging group in the DB, run scripts/init-first-agent.ts with',
|
||||
'the right flags, and verify end-to-end.',
|
||||
].join('\n'),
|
||||
'Step 6 of 6 — Finish wiring',
|
||||
);
|
||||
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'Ready to finish?',
|
||||
options: [
|
||||
{
|
||||
value: 'handoff',
|
||||
label: 'Hand me off to Claude to walk me through it',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{ value: 'self', label: "I'll do it myself" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
if (choice === 'self') {
|
||||
p.note(
|
||||
[
|
||||
' 1. Find your bot in Teams (search by name, or via the sideloaded',
|
||||
' app) and send it a message ("hi" is fine)',
|
||||
' 2. Tail ' + k.cyan('logs/nanoclaw.log') + ' for the inbound; the router',
|
||||
' auto-creates a row in ' + k.cyan('messaging_groups') + ' in data/v2.db',
|
||||
' 3. Run ' + k.cyan('scripts/init-first-agent.ts') + ' with --channel teams,',
|
||||
' the discovered platform_id, and your AAD user id, OR use',
|
||||
' ' + k.cyan('/manage-channels') + ' to wire interactively',
|
||||
].join('\n'),
|
||||
'Manual finish',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await offerClaudeHandoff({
|
||||
channel: CHANNEL,
|
||||
step: 'teams-finish-wiring',
|
||||
stepDescription:
|
||||
'finishing the Teams wiring: watch for the first inbound, discover the auto-created messaging group in data/v2.db, and run scripts/init-first-agent.ts to wire it to an agent group',
|
||||
completedSteps: completed,
|
||||
collectedValues: redactCollected(collected),
|
||||
files: [
|
||||
'scripts/init-first-agent.ts',
|
||||
'src/router.ts',
|
||||
'src/db/messaging-groups.ts',
|
||||
'logs/nanoclaw.log',
|
||||
'.claude/skills/manage-channels/SKILL.md',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// ─── shared step gate ──────────────────────────────────────────────────
|
||||
|
||||
async function stepGate(args: {
|
||||
stepName: string;
|
||||
stepDescription: string;
|
||||
reshow: () => Promise<void> | Promise<unknown>;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
while (true) {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'How did that go?',
|
||||
options: [
|
||||
{ value: 'done', label: "Done — let's continue" },
|
||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||
{ value: 'reshow', label: 'Show me the steps again' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (choice === 'done') return;
|
||||
if (choice === 'help') {
|
||||
await offerHandoff({
|
||||
step: args.stepName,
|
||||
stepDescription: args.stepDescription,
|
||||
args: args.args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (choice === 'reshow') {
|
||||
await args.reshow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function offerHandoff(args: {
|
||||
step: string;
|
||||
stepDescription: string;
|
||||
args: { collected: Collected; completed: string[] };
|
||||
}): Promise<void> {
|
||||
const ctx: HandoffContext = {
|
||||
channel: CHANNEL,
|
||||
step: args.step,
|
||||
stepDescription: args.stepDescription,
|
||||
completedSteps: args.args.completed.slice(),
|
||||
collectedValues: redactCollected(args.args.collected),
|
||||
files: ['setup/channels/teams.ts', 'setup/add-teams.sh'],
|
||||
};
|
||||
await offerClaudeHandoff(ctx);
|
||||
}
|
||||
|
||||
function redactCollected(c: Collected): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (c.publicUrl) out.publicUrl = c.publicUrl;
|
||||
if (c.appId) out.appId = c.appId;
|
||||
if (c.tenantId) out.tenantId = c.tenantId;
|
||||
if (c.appType) out.appType = c.appType;
|
||||
if (c.appPassword) {
|
||||
out.appPassword = `${c.appPassword.slice(0, 4)}…${c.appPassword.slice(-4)}`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── shared: UUID paste with help escape ───────────────────────────────
|
||||
|
||||
async function askUuid(
|
||||
message: string,
|
||||
logKey: string,
|
||||
args: { collected: Collected; completed: string[] },
|
||||
): Promise<string> {
|
||||
while (true) {
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message,
|
||||
placeholder: '00000000-0000-0000-0000-000000000000',
|
||||
validate: validateWithHelpEscape((v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Required';
|
||||
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(t)) {
|
||||
return 'Expected a UUID like 00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
}),
|
||||
);
|
||||
if (isHelpEscape(answer)) {
|
||||
await offerHandoff({
|
||||
step: logKey,
|
||||
stepDescription: `entering a UUID for ${logKey}`,
|
||||
args,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const value = (answer as string).trim().toLowerCase();
|
||||
setupLog.userInput(logKey, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── path helpers ──────────────────────────────────────────────────────
|
||||
|
||||
function shortPath(abs: string): string {
|
||||
const home = os.homedir();
|
||||
const cwd = process.cwd();
|
||||
if (abs.startsWith(`${cwd}/`)) return abs.slice(cwd.length + 1);
|
||||
if (abs.startsWith(`${home}/`)) return `~/${abs.slice(home.length + 1)}`;
|
||||
return abs;
|
||||
}
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
/**
|
||||
* Telegram channel flow for setup:auto.
|
||||
*
|
||||
* `runTelegramChannel(displayName)` owns the full branch from the
|
||||
* BotFather instructions through the welcome DM:
|
||||
*
|
||||
* 1. BotFather instructions (clack note)
|
||||
* 2. Paste the bot token (clack password) — format-validated
|
||||
* 3. getMe via the Bot API to resolve the bot's username
|
||||
* 4. Confirm + deep-link into the bot's Telegram chat (tg://resolve)
|
||||
* 5. Install the adapter (setup/add-telegram.sh, non-interactive)
|
||||
* 6. Run the pair-telegram step, rendering code events as clack notes
|
||||
* 7. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 8. Wire the agent via scripts/init-first-agent.ts
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user,
|
||||
* structured entries in logs/setup.log, full raw output in per-step files
|
||||
* under logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { confirmThenOpen } from '../lib/browser.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
|
||||
export async function runTelegramChannel(displayName: string): Promise<void> {
|
||||
const token = await collectTelegramToken();
|
||||
const botUsername = await validateTelegramToken(token);
|
||||
|
||||
// Deep-link the user into the bot's chat so they're on the right screen
|
||||
// by the time pair-telegram prints the code. https://t.me/<bot> works
|
||||
// everywhere: browsers show an "Open in Telegram" button when the app is
|
||||
// installed, or the bot's web profile if not. tg://resolve?domain= is
|
||||
// more direct but silently fails when the scheme isn't registered.
|
||||
const botUrl = `https://t.me/${botUsername}`;
|
||||
p.note(
|
||||
[
|
||||
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
|
||||
'',
|
||||
k.dim(botUrl),
|
||||
].join('\n'),
|
||||
'Open Telegram',
|
||||
);
|
||||
await confirmThenOpen(botUrl, 'Press Enter to open Telegram');
|
||||
|
||||
const install = await runQuietChild(
|
||||
'telegram-install',
|
||||
'bash',
|
||||
['setup/add-telegram.sh'],
|
||||
{
|
||||
running: `Connecting Telegram to @${botUsername}…`,
|
||||
done: 'Telegram connected.',
|
||||
},
|
||||
{
|
||||
env: { TELEGRAM_BOT_TOKEN: token },
|
||||
extraFields: { BOT_USERNAME: botUsername },
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
await fail(
|
||||
'telegram-install',
|
||||
"Couldn't connect Telegram.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const pair = await runPairTelegram();
|
||||
if (!pair.ok) {
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
"Couldn't pair with Telegram.",
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const platformId = pair.terminal?.fields.PLATFORM_ID;
|
||||
const pairedUserId = pair.terminal?.fields.PAIRED_USER_ID;
|
||||
if (!platformId || !pairedUserId) {
|
||||
await fail(
|
||||
'pair-telegram',
|
||||
'Pairing completed but came back incomplete.',
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('Telegram');
|
||||
setupLog.userInput('telegram_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'telegram',
|
||||
'--user-id', pairedUserId,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to your Telegram chat…`,
|
||||
done: `${agentName} is ready. Check Telegram for a welcome message.`,
|
||||
},
|
||||
{
|
||||
extraFields: { CHANNEL: 'telegram', AGENT_NAME: agentName, PLATFORM_ID: platformId },
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
await fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectTelegramToken(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"Your assistant talks to you through a Telegram bot you create.",
|
||||
"Here's how:",
|
||||
'',
|
||||
' 1. Open Telegram and message @BotFather',
|
||||
' 2. Send /newbot and follow the prompts',
|
||||
' 3. Copy the token it gives you (it looks like <digits>:<chars>)',
|
||||
'',
|
||||
k.dim('Planning to add your assistant to group chats? In @BotFather:'),
|
||||
k.dim(' /mybots → your bot → Bot Settings → Group Privacy → OFF'),
|
||||
].join('\n'),
|
||||
'Set up your Telegram bot',
|
||||
);
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.password({
|
||||
message: 'Paste your bot token',
|
||||
validate: (v) => {
|
||||
if (!v || !v.trim()) return "Token is required";
|
||||
if (!/^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(v.trim())) {
|
||||
return "That doesn't look right. It should be <digits>:<chars>";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const token = (answer as string).trim();
|
||||
setupLog.userInput(
|
||||
'telegram_token',
|
||||
`${token.slice(0, 12)}…${token.slice(-4)}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function validateTelegramToken(token: string): Promise<string> {
|
||||
const s = p.spinner();
|
||||
const start = Date.now();
|
||||
s.start('Checking your bot token…');
|
||||
try {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/getMe`);
|
||||
const data = (await res.json()) as {
|
||||
ok?: boolean;
|
||||
result?: { username?: string; id?: number };
|
||||
description?: string;
|
||||
};
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
if (data.ok && data.result?.username) {
|
||||
const username = data.result.username;
|
||||
s.stop(`Found your bot: @${username}. ${k.dim(`(${elapsedS}s)`)}`);
|
||||
setupLog.step('telegram-validate', 'success', Date.now() - start, {
|
||||
BOT_USERNAME: username,
|
||||
BOT_ID: data.result.id ?? '',
|
||||
});
|
||||
return username;
|
||||
}
|
||||
const reason = data.description ?? 'token rejected by Telegram';
|
||||
s.stop(`Telegram didn't accept that token: ${reason}`, 1);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: reason,
|
||||
});
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Telegram didn't accept that token.",
|
||||
'Copy the token again from @BotFather and try setup once more.',
|
||||
);
|
||||
} catch (err) {
|
||||
const elapsedS = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`Couldn't reach Telegram. ${k.dim(`(${elapsedS}s)`)}`, 1);
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setupLog.step('telegram-validate', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
await fail(
|
||||
'telegram-validate',
|
||||
"Couldn't reach Telegram.",
|
||||
'Check your internet connection and retry setup.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runPairTelegram(): Promise<
|
||||
StepResult & { rawLog: string; durationMs: number }
|
||||
> {
|
||||
const rawLog = setupLog.stepRawLog('pair-telegram');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Generating a secret code for your bot…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number) => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
const result = await spawnStep(
|
||||
'pair-telegram',
|
||||
['--intent', 'main'],
|
||||
(block: Block) => {
|
||||
if (block.type === 'PAIR_TELEGRAM_CODE') {
|
||||
const reason = block.fields.REASON ?? 'initial';
|
||||
if (reason === 'initial') {
|
||||
stopSpinner('Your secret code is ready.');
|
||||
} else {
|
||||
stopSpinner("Old code expired. Here's a fresh one.");
|
||||
}
|
||||
p.note(formatCodeCard(block.fields.CODE ?? '????'), 'Secret code');
|
||||
s.start('Waiting for you to send the code from Telegram…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM_ATTEMPT') {
|
||||
stopSpinner(`Got "${block.fields.CANDIDATE ?? '?'}", not a match.`);
|
||||
s.start('Waiting for the correct code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'PAIR_TELEGRAM') {
|
||||
if (block.fields.STATUS === 'success') {
|
||||
stopSpinner('Telegram paired.');
|
||||
} else {
|
||||
stopSpinner(`Pairing failed: ${block.fields.ERROR ?? 'unknown'}`, 1);
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// Safety net: if the child died without emitting a terminal block, make
|
||||
// sure we don't leave the spinner running.
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Pairing ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('pair-telegram', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
function formatCodeCard(code: string): string {
|
||||
const spaced = code.split('').join(' ');
|
||||
return [
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Send this code to your bot from Telegram.'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
/**
|
||||
* WhatsApp (community/Baileys) channel flow for setup:auto.
|
||||
*
|
||||
* `runWhatsAppChannel(displayName)` owns the full branch from auth-method
|
||||
* picker through the welcome DM:
|
||||
*
|
||||
* 1. Ask how to authenticate (QR code in terminal, default, or pairing code)
|
||||
* 2. If pairing-code: collect the phone number
|
||||
* 3. Install the adapter + Baileys + QR + pino via setup/add-whatsapp.sh
|
||||
* 4. Run the whatsapp-auth step, rendering status blocks as clack UI:
|
||||
* - WHATSAPP_AUTH_QR (repeating): render the QR as terminal block art
|
||||
* inside a clack note. On rotation we clear the previous QR in-place
|
||||
* via ANSI escapes so the terminal doesn't fill up with stale codes.
|
||||
* - WHATSAPP_AUTH_PAIRING_CODE (one-shot): centred code card.
|
||||
* 5. Read store/auth/creds.json → extract the authenticated (bot) phone
|
||||
* 6. Kick the service so the adapter picks up the new credentials
|
||||
* 7. Ask the operator for the phone they'll chat from (defaults to the
|
||||
* authed number). Different number ⇒ dedicated mode ⇒ also writes
|
||||
* ASSISTANT_HAS_OWN_NUMBER=true so outbound replies aren't prefixed
|
||||
* 8. Ask for the messaging-agent name (defaulting to "Nano")
|
||||
* 9. Wire the agent via scripts/init-first-agent.ts; the existing welcome
|
||||
* DM path delivers the greeting through the adapter
|
||||
*
|
||||
* All output obeys the three-level contract: clack UI for the user, structured
|
||||
* entries in logs/setup.log, full raw output in per-step files under
|
||||
* logs/setup-steps/. See docs/setup-flow.md.
|
||||
*/
|
||||
import { spawnSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import {
|
||||
type Block,
|
||||
type StepResult,
|
||||
dumpTranscriptOnFailure,
|
||||
ensureAnswer,
|
||||
fail,
|
||||
runQuietChild,
|
||||
spawnStep,
|
||||
writeStepEntry,
|
||||
} from '../lib/runner.js';
|
||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||
import { brandBold } from '../lib/theme.js';
|
||||
|
||||
const DEFAULT_AGENT_NAME = 'Nano';
|
||||
const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json');
|
||||
|
||||
type AuthMethod = 'qr' | 'pairing-code';
|
||||
|
||||
export async function runWhatsAppChannel(displayName: string): Promise<void> {
|
||||
const method = await askAuthMethod();
|
||||
const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined;
|
||||
|
||||
const install = await runQuietChild(
|
||||
'whatsapp-install',
|
||||
'bash',
|
||||
['setup/add-whatsapp.sh'],
|
||||
{
|
||||
running: 'Installing the WhatsApp adapter…',
|
||||
done: 'WhatsApp adapter installed.',
|
||||
skipped: 'WhatsApp adapter already installed.',
|
||||
},
|
||||
);
|
||||
if (!install.ok) {
|
||||
fail(
|
||||
'whatsapp-install',
|
||||
"Couldn't install the WhatsApp adapter.",
|
||||
'See logs/setup-steps/ for details, then retry setup.',
|
||||
);
|
||||
}
|
||||
|
||||
const auth = await runWhatsAppAuth(method, phone);
|
||||
if (!auth.ok) {
|
||||
const reason = auth.terminal?.fields.ERROR ?? 'unknown';
|
||||
fail(
|
||||
'whatsapp-auth',
|
||||
`WhatsApp authentication failed (${reason}).`,
|
||||
reason === 'qr_timeout' || reason === 'timeout'
|
||||
? 'The code expired. Re-run setup to get a fresh one.'
|
||||
: 'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
const botPhone = readAuthedPhone();
|
||||
if (!botPhone) {
|
||||
fail(
|
||||
'whatsapp-auth',
|
||||
"Authenticated but couldn't read your WhatsApp number from the saved credentials.",
|
||||
'Re-run setup to try again.',
|
||||
);
|
||||
}
|
||||
|
||||
await restartService();
|
||||
|
||||
const chatPhone = await askChatPhone(botPhone);
|
||||
const isDedicated = chatPhone !== botPhone;
|
||||
if (isDedicated) {
|
||||
writeAssistantHasOwnNumber();
|
||||
}
|
||||
|
||||
const role = await askOperatorRole('WhatsApp');
|
||||
setupLog.userInput('whatsapp_role', role);
|
||||
|
||||
const agentName = await resolveAgentName();
|
||||
|
||||
const platformId = `${chatPhone}@s.whatsapp.net`;
|
||||
|
||||
const init = await runQuietChild(
|
||||
'init-first-agent',
|
||||
'pnpm',
|
||||
[
|
||||
'exec', 'tsx', 'scripts/init-first-agent.ts',
|
||||
'--channel', 'whatsapp',
|
||||
'--user-id', platformId,
|
||||
'--platform-id', platformId,
|
||||
'--display-name', displayName,
|
||||
'--agent-name', agentName,
|
||||
'--role', role,
|
||||
],
|
||||
{
|
||||
running: `Connecting ${agentName} to WhatsApp…`,
|
||||
done: isDedicated
|
||||
? `${agentName} is ready. Check WhatsApp for a welcome message.`
|
||||
: `${agentName} is ready. Look in your "You" chat on WhatsApp for the welcome.`,
|
||||
},
|
||||
{
|
||||
extraFields: {
|
||||
CHANNEL: 'whatsapp',
|
||||
AGENT_NAME: agentName,
|
||||
PLATFORM_ID: platformId,
|
||||
MODE: isDedicated ? 'dedicated' : 'shared',
|
||||
ROLE: role,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!init.ok) {
|
||||
fail(
|
||||
'init-first-agent',
|
||||
`Couldn't finish connecting ${agentName}.`,
|
||||
'You can retry later with `/manage-channels`.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function askAuthMethod(): Promise<AuthMethod> {
|
||||
const choice = ensureAnswer(
|
||||
await p.select({
|
||||
message: 'How would you like to authenticate with WhatsApp?',
|
||||
options: [
|
||||
{
|
||||
value: 'qr',
|
||||
label: 'Scan a QR code in this terminal',
|
||||
hint: 'recommended',
|
||||
},
|
||||
{
|
||||
value: 'pairing-code',
|
||||
label: 'Enter a pairing code on your phone',
|
||||
hint: 'no camera needed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as AuthMethod;
|
||||
setupLog.userInput('whatsapp_auth_method', choice);
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askPhoneNumber(): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
"Enter your phone number the way WhatsApp expects it:",
|
||||
'',
|
||||
' • Digits only — no +, spaces, or dashes',
|
||||
' • Country code first, then the rest of the number',
|
||||
'',
|
||||
k.dim('Example: 14155551234 (country code 1, then 4155551234)'),
|
||||
].join('\n'),
|
||||
'Your phone number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Phone number',
|
||||
validate: (v) => {
|
||||
const t = (v ?? '').trim();
|
||||
if (!t) return 'Phone number is required';
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return "That doesn't look right. Digits only, country code included.";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = (answer as string).trim();
|
||||
setupLog.userInput('whatsapp_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
async function runWhatsAppAuth(
|
||||
method: AuthMethod,
|
||||
phone: string | undefined,
|
||||
): Promise<StepResult & { rawLog: string; durationMs: number }> {
|
||||
const rawLog = setupLog.stepRawLog('whatsapp-auth');
|
||||
const start = Date.now();
|
||||
const s = p.spinner();
|
||||
s.start('Starting WhatsApp authentication…');
|
||||
let spinnerActive = true;
|
||||
|
||||
const stopSpinner = (msg: string, code?: number) => {
|
||||
if (spinnerActive) {
|
||||
s.stop(msg, code);
|
||||
spinnerActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Tracks the QR render so we can overwrite it in-place on rotation. null
|
||||
// before the first QR is printed.
|
||||
let qrLinesPrinted = 0;
|
||||
|
||||
const extra =
|
||||
method === 'pairing-code' && phone
|
||||
? ['--method', 'pairing-code', '--phone', phone]
|
||||
: ['--method', 'qr'];
|
||||
|
||||
const result = await spawnStep(
|
||||
'whatsapp-auth',
|
||||
extra,
|
||||
(block: Block) => {
|
||||
if (block.type === 'WHATSAPP_AUTH_QR') {
|
||||
const qr = block.fields.QR ?? '';
|
||||
if (!qr) return;
|
||||
// Fire-and-forget — await inside spawnStep's sync onBlock is fine
|
||||
// since spawnStep's own logic keeps running in parallel.
|
||||
void renderQr(qr).then((lines) => {
|
||||
if (qrLinesPrinted === 0) {
|
||||
stopSpinner('QR code ready — scan with WhatsApp.');
|
||||
} else {
|
||||
// Cursor up N lines + clear from there to end of screen. Wipes
|
||||
// the previous QR + caption so the new one renders in place.
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
}
|
||||
process.stdout.write(lines.join('\n') + '\n');
|
||||
qrLinesPrinted = lines.length;
|
||||
});
|
||||
} else if (block.type === 'WHATSAPP_AUTH_PAIRING_CODE') {
|
||||
const code = block.fields.CODE ?? '????';
|
||||
stopSpinner('Your pairing code is ready.');
|
||||
p.note(formatPairingCard(code), 'Pairing code');
|
||||
s.start('Waiting for you to enter the code…');
|
||||
spinnerActive = true;
|
||||
} else if (block.type === 'WHATSAPP_AUTH') {
|
||||
const status = block.fields.STATUS;
|
||||
if (status === 'skipped') {
|
||||
stopSpinner('WhatsApp is already authenticated.');
|
||||
} else if (status === 'success') {
|
||||
// Erase the QR block if one was on screen — it's served its purpose.
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
// In QR flow the spinner was stopped when the first QR landed.
|
||||
// Fall back to a plain success line so the user sees confirmation.
|
||||
if (spinnerActive) {
|
||||
stopSpinner('WhatsApp linked.');
|
||||
} else {
|
||||
p.log.success('WhatsApp linked.');
|
||||
}
|
||||
} else if (status === 'failed') {
|
||||
if (qrLinesPrinted > 0) {
|
||||
process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`);
|
||||
qrLinesPrinted = 0;
|
||||
}
|
||||
const err = block.fields.ERROR ?? 'unknown';
|
||||
if (spinnerActive) {
|
||||
stopSpinner(`Authentication failed: ${err}`, 1);
|
||||
} else {
|
||||
p.log.error(`Authentication failed: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
rawLog,
|
||||
);
|
||||
const durationMs = Date.now() - start;
|
||||
|
||||
// Safety net — if the step died without emitting a terminal block, don't
|
||||
// leave the spinner running.
|
||||
if (spinnerActive) {
|
||||
stopSpinner(
|
||||
result.ok ? 'Done.' : 'Authentication ended unexpectedly.',
|
||||
result.ok ? 0 : 1,
|
||||
);
|
||||
if (!result.ok) dumpTranscriptOnFailure(result.transcript);
|
||||
}
|
||||
|
||||
writeStepEntry('whatsapp-auth', result, durationMs, rawLog);
|
||||
return { ...result, rawLog, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the raw QR string to an array of terminal lines (block-art QR +
|
||||
* a caption). Returned as an array so the caller can count lines for the
|
||||
* in-place rewrite on rotation. Uses the small-mode QR to keep the height
|
||||
* manageable on 24-row terminals.
|
||||
*/
|
||||
async function renderQr(qr: string): Promise<string[]> {
|
||||
try {
|
||||
const QRCode = await import('qrcode');
|
||||
const qrText = await QRCode.toString(qr, { type: 'terminal', small: true });
|
||||
const caption = k.dim(
|
||||
' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.',
|
||||
);
|
||||
return [...qrText.trimEnd().split('\n'), '', caption];
|
||||
} catch {
|
||||
return ['QR code (raw): ' + qr];
|
||||
}
|
||||
}
|
||||
|
||||
function formatPairingCard(code: string): string {
|
||||
// WhatsApp pairing codes are 8 characters; render with two-wide gap so the
|
||||
// digits read clearly in the terminal.
|
||||
const spaced = code.split('').join(' ');
|
||||
return [
|
||||
'',
|
||||
` ${brandBold(spaced)}`,
|
||||
'',
|
||||
k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'),
|
||||
k.dim(' → "Link with phone number instead" → enter this code.'),
|
||||
k.dim(' It expires in ~60 seconds.'),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the authenticated WhatsApp phone out of store/auth/creds.json.
|
||||
* `creds.me.id` looks like `14155551234:<device>@s.whatsapp.net` — we want
|
||||
* just the leading digit run.
|
||||
*/
|
||||
function readAuthedPhone(): string {
|
||||
try {
|
||||
const raw = fs.readFileSync(AUTH_CREDS_PATH, 'utf-8');
|
||||
const creds = JSON.parse(raw) as { me?: { id?: string } };
|
||||
const id = creds.me?.id;
|
||||
if (!id) return '';
|
||||
return id.split(':')[0].split('@')[0];
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function restartService(): Promise<void> {
|
||||
const s = p.spinner();
|
||||
s.start('Restarting NanoClaw so it sees your WhatsApp credentials…');
|
||||
const start = Date.now();
|
||||
const platform = process.platform;
|
||||
try {
|
||||
if (platform === 'darwin') {
|
||||
spawnSync(
|
||||
'launchctl',
|
||||
['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/com.nanoclaw`],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
} else if (platform === 'linux') {
|
||||
const user = spawnSync(
|
||||
'systemctl',
|
||||
['--user', 'restart', 'nanoclaw'],
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
if (user.status !== 0) {
|
||||
spawnSync('sudo', ['systemctl', 'restart', 'nanoclaw'], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Give the adapter a moment to reconnect before init-first-agent's
|
||||
// welcome DM hits the delivery path.
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
const elapsed = Math.round((Date.now() - start) / 1000);
|
||||
s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`);
|
||||
setupLog.step('whatsapp-restart', 'success', Date.now() - start, {
|
||||
PLATFORM: platform,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
s.stop(`Restart may have failed: ${message}`, 1);
|
||||
setupLog.step('whatsapp-restart', 'failed', Date.now() - start, {
|
||||
ERROR: message,
|
||||
});
|
||||
// Non-fatal — the user can restart manually if init-first-agent fails.
|
||||
}
|
||||
}
|
||||
|
||||
async function askChatPhone(authedPhone: string): Promise<string> {
|
||||
p.note(
|
||||
[
|
||||
`Authenticated with ${k.cyan('+' + authedPhone)}.`,
|
||||
'',
|
||||
"What's the phone number you'll chat with your agent from?",
|
||||
'',
|
||||
k.dim(
|
||||
'Same number = messages will land in your "You" / self-chat on WhatsApp\n' +
|
||||
"(you won't be able to reply to yourself — use a different number for a\n" +
|
||||
'two-way chat).',
|
||||
),
|
||||
].join('\n'),
|
||||
'Your chat number',
|
||||
);
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Your personal phone number',
|
||||
placeholder: authedPhone,
|
||||
defaultValue: authedPhone,
|
||||
validate: (v) => {
|
||||
const t = (v ?? authedPhone).trim();
|
||||
if (!/^\d{8,15}$/.test(t)) {
|
||||
return 'Digits only, country code included.';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
);
|
||||
const phone = ((answer as string) || authedPhone).trim();
|
||||
setupLog.userInput('whatsapp_chat_phone', phone);
|
||||
return phone;
|
||||
}
|
||||
|
||||
/** Persist ASSISTANT_HAS_OWN_NUMBER=true to .env and data/env/env. */
|
||||
function writeAssistantHasOwnNumber(): void {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
let contents = '';
|
||||
try {
|
||||
contents = fs.readFileSync(envPath, 'utf-8');
|
||||
} catch {
|
||||
contents = '';
|
||||
}
|
||||
if (/^ASSISTANT_HAS_OWN_NUMBER=/m.test(contents)) {
|
||||
contents = contents.replace(
|
||||
/^ASSISTANT_HAS_OWN_NUMBER=.*$/m,
|
||||
'ASSISTANT_HAS_OWN_NUMBER=true',
|
||||
);
|
||||
} else {
|
||||
if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n';
|
||||
contents += 'ASSISTANT_HAS_OWN_NUMBER=true\n';
|
||||
}
|
||||
fs.writeFileSync(envPath, contents);
|
||||
|
||||
// Container reads from data/env/env.
|
||||
const containerEnvDir = path.join(process.cwd(), 'data', 'env');
|
||||
fs.mkdirSync(containerEnvDir, { recursive: true });
|
||||
fs.copyFileSync(envPath, path.join(containerEnvDir, 'env'));
|
||||
}
|
||||
|
||||
async function resolveAgentName(): Promise<string> {
|
||||
const preset = process.env.NANOCLAW_AGENT_NAME?.trim();
|
||||
if (preset) {
|
||||
setupLog.userInput('agent_name', preset);
|
||||
return preset;
|
||||
}
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'What should your assistant be called?',
|
||||
placeholder: DEFAULT_AGENT_NAME,
|
||||
defaultValue: DEFAULT_AGENT_NAME,
|
||||
}),
|
||||
);
|
||||
const value = (answer as string).trim() || DEFAULT_AGENT_NAME;
|
||||
setupLog.userInput('agent_name', value);
|
||||
return value;
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Step: cli-agent — Create the scratch CLI agent for `/new-setup`.
|
||||
*
|
||||
* Thin wrapper around `scripts/init-cli-agent.ts`. Emits a status block so
|
||||
* /new-setup SKILL.md can parse the result without having to read the
|
||||
* script's plain stdout.
|
||||
*
|
||||
* Args:
|
||||
* --display-name <name> (required) operator's display name
|
||||
* --agent-name <name> (optional) agent persona name, defaults to display-name
|
||||
*/
|
||||
import { execFileSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
function parseArgs(args: string[]): {
|
||||
displayName: string;
|
||||
agentName?: string;
|
||||
} {
|
||||
let displayName: string | undefined;
|
||||
let agentName: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const key = args[i];
|
||||
const val = args[i + 1];
|
||||
switch (key) {
|
||||
case '--display-name':
|
||||
displayName = val;
|
||||
i++;
|
||||
break;
|
||||
case '--agent-name':
|
||||
agentName = val;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
emitStatus('CLI_AGENT', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'missing_display_name',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { displayName, agentName };
|
||||
}
|
||||
|
||||
export async function run(args: string[]): Promise<void> {
|
||||
const { displayName, agentName } = parseArgs(args);
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const script = path.join(projectRoot, 'scripts', 'init-cli-agent.ts');
|
||||
|
||||
const scriptArgs = ['exec', 'tsx', script, '--display-name', displayName];
|
||||
if (agentName) scriptArgs.push('--agent-name', agentName);
|
||||
|
||||
log.info('Invoking init-cli-agent', { displayName, agentName });
|
||||
|
||||
try {
|
||||
execFileSync('pnpm', scriptArgs, {
|
||||
cwd: projectRoot,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} catch (err) {
|
||||
const e = err as { stdout?: string; stderr?: string; status?: number };
|
||||
log.error('init-cli-agent failed', {
|
||||
status: e.status,
|
||||
stdout: e.stdout,
|
||||
stderr: e.stderr,
|
||||
});
|
||||
emitStatus('CLI_AGENT', {
|
||||
STATUS: 'failed',
|
||||
ERROR: 'init_script_failed',
|
||||
EXIT_CODE: e.status ?? -1,
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
emitStatus('CLI_AGENT', {
|
||||
DISPLAY_NAME: displayName,
|
||||
AGENT_NAME: agentName || displayName,
|
||||
CHANNEL: 'cli/local',
|
||||
STATUS: 'success',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
}
|
||||
+41
-101
@@ -2,73 +2,15 @@
|
||||
* Step: container — Build container image and verify with test run.
|
||||
* Replaces 03-setup-container.sh
|
||||
*/
|
||||
import { execSync, spawnSync } from 'child_process';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { setTimeout as sleep } from 'timers/promises';
|
||||
|
||||
import { log } from '../src/log.js';
|
||||
import { commandExists, getPlatform } from './platform.js';
|
||||
import { commandExists } from './platform.js';
|
||||
import { emitStatus } from './status.js';
|
||||
|
||||
type DockerStatus = 'ok' | 'no-permission' | 'no-daemon' | 'other';
|
||||
|
||||
function dockerStatus(): DockerStatus {
|
||||
const res = spawnSync('docker', ['info'], { encoding: 'utf-8' });
|
||||
if (res.status === 0) return 'ok';
|
||||
const err = `${res.stderr ?? ''}\n${res.stdout ?? ''}`;
|
||||
if (/permission denied/i.test(err)) return 'no-permission';
|
||||
if (/cannot connect|is the docker daemon running|no such file/i.test(err)) return 'no-daemon';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function dockerRunning(): boolean {
|
||||
return dockerStatus() === 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to start Docker if it's installed but idle. Poll up to 60s for the
|
||||
* daemon to come up — but bail immediately if the socket is reachable and
|
||||
* only blocked by a group-permission error, since that won't resolve by
|
||||
* waiting (the caller handles the sg re-exec for that case).
|
||||
*/
|
||||
async function tryStartDocker(): Promise<DockerStatus> {
|
||||
const platform = getPlatform();
|
||||
log.info('Docker not running — attempting to start', { platform });
|
||||
|
||||
try {
|
||||
if (platform === 'macos') {
|
||||
execSync('open -a Docker', { stdio: 'ignore' });
|
||||
} else if (platform === 'linux') {
|
||||
// Inherit stdio so sudo can prompt for a password if needed.
|
||||
execSync('sudo systemctl start docker', { stdio: 'inherit' });
|
||||
} else {
|
||||
return 'other';
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Start command failed', { err });
|
||||
return 'other';
|
||||
}
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await sleep(2000);
|
||||
const s = dockerStatus();
|
||||
if (s === 'ok') {
|
||||
log.info('Docker is up');
|
||||
return 'ok';
|
||||
}
|
||||
if (s === 'no-permission') {
|
||||
log.info('Docker daemon is up but socket is not accessible (group membership)');
|
||||
return 'no-permission';
|
||||
}
|
||||
}
|
||||
log.warn('Docker did not become ready within 60s');
|
||||
return 'no-daemon';
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { runtime: string } {
|
||||
// `--runtime` is still accepted for backwards compatibility with the /setup
|
||||
// skill, but `docker` is the only supported value.
|
||||
let runtime = 'docker';
|
||||
let runtime = '';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--runtime' && args[i + 1]) {
|
||||
runtime = args[i + 1];
|
||||
@@ -84,29 +26,21 @@ export async function run(args: string[]): Promise<void> {
|
||||
const image = 'nanoclaw-agent:latest';
|
||||
const logFile = path.join(projectRoot, 'logs', 'setup.log');
|
||||
|
||||
if (runtime !== 'docker') {
|
||||
if (!runtime) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
RUNTIME: 'unknown',
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'unknown_runtime',
|
||||
ERROR: 'missing_runtime_flag',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
if (!commandExists('docker')) {
|
||||
log.info('Docker not found — running setup/install-docker.sh');
|
||||
try {
|
||||
execSync('bash setup/install-docker.sh', { cwd: projectRoot, stdio: 'inherit' });
|
||||
} catch (err) {
|
||||
log.warn('install-docker.sh failed', { err });
|
||||
}
|
||||
}
|
||||
|
||||
if (!commandExists('docker')) {
|
||||
// Validate runtime availability
|
||||
if (runtime === 'apple-container' && !commandExists('container')) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
@@ -119,45 +53,51 @@ export async function run(args: string[]): Promise<void> {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
{
|
||||
let status = dockerStatus();
|
||||
if (status !== 'ok') {
|
||||
status = await tryStartDocker();
|
||||
}
|
||||
|
||||
// Socket is unreachable due to group perms — current shell's supplementary
|
||||
// groups are fixed at login, so `usermod -aG docker` (via install-docker.sh
|
||||
// or a prior install) doesn't affect us until next login. Re-exec this
|
||||
// step under `sg docker` so the child picks up docker as its primary
|
||||
// group and can talk to /var/run/docker.sock without a logout.
|
||||
if (status === 'no-permission' && getPlatform() === 'linux' && commandExists('sg')) {
|
||||
log.info('Re-executing container step under `sg docker`');
|
||||
const res = spawnSync(
|
||||
'sg',
|
||||
['docker', '-c', 'pnpm exec tsx setup/index.ts --step container'],
|
||||
{ cwd: projectRoot, stdio: 'inherit' },
|
||||
);
|
||||
process.exit(res.status ?? 1);
|
||||
}
|
||||
|
||||
if (status !== 'ok') {
|
||||
const error =
|
||||
status === 'no-permission' ? 'docker_group_not_active' : 'runtime_not_available';
|
||||
if (runtime === 'docker') {
|
||||
if (!commandExists('docker')) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: error,
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
execSync('docker info', { stdio: 'ignore' });
|
||||
} catch {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'runtime_not_available',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
const buildCmd = 'docker build';
|
||||
const runCmd = 'docker';
|
||||
if (!['apple-container', 'docker'].includes(runtime)) {
|
||||
emitStatus('SETUP_CONTAINER', {
|
||||
RUNTIME: runtime,
|
||||
IMAGE: image,
|
||||
BUILD_OK: false,
|
||||
TEST_OK: false,
|
||||
STATUS: 'failed',
|
||||
ERROR: 'unknown_runtime',
|
||||
LOG: 'logs/setup.log',
|
||||
});
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const buildCmd =
|
||||
runtime === 'apple-container' ? 'container build' : 'docker build';
|
||||
const runCmd = runtime === 'apple-container' ? 'container' : 'docker';
|
||||
|
||||
// Build-args from .env. Only INSTALL_CJK_FONTS is passed through today.
|
||||
// Keeps /setup and ./container/build.sh in sync — both read the same source.
|
||||
|
||||
@@ -21,6 +21,12 @@ export async function run(_args: string[]): Promise<void> {
|
||||
const wsl = isWSL();
|
||||
const headless = isHeadless();
|
||||
|
||||
// Check Apple Container
|
||||
let appleContainer: 'installed' | 'not_found' = 'not_found';
|
||||
if (commandExists('container')) {
|
||||
appleContainer = 'installed';
|
||||
}
|
||||
|
||||
// Check Docker
|
||||
let docker: 'running' | 'installed_not_running' | 'not_found' = 'not_found';
|
||||
if (commandExists('docker')) {
|
||||
@@ -72,6 +78,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
{
|
||||
platform,
|
||||
wsl,
|
||||
appleContainer,
|
||||
docker,
|
||||
hasEnv,
|
||||
hasAuth,
|
||||
@@ -84,6 +91,7 @@ export async function run(_args: string[]): Promise<void> {
|
||||
PLATFORM: platform,
|
||||
IS_WSL: wsl,
|
||||
IS_HEADLESS: headless,
|
||||
APPLE_CONTAINER: appleContainer,
|
||||
DOCKER: docker,
|
||||
HAS_ENV: hasEnv,
|
||||
HAS_AUTH: hasAuth,
|
||||
|
||||
@@ -10,18 +10,12 @@ const STEPS: Record<
|
||||
() => Promise<{ run: (args: string[]) => Promise<void> }>
|
||||
> = {
|
||||
timezone: () => import('./timezone.js'),
|
||||
'set-env': () => import('./set-env.js'),
|
||||
environment: () => import('./environment.js'),
|
||||
container: () => import('./container.js'),
|
||||
register: () => import('./register.js'),
|
||||
groups: () => import('./groups.js'),
|
||||
'whatsapp-auth': () => import('./whatsapp-auth.js'),
|
||||
mounts: () => import('./mounts.js'),
|
||||
service: () => import('./service.js'),
|
||||
verify: () => import('./verify.js'),
|
||||
onecli: () => import('./onecli.js'),
|
||||
auth: () => import('./auth.js'),
|
||||
'cli-agent': () => import('./cli-agent.js'),
|
||||
};
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install the Claude Code CLI on the host via the official native installer.
|
||||
# Invoked from setup/register-claude-token.sh when the user picks the
|
||||
# subscription auth path and `claude` is missing. The other two auth paths
|
||||
# (paste OAuth token, paste API key) don't need the CLI, so this runs on
|
||||
# demand rather than up front.
|
||||
#
|
||||
# The native installer is Node-independent (downloads a prebuilt binary to
|
||||
# ~/.local/bin) and is the path Anthropic documents. This matches the
|
||||
# pattern used by install-docker.sh / install-node.sh: the script itself is
|
||||
# the allowlisted unit; the curl | bash pipe lives inside it.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_CLAUDE ==="
|
||||
|
||||
if command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: curl not available."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STEP: claude-native-install"
|
||||
curl -fsSL https://claude.ai/install.sh | bash
|
||||
|
||||
# Native installer writes to ~/.local/bin and appends a PATH line to the
|
||||
# user's rc file; that doesn't help this session, so put it on PATH now.
|
||||
if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
fi
|
||||
hash -r 2>/dev/null || true
|
||||
|
||||
if ! command -v claude >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: claude not found on PATH after install."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "CLAUDE_VERSION: $(claude --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-discord — bundles the preflight + install commands
|
||||
# from the /add-discord skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Discord adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/discord package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_DISCORD ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/discord.ts ]] || needs_install=true
|
||||
grep -q "import './discord.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/discord"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/discord ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/discord.ts > src/channels/discord.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './discord.js';" src/channels/index.ts; then
|
||||
printf "import './discord.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/discord@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-docker — bundles Docker install into one idempotent
|
||||
# script so /new-setup can run it without needing `curl | sh` in the allowlist
|
||||
# (pipelines split at matching time, and `sh` receiving stdin can't be
|
||||
# pre-approved safely).
|
||||
#
|
||||
# The script itself is the allowlisted unit; the pipes and sudo live inside
|
||||
# it. Starting the daemon (after install) stays separate — `open -a Docker`
|
||||
# and `sudo systemctl start docker` are already in the allowlist.
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_DOCKER ==="
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin)
|
||||
echo "STEP: brew-install-docker"
|
||||
if ! command -v brew >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run."
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
brew install --cask docker
|
||||
;;
|
||||
Linux)
|
||||
echo "STEP: docker-get-script"
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
echo "STEP: usermod-docker-group"
|
||||
sudo usermod -aG docker "$USER"
|
||||
echo "NOTE: you may need to log out and back in for docker group membership to take effect"
|
||||
;;
|
||||
*)
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: Unsupported platform: $(uname -s)"
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "STATUS: failed"
|
||||
echo "ERROR: docker not found on PATH after install"
|
||||
echo "=== END ==="
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "DOCKER_VERSION: $(docker --version 2>/dev/null || echo unknown)"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-gchat — bundles the preflight + install commands
|
||||
# from the /add-gchat skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Google Chat adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/gchat package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GCHAT ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/gchat.ts ]] || needs_install=true
|
||||
grep -q "import './gchat.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/gchat"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/gchat ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/gchat.ts > src/channels/gchat.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './gchat.js';" src/channels/index.ts; then
|
||||
printf "import './gchat.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/gchat@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-github — bundles the preflight + install commands
|
||||
# from the /add-github skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the GitHub adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned @chat-adapter/github package;
|
||||
# builds. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_GITHUB ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/github.ts ]] || needs_install=true
|
||||
grep -q "import './github.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/github"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/github ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/github.ts > src/channels/github.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './github.js';" src/channels/index.ts; then
|
||||
printf "import './github.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/github@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-imessage — bundles the preflight + install commands
|
||||
# from the /add-imessage skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the iMessage adapter in from the `channels` branch; appends the
|
||||
# self-registration import; installs the pinned chat-adapter-imessage package;
|
||||
# builds. Local vs remote mode pick stays in the skill — this script only
|
||||
# handles the deterministic install. All steps are safe to re-run.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_IMESSAGE ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/imessage.ts ]] || needs_install=true
|
||||
grep -q "import './imessage.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"chat-adapter-imessage"' package.json || needs_install=true
|
||||
[[ -d node_modules/chat-adapter-imessage ]] || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/imessage.ts > src/channels/imessage.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './imessage.js';" src/channels/index.ts; then
|
||||
printf "import './imessage.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install chat-adapter-imessage@0.1.1
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Setup helper: install-linear — bundles the preflight + install commands
|
||||
# from the /add-linear skill into one idempotent script so /new-setup can
|
||||
# run them programmatically before continuing to credentials.
|
||||
#
|
||||
# Copies the Linear adapter in from the `channels` branch; appends the
|
||||
# self-registration import; patches src/channels/chat-sdk-bridge.ts to add
|
||||
# catch-all forwarding (Linear OAuth apps can't be @-mentioned, so the
|
||||
# onNewMention handler never fires — the bridge needs a catchAll path);
|
||||
# installs the pinned @chat-adapter/linear package; builds. All steps are
|
||||
# safe to re-run.
|
||||
#
|
||||
# Note: the bridge patch's onNewMessage handler passes `false` for isMention
|
||||
# (current trunk signature requires the arg). The /add-linear SKILL's
|
||||
# snippet omits the arg — this script uses the full signature so TypeScript
|
||||
# builds cleanly.
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== NANOCLAW SETUP: INSTALL_LINEAR ==="
|
||||
|
||||
needs_install=false
|
||||
[[ -f src/channels/linear.ts ]] || needs_install=true
|
||||
grep -q "import './linear.js';" src/channels/index.ts || needs_install=true
|
||||
grep -q '"@chat-adapter/linear"' package.json || needs_install=true
|
||||
[[ -d node_modules/@chat-adapter/linear ]] || needs_install=true
|
||||
grep -q 'catchAll' src/channels/chat-sdk-bridge.ts || needs_install=true
|
||||
|
||||
if ! $needs_install; then
|
||||
echo "STATUS: already-installed"
|
||||
echo "=== END ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "STEP: fetch-channels-branch"
|
||||
git fetch origin channels
|
||||
|
||||
echo "STEP: copy-files"
|
||||
git show origin/channels:src/channels/linear.ts > src/channels/linear.ts
|
||||
|
||||
echo "STEP: register-import"
|
||||
if ! grep -q "import './linear.js';" src/channels/index.ts; then
|
||||
printf "import './linear.js';\n" >> src/channels/index.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-field"
|
||||
if ! grep -q 'catchAll?: boolean;' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/^export interface ChatSdkBridgeConfig \{/ { in_iface = 1 }
|
||||
in_iface && /^\}/ && !inserted {
|
||||
print " /**"
|
||||
print " * Forward ALL messages in unsubscribed threads, not just @-mentions."
|
||||
print " * Use for platforms where the bot identity can'\''t be @-mentioned (e.g."
|
||||
print " * Linear OAuth apps). The thread is auto-subscribed on first message."
|
||||
print " */"
|
||||
print " catchAll?: boolean;"
|
||||
inserted = 1
|
||||
in_iface = 0
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: patch-bridge-catchall-handler"
|
||||
if ! grep -q 'if (config.catchAll) {' src/channels/chat-sdk-bridge.ts; then
|
||||
awk '
|
||||
/ \/\/ DMs — apply engage rules too/ && !inserted {
|
||||
print " // Catch-all for platforms where @-mention isn'\''t possible (e.g. Linear"
|
||||
print " // OAuth apps). Forward every unsubscribed message and auto-subscribe."
|
||||
print " if (config.catchAll) {"
|
||||
print " chat.onNewMessage(/.*/, async (thread, message) => {"
|
||||
print " const channelId = adapter.channelIdFromThreadId(thread.id);"
|
||||
print " await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false));"
|
||||
print " await thread.subscribe();"
|
||||
print " });"
|
||||
print " }"
|
||||
print ""
|
||||
inserted = 1
|
||||
}
|
||||
{ print }
|
||||
' src/channels/chat-sdk-bridge.ts > src/channels/chat-sdk-bridge.ts.tmp \
|
||||
&& mv src/channels/chat-sdk-bridge.ts.tmp src/channels/chat-sdk-bridge.ts
|
||||
fi
|
||||
|
||||
echo "STEP: pnpm-install"
|
||||
pnpm install @chat-adapter/linear@4.26.0
|
||||
|
||||
echo "STEP: pnpm-build"
|
||||
pnpm run build
|
||||
|
||||
echo "STATUS: installed"
|
||||
echo "=== END ==="
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user