Merge remote-tracking branch 'origin/main' into nc-cli

This commit is contained in:
gavrielc
2026-05-08 15:35:03 +03:00
62 changed files with 2919 additions and 254 deletions

View File

@@ -140,7 +140,7 @@ After accepting, DeltaChat exchanges keys and creates the chat automatically.
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
```bash
sqlite3 data/v2.db \
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT platform_id, name FROM messaging_groups WHERE channel_type='deltachat' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
@@ -226,7 +226,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
1. Check the service is running and the adapter started: `grep "Channel adapter started.*deltachat" logs/nanoclaw.log`
2. Check connectivity: `grep "DeltaChat: IO started" logs/nanoclaw.log`
3. Check the sender has been granted access — run `/init-first-agent` to create their user record and wire the chat
4. Verify the messaging group is wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
4. Verify the messaging group is wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mga.agent_group_id FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='deltachat'"`
### Stale lock file after crash
@@ -248,7 +248,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -20
The messaging group exists but may not be wired to an agent group. Run:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat'"
```
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.

View File

@@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10
## 4. Check messaging group was created
```bash
sqlite3 data/v2.db \
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id, name FROM messaging_groups WHERE channel_type='deltachat' ORDER BY created_at DESC LIMIT 5"
```
@@ -48,7 +48,7 @@ If a row appears, the inbound routing is working. If not, the adapter isn't rece
If the message arrived but the agent didn't respond, the sender may not have access:
```bash
sqlite3 data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, display_name FROM users WHERE id LIKE 'deltachat:%'"
```
Grant access as shown in the SKILL.md "Grant user access" section.

View File

@@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
### 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'"`
2. Messaging group wired: `pnpm exec tsx scripts/q.ts 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`
If no messaging group row exists, run the `register` command above.
@@ -292,5 +292,5 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# 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';"
pnpm exec tsx scripts/q.ts 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';"
```

View File

@@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c
onecli agents list
```
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
```bash
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
onecli agents secrets --id <agent-id>
```
## Phase 2: Apply Code Changes

View File

@@ -71,38 +71,11 @@ AskUserQuestion: "Want periodic wiki health checks?"
2. **Monthly**
3. **Skip** — lint manually
If yes, create a NanoClaw scheduled task that runs in the wiki group. This is NOT a Claude Code cron job — it's a NanoClaw group task that runs in the agent container. Insert it into the SQLite database:
If yes, ask the agent to schedule the lint task using the `schedule_task` MCP tool in conversation.
## Step 6: Restart
```bash
pnpm exec tsx -e "
const Database = require('better-sqlite3');
const { CronExpressionParser } = require('cron-parser');
const db = new Database('store/messages.db');
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
const nextRun = interval.next().toISOString();
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
'wiki-lint',
'<group_folder>',
'<chat_jid>',
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
'cron',
'<cron-expr>',
'group',
nextRun,
'active',
new Date().toISOString()
);
db.close();
"
```
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
## Step 6: Build and restart
```bash
pnpm run build
./container/build.sh
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```

View File

@@ -0,0 +1,208 @@
---
name: add-mnemon
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
---
# Add Mnemon — Persistent Memory
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
## Provider Compatibility
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
Check your provider:
```bash
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
```
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
## Phase 1: Pre-flight
### Check if already applied
```bash
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
```
If already applied, skip to Phase 3 (Verify).
### Check latest mnemon version
```bash
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
```
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
## Phase 2: Apply Changes (Claude Code path)
### 1. Dockerfile — install mnemon binary
Add after the AWS CLI block, before the Bun runtime section:
```dockerfile
# ---- mnemon — persistent agent memory ----------------------------------------
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
### 2. Entrypoint — run mnemon setup on each container start
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
```bash
#!/bin/bash
# NanoClaw agent container entrypoint.
#
# ...existing header comment...
set -e
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
cat > /tmp/input.json
exec bun run /app/src/index.ts < /tmp/input.json
```
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
### 3. Rebuild and smoke-test the image
```bash
./container/build.sh
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Phase 3: Restart and Verify
### Restart the service
```bash
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
### Confirm mnemon hooks are registered
After the next container starts, check that setup ran:
```bash
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
```
Then inspect the hooks inside the running container:
```bash
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
cat /home/node/.claude/settings.json | grep -A5 mnemon
```
### Test memory recall
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
## Phase 2 (OpenCode path) — context injection
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
Then rebuild: `./container/build.sh`
### Verify (OpenCode)
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
```bash
# Also confirm the binary is present in the image:
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
```
## Memory Storage
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
```bash
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
```
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
## Migration Guide Update
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
**Dockerfile — after AWS CLI, before Bun runtime:**
```dockerfile
ARG MNEMON_VERSION=0.1.1
RUN ARCH=$(dpkg --print-architecture) && \
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin mnemon && \
chmod +x /usr/local/bin/mnemon
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
```
**`container/entrypoint.sh` — add after `set -e`:**
```bash
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
```
## Troubleshooting
### `mnemon: command not found` in container
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
### Memory not persisting across restarts
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
```bash
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
```
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
### Agent not using past memory
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
```bash
docker exec <container> cat /home/node/.claude/settings.json
```
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
### Setup fails at container start
Run setup manually inside a running container to see the full error:
```bash
docker exec -it <container> mnemon setup --target claude-code --yes --global
```

View File

@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
Ask the user (plain text, not AskUserQuestion):
1. **Which agent group?** List available groups: `sqlite3 data/v2.db "SELECT folder, name FROM agent_groups;"`
1. **Which agent group?** List available groups: `pnpm exec tsx scripts/q.ts 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.
@@ -111,7 +111,7 @@ 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>';")
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
```

View File

@@ -132,12 +132,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
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>
```
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
#### Example: DeepSeek

View File

@@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
- Check agent-runner logs for "Parallel AI MCP servers configured" message
**Task polling not working:**
- Verify scheduled task was created: `sqlite3 store/messages.db "SELECT * FROM scheduled_tasks"`
- Verify scheduled task was created: `pnpm exec tsx scripts/q.ts store/messages.db "SELECT * FROM scheduled_tasks"`
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
- Ensure task prompt includes proper Parallel MCP tool names

View File

@@ -200,7 +200,7 @@ systemctl --user restart nanoclaw
After the service starts, send any message to the Signal number from your personal Signal app. The router auto-creates a `messaging_groups` row. Then:
```bash
sqlite3 data/v2.db \
pnpm exec tsx scripts/q.ts data/v2.db \
"SELECT id, platform_id FROM messaging_groups WHERE channel_type='signal' ORDER BY created_at DESC LIMIT 5"
```
@@ -212,7 +212,7 @@ Add the Signal number to a group from your phone, send any message, then wire th
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR IGNORE INTO messaging_group_agents
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
VALUES
@@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi
```bash
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sqlite3 data/v2.db "
pnpm exec tsx scripts/q.ts data/v2.db "
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
@@ -282,8 +282,13 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
### Bot not responding
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
2. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"`
3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
4. **Check for duplicate service instances** — if `logs/nanoclaw.error.log` shows `No adapter for channel type channelType="signal"` despite the adapter starting, two NanoClaw processes are racing. See the `/debug` skill section "No adapter for channel type / Messages silently lost" for the full fix.
### Messages delivered but never arrive (null platformMsgId)
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
### Lost connection mid-session

View File

@@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
```bash
# For each agent, add the Vercel secret to its assigned secrets list.
# First get current assignments, then set them with the new secret appended.
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
# set-secrets replaces the entire list — read and merge for each agent.
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
for agent in $(onecli agents list | jq -r '.data[].id'); do
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
done
```

View File

@@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
- **type**: `whatsapp`
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
- **supports-threads**: no
- **typical-use**: Interactive chat — direct messages or small groups
- **default-isolation**: Same agent group if you're the only participant across multiple chats. Separate agent group if different people are in different groups.
@@ -256,7 +256,7 @@ systemctl --user start nanoclaw
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
3. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
4. Service running: `systemctl --user status nanoclaw`
### "conflict" disconnection

View File

@@ -57,7 +57,50 @@ Debug level shows:
## Common Issues
### 1. "Claude Code process exited with code 1"
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
```
WARN No adapter for channel type channelType="telegram"
WARN No adapter for channel type channelType="signal"
```
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
**Root cause: two NanoClaw service instances running simultaneously.**
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
**Diagnosis:**
```bash
# Check for duplicate running instances
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
# Check which services are active
systemctl --user list-units 'nanoclaw*' --all
# Confirm channel adapters registered by the current process
grep "Channel adapter started" logs/nanoclaw.log | tail -10
```
**Fix:**
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
2. Stop and disable the stale duplicate service:
```bash
systemctl --user stop nanoclaw.service # or whichever is the old one
systemctl --user disable nanoclaw.service
```
3. If the remaining service unit is missing `EnvironmentFile`, add it:
```bash
# Edit the service unit — add this line under [Service]:
# EnvironmentFile=/home/[user]/nanoclaw/.env
systemctl --user daemon-reload
systemctl --user restart nanoclaw-v2-<id>.service
```
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
### 2. "Claude Code process exited with code 1"
**Check the container log file** in `groups/{folder}/logs/container-*.log`
@@ -279,7 +322,7 @@ rm -rf data/sessions/
rm -rf data/sessions/{groupFolder}/.claude/
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
sqlite3 store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
pnpm exec tsx scripts/q.ts store/messages.db "DELETE FROM sessions WHERE group_folder = '{groupFolder}'"
```
To verify session resumption is working, check the logs for the same session ID across messages:

View File

@@ -54,7 +54,7 @@ Tell the user:
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
```bash
sqlite3 data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
pnpm exec tsx scripts/q.ts data/v2.db "SELECT id, platform_id, name, created_at FROM messaging_groups WHERE channel_type='${CHANNEL}' AND is_group=0 ORDER BY created_at DESC LIMIT 5"
```
Show the top rows to the user and confirm which `platform_id` is theirs (usually the most recent). Record as `PLATFORM_ID`. If none appeared, check `logs/nanoclaw.log` for `unknown_sender` drops — the adapter might be rejecting inbound due to connection or permission issues.
@@ -103,7 +103,7 @@ Wait for the user's reply. If they confirm receipt, the skill is done.
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.

View File

@@ -259,6 +259,41 @@ Tell the user:
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
- To add rate limits or policies: `onecli rules create --help`
## Granting secrets to agents (safe merge)
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
```bash
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
onecli agents secrets --id "$AGENT_ID"
```
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
- `<new-secret-id>` — the `id` from `onecli secrets list`
- Multiple new secrets: append them comma-separated before the `printf` step
### git over HTTPS
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
```json
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
"GIT_TERMINAL_PROMPT": "0",
"GIT_CONFIG_COUNT": "1",
"GIT_CONFIG_KEY_0": "credential.helper",
"GIT_CONFIG_VALUE_0": "",
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
```
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
## Troubleshooting
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.

View File

@@ -11,7 +11,22 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
## Assess Current State
Read the central DB (`data/v2.db`) — query `agent_groups`, `messaging_groups`, `messaging_group_agents`, `users`, and `user_roles` tables. Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
```bash
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
```
```sql
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
SELECT id, channel_type, platform_id, name, unknown_sender_policy FROM messaging_groups;
SELECT messaging_group_id, agent_group_id, session_mode, priority FROM messaging_group_agents;
SELECT user_id, role, agent_group_id FROM user_roles ORDER BY role='owner' DESC;
```
Also check `.env` for channel tokens and `src/channels/index.ts` for uncommented imports.
Categorize channels as: **wired** (has DB entities + messaging_group_agents row), **configured but unwired** (has credentials + barrel import, no DB entities), or **not configured**.

View File

@@ -53,6 +53,8 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
## Key Files
| File | Purpose |
@@ -74,7 +76,7 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
| `container/skills/` | Container skills mounted into every agent session |
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
@@ -98,7 +100,7 @@ A second tier (direct source-level self-edits via a draft/activate flow) is plan
## Secrets / Credentials / OneCLI
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`.
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. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
### Gotcha: auto-created agents start in `selective` secret mode
@@ -142,7 +144,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`).
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
| Skill | When to Use |
|-------|-------------|

View File

@@ -215,3 +215,5 @@ See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release hist
## License
MIT
<img referrerpolicy="no-referrer-when-downgrade" src="https://static.scarf.sh/a.png?x-pxid=47894bd5-353b-42fe-bb97-74144e6df0bf" />

View File

@@ -91,7 +91,13 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
# the SDK fails at spawn time with "native binary not found".
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
# silently skips claude-code's native-binary postinstall and agent-browser's
# bin chmod — the agent then crashes at runtime with "native binary not
# installed". Keep this in lockstep with package.json's `packageManager`.
ARG PNPM_VERSION=10.33.0
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
RUN --mount=type=cache,target=/root/.cache/pnpm \
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \

View File

@@ -0,0 +1,34 @@
/**
* PreCompact hook script — outputs custom compaction instructions to stdout.
*
* Claude Code captures the stdout of PreCompact shell hooks and passes it
* as `customInstructions` to the compaction prompt. This ensures the
* compaction summary preserves message routing context that the agent needs
* to correctly address responses.
*
* Invoked by the PreCompact hook in .claude-shared/settings.json:
* "command": "bun /app/src/compact-instructions.ts"
*/
import { getAllDestinations } from './destinations.js';
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name);
const instructions = [
'Preserve the following in the compaction summary:',
'',
'1. For recent messages, keep the full XML structure including all attributes:',
' - <message from="..." sender="..." time="..."> for chat messages',
' - <task from="..." time="..."> for scheduled tasks',
' - <webhook from="..." source="..." event="..."> for webhooks',
' The message content can be summarized if long, but the XML tags and attributes must remain.',
'',
'2. Preserve the chronological message/reply sequence of recent exchanges.',
' The agent needs to see: who said what, in what order, and from which destination.',
'',
'3. The `from` attribute identifies which destination sent the message.',
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
];
console.log(instructions.join('\n'));

View File

@@ -0,0 +1,29 @@
/**
* Per-batch context the poll loop publishes for downstream consumers
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
*
* Today the only field is `inReplyTo` — the id of the first inbound
* message in the batch the agent is currently processing. MCP tools like
* `send_message` and `send_file` read this and stamp it onto the outbound
* row so the host's a2a return-path routing can correlate replies back to
* the originating session.
*
* This is module-level state on purpose: the agent-runner is single-process
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
* before invoking the provider and `clearCurrentInReplyTo` after the batch
* completes (or errors out).
*/
let currentInReplyTo: string | null = null;
export function setCurrentInReplyTo(id: string | null): void {
currentInReplyTo = id;
}
export function clearCurrentInReplyTo(): void {
currentInReplyTo = null;
}
export function getCurrentInReplyTo(): string | null {
return currentInReplyTo;
}

View File

@@ -0,0 +1,63 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
import { buildSystemPromptAddendum } from './destinations.js';
beforeEach(() => {
initTestSessionDb();
});
afterEach(() => {
closeSessionDb();
});
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, displayName, channelType, platformId);
}
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
it('includes default-routing nudge when there are >1 destinations', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('requires explicit wrapping even for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
it('handles the no-destination case without crashing', () => {
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('Default routing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('`casa`');
});
});

View File

@@ -102,32 +102,28 @@ function buildDestinationsSection(): string {
].join('\n');
}
// Single-destination shortcut: the agent just writes its response normally.
const lines = ['## Sending messages', ''];
if (all.length === 1) {
const d = all[0];
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
return [
'## Sending messages',
'',
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
'',
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
'',
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
].join('\n');
}
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
lines.push(`Your destination is \`${d.name}\`${label}.`);
} else {
lines.push('You can send messages to the following destinations:', '');
for (const d of all) {
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
lines.push(`- \`${d.name}\`${label}`);
}
}
lines.push('');
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
);

View File

@@ -177,40 +177,49 @@ function formatSingleChat(msg: MessageInRow): string {
const replyPrefix = formatReplyContext(content.replyTo);
const attachmentsSuffix = formatAttachments(content.attachments);
// Look up the destination name for the origin (reverse map lookup).
// If not found, fall back to a raw channel:platform_id marker so nothing
// gets silently dropped — this should only happen if the destination was
// removed between when the message was received and when it's being processed.
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
const fromAttr = fromDest
? ` from="${escapeXml(fromDest.name)}"`
: msg.channel_type || msg.platform_id
? ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`
: '';
const fromAttr = originAttr(msg);
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
}
/**
* Build a ` from="destination_name"` attribute string from a message's routing
* fields. Shared by all formatters so the agent always knows where a message
* originated — critical for explicit addressing.
*/
function originAttr(msg: MessageInRow): string {
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
if (msg.channel_type || msg.platform_id) {
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
}
return '';
}
function formatTaskMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const parts = ['[SCHEDULED TASK]'];
const from = originAttr(msg);
const time = formatLocalTime(msg.timestamp, TIMEZONE);
const parts: string[] = [];
if (content.scriptOutput) {
parts.push('', 'Script output:', JSON.stringify(content.scriptOutput, null, 2));
parts.push('Script output:', JSON.stringify(content.scriptOutput, null, 2), '');
}
parts.push('', 'Instructions:', content.prompt || '');
return parts.join('\n');
parts.push('Instructions:', content.prompt || '');
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
}
function formatWebhookMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
const source = content.source || 'unknown';
const event = content.event || 'unknown';
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
const from = originAttr(msg);
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
}
function formatSystemMessage(msg: MessageInRow): string {
const content = parseContent(msg.content);
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
const from = originAttr(msg);
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
}
/**

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { getContinuation, setContinuation } from './db/session-state.js';
import { MockProvider } from './providers/mock.js';
import { runPollLoop } from './poll-loop.js';
@@ -74,6 +75,163 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should resolve thread_id per-destination, not from global routing', async () => {
// Seed a second destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
// Insert messages from each destination with distinct thread IDs
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
// Agent replies to both destinations
const provider = new MockProvider({}, () =>
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
const discordOut = out.find((m) => m.platform_id === 'chan-1');
const slackOut = out.find((m) => m.platform_id === 'chan-2');
expect(discordOut).toBeDefined();
expect(discordOut!.thread_id).toBe('discord-thread-1');
expect(discordOut!.in_reply_to).toBe('m-discord');
expect(slackOut).toBeDefined();
expect(slackOut!.thread_id).toBe('slack-thread-99');
expect(slackOut!.in_reply_to).toBe('m-slack');
await loopPromise.catch(() => {});
});
it('bare text produces no outbound messages (scratchpad only)', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
// Agent responds with bare text — no <message to="..."> wrapping
const provider = new MockProvider({}, () => 'I am thinking about this...');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
// Wait long enough for the poll loop to process
await sleep(1000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(0);
await loopPromise.catch(() => {});
});
it('unknown destination is dropped, valid destination is sent', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
// Only the valid destination should produce output
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('delivered');
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
it('multiple <message> blocks each produce an outbound message', async () => {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(2);
const discord = out.find((m) => m.platform_id === 'chan-1');
const slack = out.find((m) => m.platform_id === 'chan-2');
expect(discord).toBeDefined();
expect(JSON.parse(discord!.content).text).toBe('for discord');
expect(slack).toBeDefined();
expect(JSON.parse(slack!.content).text).toBe('for slack');
await loopPromise.catch(() => {});
});
it('sends null thread_id when no prior inbound from destination', async () => {
// Seed a second destination that has NO inbound messages
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
)
.run();
// Only insert a message from discord — slack-new has never sent anything
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-new');
expect(out[0].thread_id).toBeNull();
await loopPromise.catch(() => {});
});
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
// Two messages from same destination, different threads
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].thread_id).toBe('thread-new');
expect(out[0].in_reply_to).toBe('m-new');
await loopPromise.catch(() => {});
});
it('should process messages arriving after loop starts', async () => {
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
const controller = new AbortController();
@@ -91,8 +249,161 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('internal tags between message blocks are stripped from scratchpad', async () => {
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new MockProvider(
{},
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
);
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('answer');
await loopPromise.catch(() => {});
});
it('handles mixed task + chat batch with correct origin metadata', async () => {
// Seed destination for routing lookup
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
// Task with same routing — simulates a scheduled task in a channel session
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ prompt: 'daily check' }));
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].platform_id).toBe('chan-1');
await loopPromise.catch(() => {});
});
it('should inject destination reminder after a compacted event', async () => {
// Two destinations — required for the reminder to fire (single-destination
// groups have a fallback path that works without <message to="…"> wrapping).
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
expect(reminder).toBeDefined();
expect(reminder).toContain('2 destinations');
expect(reminder).toContain('discord-test');
expect(reminder).toContain('discord-second');
expect(reminder).toContain('<message to="name">');
await loopPromise.catch(() => {});
});
it('should NOT inject destination reminder with a single destination', async () => {
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
// Only the original prompt push (if any) — no reminder, since beforeEach
// seeds exactly one destination.
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
expect(reminders).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that emits a single compacted event mid-stream, then returns a
* result. Captures every push() call so tests can assert on the injected
* reminder content.
*/
class CompactingProvider {
readonly supportsNativeSlashCommands = false;
readonly pushes: string[] = [];
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const pushes = this.pushes;
let ended = false;
let aborted = false;
let resolveWaiter: (() => void) | null = null;
async function* events() {
yield { type: 'activity' as const };
yield { type: 'init' as const, continuation: 'compaction-test-session' };
yield { type: 'activity' as const };
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
// Wait for poll-loop to push the reminder (or end / abort)
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
// Belt-and-braces: don't hang forever if the reminder never arrives
setTimeout(resolve, 200);
});
yield { type: 'activity' as const };
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
while (!ended && !aborted) {
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
setTimeout(resolve, 50);
});
}
}
return {
push(message: string) {
pushes.push(message);
resolveWaiter?.();
},
end() {
ended = true;
resolveWaiter?.();
},
abort() {
aborted = true;
resolveWaiter?.();
},
events: events(),
};
}
}
// Helper: run poll loop until aborted or timeout
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([
@@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('poll loop — provider error recovery', () => {
it('writes error to outbound and continues loop on provider throw', async () => {
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new ThrowingProvider('API rate limit exceeded');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
// Input message should be marked completed despite the error
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
describe('poll loop — stale session recovery', () => {
it('clears continuation when provider reports session invalid', async () => {
// Pre-seed a continuation so the local variable in runPollLoop is set.
// Without this, the `if (continuation && isSessionInvalid)` check skips.
setContinuation('mock', 'pre-existing-session');
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new InvalidSessionProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
// Error was written to outbound
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
// Continuation was cleared (isSessionInvalid returned true)
expect(getContinuation('mock')).toBeUndefined();
await loopPromise.catch(() => {});
});
});
describe('poll loop — /clear command', () => {
it('clears session, writes confirmation, skips query', async () => {
// Seed a continuation so we can verify it gets cleared
setContinuation('mock', 'existing-session-id');
expect(getContinuation('mock')).toBe('existing-session-id');
// Insert a /clear command
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ text: '/clear' }));
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
// Continuation was cleared
expect(getContinuation('mock')).toBeUndefined();
// Command message was completed
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that throws on every query, simulating API failures.
*/
class ThrowingProvider {
readonly supportsNativeSlashCommands = false;
private errorMessage: string;
constructor(errorMessage: string) {
this.errorMessage = errorMessage;
}
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const errorMessage = this.errorMessage;
return {
push() {},
end() {},
abort() {},
events: (async function* () {
throw new Error(errorMessage);
})(),
};
}
}
/**
* Provider that throws with an error that triggers isSessionInvalid.
* First emits an init event (setting continuation), then throws.
*/
class InvalidSessionProvider {
readonly supportsNativeSlashCommands = false;
isSessionInvalid(): boolean {
return true;
}
query(_input: { prompt: string; cwd: string }) {
return {
push() {},
end() {},
abort() {},
events: (async function* () {
yield { type: 'init' as const, continuation: 'doomed-session' };
throw new Error('session not found');
})(),
};
}
}

View File

@@ -0,0 +1,50 @@
/**
* Tests for the core MCP tools' interaction with the per-batch routing
* context. The agent-runner sets a current `inReplyTo` at the top of each
* batch in poll-loop, and outbound writes from MCP tools (send_message,
* send_file) must pick it up so a2a return-path routing on the host can
* correlate replies back to the originating session.
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
import { getUndeliveredMessages } from '../db/messages-out.js';
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
import { sendMessage } from './core.js';
beforeEach(() => {
initTestSessionDb();
// Seed a peer agent destination
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
)
.run();
});
afterEach(() => {
clearCurrentInReplyTo();
closeSessionDb();
});
describe('send_message MCP tool — in_reply_to plumbing', () => {
it('stamps current batch in_reply_to on outbound rows', async () => {
setCurrentInReplyTo('inbound-msg-1');
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBe('inbound-msg-1');
});
it('writes null when no batch is active', async () => {
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
await sendMessage.handler({ to: 'peer', text: 'hello' });
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(out[0].in_reply_to).toBeNull();
});
});

View File

@@ -9,6 +9,7 @@
import fs from 'fs';
import path from 'path';
import { getCurrentInReplyTo } from '../current-batch.js';
import { findByName, getAllDestinations } from '../destinations.js';
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
import { getSessionRouting } from '../db/session-routing.js';
@@ -50,9 +51,7 @@ function destinationList(): string {
*/
function resolveRouting(
to: string | undefined,
):
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
| { error: string } {
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
if (!to) {
// Default: reply to whatever thread/channel this session is bound to.
const session = getSessionRouting();
@@ -82,9 +81,7 @@ function resolveRouting(
// preserve the thread_id so replies land in the correct thread.
const session = getSessionRouting();
const threadId =
session.channel_type === dest.channelType && session.platform_id === dest.platformId
? session.thread_id
: null;
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
return {
channel_type: dest.channelType!,
platform_id: dest.platformId!,
@@ -98,12 +95,14 @@ function resolveRouting(
export const sendMessage: McpToolDefinition = {
tool: {
name: 'send_message',
description:
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
inputSchema: {
type: 'object' as const,
properties: {
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
to: {
type: 'string',
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
},
text: { type: 'string', description: 'Message content' },
},
required: ['text'],
@@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = {
const id = generateId();
const seq = writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,
@@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = {
writeMessageOut({
id,
in_reply_to: getCurrentInReplyTo(),
kind: 'chat',
platform_id: routing.platform_id,
channel_type: routing.channel_type,

View File

@@ -47,7 +47,7 @@ describe('formatter', () => {
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[SCHEDULED TASK]');
expect(prompt).toContain('<task');
expect(prompt).toContain('Review open PRs');
});
@@ -55,15 +55,17 @@ describe('formatter', () => {
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[WEBHOOK: github/push]');
expect(prompt).toContain('<webhook');
expect(prompt).toContain('source="github"');
expect(prompt).toContain('event="push"');
});
it('should format system messages', () => {
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('[SYSTEM RESPONSE]');
expect(prompt).toContain('register_group');
expect(prompt).toContain('<system_response');
expect(prompt).toContain('action="register_group"');
});
it('should handle mixed kinds', () => {
@@ -72,7 +74,7 @@ describe('formatter', () => {
const messages = getPendingMessages();
const prompt = formatMessages(messages);
expect(prompt).toContain('sender="John"');
expect(prompt).toContain('[SYSTEM RESPONSE]');
expect(prompt).toContain('<system_response');
});
it('should escape XML in content', () => {
@@ -147,6 +149,76 @@ describe('routing', () => {
});
});
describe('origin metadata (from= attribute)', () => {
function seedDestination(name: string, channelType: string, platformId: string): void {
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES (?, ?, 'channel', ?, ?, NULL)`,
)
.run(name, name, channelType, platformId);
}
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
)
.run(id, kind, platformId, channelType, JSON.stringify(content));
}
it('chat message includes from= when destination matches', () => {
seedDestination('discord-main', 'discord', 'chan-1');
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('from="discord-main"');
});
it('chat message falls back to raw routing when no destination matches', () => {
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('from="unknown:telegram:chat-999"');
});
it('chat message omits from= when routing is null', () => {
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
const prompt = formatMessages(getPendingMessages());
expect(prompt).not.toContain('from=');
});
it('task message includes from= when destination matches', () => {
seedDestination('slack-ops', 'slack', 'C-OPS');
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<task');
expect(prompt).toContain('from="slack-ops"');
});
it('task message omits from= when routing is null', () => {
insertMessage('t1', 'task', { prompt: 'check status' });
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<task');
expect(prompt).not.toContain('from=');
});
it('webhook message includes from= when destination matches', () => {
seedDestination('github-ch', 'github', 'repo-1');
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<webhook');
expect(prompt).toContain('from="github-ch"');
});
it('system message includes from= when destination matches', () => {
seedDestination('discord-main', 'discord', 'chan-1');
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
const prompt = formatMessages(getPendingMessages());
expect(prompt).toContain('<system_response');
expect(prompt).toContain('from="discord-main"');
});
});
describe('mock provider', () => {
it('should produce init + result events', async () => {
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);

View File

@@ -1,13 +1,18 @@
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
import {
clearContinuation,
migrateLegacyContinuation,
setContinuation,
} from './db/session-state.js';
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js';
formatMessages,
extractRouting,
categorizeMessage,
isClearCommand,
isRunnerCommand,
stripInternalTags,
type RoutingContext,
} from './formatter.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -170,6 +175,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
// Process the query while concurrently polling for new messages
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
// can stamp it on outbound rows — needed for a2a return-path routing.
setCurrentInReplyTo(routing.inReplyTo);
try {
const result = await processQuery(query, routing, processingIds, config.providerName);
if (result.continuation && result.continuation !== continuation) {
@@ -198,6 +206,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
thread_id: routing.threadId,
content: JSON.stringify({ text: `Error: ${errMsg}` }),
});
} finally {
clearCurrentInReplyTo();
}
// Ensure completed even if processQuery ended without a result event
@@ -366,6 +376,23 @@ async function processQuery(
if (event.text) {
dispatchResultText(event.text, routing);
}
} else if (event.type === 'compacted') {
// The SDK auto-compacted the conversation. After compaction the
// model often drops the learned `<message to="…">` wrapping
// discipline (the destinations are still in the system prompt,
// but the behavioral pattern is summarized away). Inject a
// reminder back into the live query so the next turn re-anchors
// on the destination model. Only do this when there's >1
// destination — single-destination groups have a fallback that
// works without wrapping. See qwibitai/nanoclaw#2325.
const destinations = getAllDestinations();
if (destinations.length > 1) {
const names = destinations.map((d) => d.name).join(', ');
query.push(
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
);
}
}
}
} finally {
@@ -385,25 +412,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
break;
case 'error':
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
log(
`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
);
break;
case 'progress':
log(`Progress: ${event.message}`);
break;
case 'compacted':
log(`Compacted: ${event.text}`);
break;
}
}
/**
* Parse the agent's final text for <message to="name">...</message> blocks
* and dispatch each one to its resolved destination. Text outside of blocks
* (including <internal>...</internal>) is normally scratchpad — logged but
* not sent.
* (including <internal>...</internal>) is scratchpad — logged but not sent.
*
* Single-destination shortcut: if the agent has exactly one configured
* destination AND the output contains zero <message> blocks, the entire
* cleaned text (with <internal> tags stripped) is sent to that destination.
* This preserves the simple case of one user on one channel — the agent
* doesn't need to know about wrapping syntax at all.
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
*/
function dispatchResultText(text: string, routing: RoutingContext): void {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
@@ -436,30 +464,6 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
const scratchpad = stripInternalTags(scratchpadParts.join(''));
// Single-destination shortcut: the agent wrote plain text — send to
// the session's originating channel (from session_routing) if available,
// otherwise fall back to the single destination.
if (sent === 0 && scratchpad) {
if (routing.channelType && routing.platformId) {
// Reply to the channel/thread the message came from
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
kind: 'chat',
platform_id: routing.platformId,
channel_type: routing.channelType,
thread_id: routing.threadId,
content: JSON.stringify({ text: scratchpad }),
});
return;
}
const all = getAllDestinations();
if (all.length === 1) {
sendToDestination(all[0], scratchpad, routing);
return;
}
}
if (scratchpad) {
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
@@ -472,20 +476,46 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
// Inherit thread_id from the inbound routing context so replies land in the
// same thread the conversation is in. For non-threaded adapters the router
// strips thread_id at ingest, so this will already be null.
// Resolve thread_id per-destination from the most recent inbound message
// that came from this same channel+platform. In agent-shared sessions,
// different destinations have different thread contexts — using a single
// routing.threadId would stamp one channel's thread onto another.
const destRouting = resolveDestinationThread(channelType, platformId);
writeMessageOut({
id: generateId(),
in_reply_to: routing.inReplyTo,
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
kind: 'chat',
platform_id: platformId,
channel_type: channelType,
thread_id: routing.threadId,
thread_id: destRouting?.threadId ?? null,
content: JSON.stringify({ text: body }),
});
}
/**
* Find the thread_id and message id from the most recent inbound message
* matching the given channel+platform. Returns null if no match found.
*/
function resolveDestinationThread(
channelType: string,
platformId: string,
): { threadId: string | null; inReplyTo: string | null } | null {
try {
const db = getInboundDb();
const row = db
.prepare(
`SELECT thread_id, id FROM messages_in
WHERE channel_type = ? AND platform_id = ?
ORDER BY seq DESC LIMIT 1`,
)
.get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
if (row) return { threadId: row.thread_id, inReplyTo: row.id };
} catch (err) {
log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
}
return null;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -329,7 +329,7 @@ export class ClaudeProvider implements AgentProvider {
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'result', text: `Context compacted${detail}.` };
yield { type: 'compacted', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };

View File

@@ -79,4 +79,12 @@ export type ProviderEvent =
* event (tool call, thinking, partial message, anything) so the
* poll-loop's idle timer stays honest during long tool runs.
*/
| { type: 'activity' };
| { type: 'activity' }
/**
* The provider's underlying SDK auto-compacted the conversation context.
* The poll-loop reacts by injecting a destination reminder back into
* the live query so the agent doesn't drop `<message to="…">` wrapping
* after compaction. Distinct from `result` so it doesn't mark the turn
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
*/
| { type: 'compacted'; text: string };

View File

@@ -0,0 +1,85 @@
---
name: onecli-gateway
description: >-
OneCLI Gateway: transparent HTTPS proxy that injects stored credentials
into outbound calls. You MUST use this skill when the user asks you to
read emails, check calendar, access GitHub repos, create issues, check
Stripe payments, or interact with ANY external service or API. Do NOT
use browser extensions or OAuth CLI tools. Make HTTP requests directly;
the gateway injects credentials automatically.
compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`)
metadata:
author: onecli
version: "0.5.0"
---
# OneCLI Gateway
Your outbound HTTPS traffic is transparently proxied through the OneCLI
gateway, which injects stored credentials at the proxy boundary. You never
see or handle credential values directly.
## How to Access External Services
You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub,
Google Calendar, Google Drive, etc.) and API key services are all available
through the gateway. Just make the request directly; the gateway injects
credentials if the app is connected. If not, it returns an error with a
connect URL you can present to the user.
## Making Requests
Call the real API URL. The gateway intercepts the request and injects
credentials automatically.
```bash
curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5"
curl -s "https://api.github.com/user/repos?per_page=10"
curl -s "https://api.stripe.com/v1/charges?limit=5"
```
Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all
honor the `HTTPS_PROXY` environment variable automatically. You do not need
to set any auth headers.
## Credential Stubs for MCP Servers
Some MCP servers need local credential files to start. Stubs for connected
apps are pre-written automatically. Files containing `"onecli-managed"`
values are managed by OneCLI — do NOT modify or delete them.
If an MCP server won't start due to missing credentials, create stubs
**before** starting it. Use `"onecli-managed"` as the placeholder for all
secret values, with file permissions `0600`. See the guide at:
https://www.onecli.sh/docs/guides/credential-stubs/general-app
## When a Request Fails
If you get a 401, 403, or a gateway error (e.g., `app_not_connected`):
**Step 1 — Show the user a connect link.** Use the `connect_url` from the
error response:
> To connect [service], open this link:
> [connect_url from the error response]
If there is no `connect_url` in the error, tell the user to open the
OneCLI dashboard and connect the service there.
**Step 2 — Retry after the user connects.** Let the user know you will
retry once they have connected. When they confirm, retry the original
request. If the retry still fails, ask if they need help with the setup.
## Rules
- **Never** say "I don't have access to X" without first making the HTTP
request through the proxy.
- **Never** use browser extensions, gcloud, or manual auth flows. The
gateway handles credentials for you.
- **Never** ask the user for API keys or tokens directly. Direct them to
connect the service in the OneCLI dashboard.
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
when they ask you to read or interact with those services. You have API
access. Use it.
- If the gateway returns a policy error (403 with a JSON body), respect
the block. Do not retry or circumvent it.

View File

@@ -0,0 +1,7 @@
# Credentials & External Services
Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service.
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.

View File

@@ -242,8 +242,12 @@ fi
V1_DB="$V1_PATH/store/messages.db"
# Quick schema check — make sure the tables we need exist
TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true)
# Quick schema check — make sure the tables we need exist.
# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via
# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI,
# and #2191 documented how a missing CLI here used to surface as a
# misleading "registered_groups missing" abort.
TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 2>/dev/null || true)
if echo "$TABLES" | grep -q "registered_groups"; then
step_ok "v1 database has registered_groups"
@@ -253,8 +257,8 @@ else
fi
# Show what we found
GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0)
GROUP_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0)
ENV_KEYS=0
if [ -f "$V1_PATH/.env" ]; then
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
@@ -450,7 +454,7 @@ ONECLI_OK=false
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then
if curl -sf "${ONECLI_URL_CHECK}/api/health" >/dev/null 2>&1; then
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
ONECLI_OK=true
log "OneCLI: running at $ONECLI_URL_CHECK"

View File

@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.0.32",
"version": "2.0.45",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
@@ -26,7 +26,7 @@
"dependencies": {
"@clack/core": "^1.2.0",
"@clack/prompts": "^1.2.0",
"@onecli-sh/sdk": "^0.3.1",
"@onecli-sh/sdk": "^0.5.0",
"better-sqlite3": "11.10.0",
"chat": "^4.24.0",
"cron-parser": "5.5.0",

10
pnpm-lock.yaml generated
View File

@@ -15,8 +15,8 @@ importers:
specifier: ^1.2.0
version: 1.2.0
'@onecli-sh/sdk':
specifier: ^0.3.1
version: 0.3.1
specifier: ^0.5.0
version: 0.5.0
better-sqlite3:
specifier: 11.10.0
version: 11.10.0
@@ -303,8 +303,8 @@ packages:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@onecli-sh/sdk@0.3.1':
resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==}
'@onecli-sh/sdk@0.5.0':
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
engines: {node: '>=20'}
'@oxc-project/types@0.124.0':
@@ -1665,7 +1665,7 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@onecli-sh/sdk@0.3.1': {}
'@onecli-sh/sdk@0.5.0': {}
'@oxc-project/types@0.124.0': {}

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="141k tokens, 71% of context window">
<title>141k tokens, 71% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="150k tokens, 75% of context window">
<title>150k tokens, 75% 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="71" y="15" fill="#010101" fill-opacity=".3">141k</text>
<text x="71" y="14">141k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">150k</text>
<text x="71" y="14">150k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

106
scripts/q.test.ts Normal file
View File

@@ -0,0 +1,106 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { spawnSync } from 'child_process';
import Database from 'better-sqlite3';
/**
* Smoke tests for the q.ts sqlite-CLI replacement wrapper.
*
* Verifies the two modes (SELECT prints rows in sqlite3 default "list"
* format; mutation runs via db.exec) and a few edge cases that real
* skill invocations rely on.
*/
const Q = path.resolve(__dirname, 'q.ts');
describe('scripts/q.ts', () => {
let tempDir: string;
let dbPath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-'));
dbPath = path.join(tempDir, 'test.db');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE t (id INTEGER, name TEXT, note TEXT);
INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL);
`);
db.close();
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
function run(sql: string): { stdout: string; stderr: string; status: number } {
const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], {
encoding: 'utf-8',
cwd: path.resolve(__dirname, '..'),
});
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
}
it('SELECT prints pipe-separated rows in default order', () => {
const r = run('SELECT id, name FROM t ORDER BY id');
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe('1|alice\n2|bob');
});
it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => {
const r = run('SELECT id, note FROM t ORDER BY id');
expect(r.status).toBe(0);
expect(r.stdout.trim()).toBe('1|hi\n2|');
});
it('SELECT with no rows prints nothing', () => {
const r = run("SELECT id FROM t WHERE name = 'nobody'");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
});
it('INSERT runs via db.exec and persists', () => {
const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
const db = new Database(dbPath, { readonly: true });
const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string };
db.close();
expect(row.name).toBe('carol');
});
it('compound mutation statements execute together', () => {
const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');");
expect(r.status).toBe(0);
const db = new Database(dbPath, { readonly: true });
const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map(
(r) => r.id,
);
db.close();
expect(ids).toEqual([2, 9]);
});
it('WITH...DELETE is treated as a mutation, not a query', () => {
const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)");
expect(r.status).toBe(0);
expect(r.stdout).toBe('');
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare('SELECT name FROM t').all() as { name: string }[];
db.close();
expect(rows).toEqual([{ name: 'bob' }]);
});
it('exits 2 with usage when args are missing', () => {
const r = spawnSync('pnpm', ['exec', 'tsx', Q], {
encoding: 'utf-8',
cwd: path.resolve(__dirname, '..'),
});
expect(r.status).toBe(2);
expect(r.stderr).toMatch(/Usage/);
});
});

58
scripts/q.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations.
*
* Usage:
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
*
* Uses better-sqlite3's stmt.reader property to distinguish queries
* (SELECT / WITH...SELECT) from mutations. Queries print rows in
* sqlite3 CLI default ("list") format — pipe-separated, no header —
* so existing skill text reads identically. Mutations run via
* stmt.run() (single statement) or db.exec() (compound).
*
* Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids
* depending on the sqlite3 CLI binary; setup never installs or probes
* for it. Skills that shell out to `sqlite3` therefore fail on hosts
* where it isn't preinstalled (common on fresh Ubuntu — see #2191).
* This wrapper preserves the skill-text shape (path then SQL string)
* while routing through the better-sqlite3 dep that setup already
* installs and verifies.
*/
import Database from 'better-sqlite3';
const [, , dbPath, sql] = process.argv;
if (!dbPath || sql === undefined) {
console.error('Usage: pnpm exec tsx scripts/q.ts <db-path> "<sql>"');
process.exit(2);
}
const db = new Database(dbPath);
try {
try {
const stmt = db.prepare(sql);
if (stmt.reader) {
const rows = stmt.all() as Record<string, unknown>[];
for (const row of rows) {
console.log(
Object.values(row)
.map((v) => (v === null ? '' : String(v)))
.join('|'),
);
}
} else {
stmt.run();
}
} catch (e: unknown) {
// better-sqlite3 throws on compound statements ("contains more than
// one statement"). Compound SQL in skills is always mutations
// (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec().
if (e instanceof Error && /more than one statement/i.test(e.message)) {
db.exec(sql);
} else {
throw e;
}
}
} finally {
db.close();
}

View File

@@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
import {
applyToEnv,
parseFlags,
@@ -416,7 +416,7 @@ async function main(): Promise<void> {
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
await offerClaudeAssist({
await offerClaudeOnFailure({
stepName: 'cli-agent',
msg:
ping === 'socket_error'
@@ -468,7 +468,7 @@ async function main(): Promise<void> {
} else if (channelChoice === 'imessage') {
result = await runIMessageChannel(displayName!);
} else if (channelChoice === 'other') {
await askOtherChannelName();
result = await askOtherChannelName();
} else {
p.log.info(
brandBody(
@@ -528,7 +528,7 @@ async function main(): Promise<void> {
service_running: res.terminal?.fields.SERVICE === 'running',
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
});
await offerClaudeAssist({
await offerClaudeOnFailure({
stepName: 'verify',
msg: summary || 'Verification completed with unresolved issues.',
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
@@ -740,12 +740,38 @@ async function runAuthStep(): Promise<void> {
label: 'Paste an Anthropic API key',
hint: 'pay-per-use via console.anthropic.com',
},
{
value: 'skip',
label: "Skip — I'll connect later",
hint: 'not recommended — Claude helps debug setup issues',
},
],
}),
) as 'subscription' | 'oauth' | 'api';
) as 'subscription' | 'oauth' | 'api' | 'skip';
setupLog.userInput('auth_method', method);
phEmit('auth_method_chosen', { method });
if (method === 'skip') {
const confirmed = ensureAnswer(
await p.confirm({
message:
"Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.",
initialValue: false,
}),
);
if (!confirmed) {
// Loop back to the auth picker so they can choose a real method.
return runAuthStep();
}
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' });
p.log.warn(
brandBody(
'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.',
),
);
return;
}
if (method === 'subscription') {
await runSubscriptionAuth();
} else {
@@ -1099,10 +1125,26 @@ async function askChannelChoice(): Promise<ChannelChoice> {
return choice;
}
async function askOtherChannelName(): Promise<void> {
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
const action = ensureAnswer(
await brightSelect<'type' | 'back'>({
message: 'Which channel would you like to install?',
options: [
{
value: 'type',
label: 'Type the channel name',
hint: 'e.g. matrix, github, linear, webex',
},
{ value: 'back', label: '← Back to channel selection' },
],
initialValue: 'type',
}),
);
if (action === 'back') return BACK_TO_CHANNEL_SELECTION;
const answer = ensureAnswer(
await p.text({
message: 'Which channel would you like to install?',
message: 'Channel name',
placeholder: 'e.g. matrix, github, linear, webex',
}),
);

View File

@@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise<string> {
"What phone number or email do you iMessage with?",
"That's where your assistant will send its welcome message.",
'',
k.dim(' • Phone: full E.164, e.g. +15551234567'),
k.dim(' • Phone: start with + and your country code, no spaces or dashes'),
k.dim(' Example: +14155551234 (country code 1, then 4155551234)'),
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
].join('\n'),
'Your iMessage handle',

View File

@@ -27,7 +27,7 @@ import k from 'kleur';
import * as setupLog from '../logs.js';
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
import { brightSelect } from '../lib/bright-select.js';
import { formatNoteLink, openUrl } from '../lib/browser.js';
import { openUrl } from '../lib/browser.js';
import { isHeadless } from '../platform.js';
import { askOperatorRole } from '../lib/role-prompt.js';
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
@@ -126,22 +126,31 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
}
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
// per-line formatter so the URL stands out against the rest of the body.
const linkBlock = isHeadless()
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
: [];
note(
[
"You'll create a Slack app that the assistant talks through.",
"Free and stays inside the workspaces you pick.",
'',
...linkBlock,
' 1. Create a new app "From scratch", name it, pick a workspace',
' 2. OAuth & Permissions → add Bot Token Scopes:',
' chat:write, im:write, channels:history, groups:history,',
' im:history, channels:read, groups:read, users:read,',
' reactions:write',
' • im:write, im:history',
' channels:read, channels:history',
' • groups:read, groups:history',
' • chat:write',
' • users:read',
' • reactions:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
formatNoteLink(SLACK_APPS_URL),
].filter((line): line is string => line !== null).join('\n'),
].join('\n'),
'Create a Slack app',
);

View File

@@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
const prereqsResult = await confirmPrereqs({ collected, completed });
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
await stepPublicUrl({ collected, completed });
await stepAppRegistration({ collected, completed });
await stepClientSecret({ collected, completed });
await stepAzureBot({ collected, completed });
await stepEnableTeamsChannel({ collected, completed });
if (await stepAppRegistration({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepClientSecret({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepAzureBot({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
return BACK_TO_CHANNEL_SELECTION;
}
const manifestResult = await stepGenerateManifest({ collected, completed });
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
if (
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
=== 'back'
) {
return BACK_TO_CHANNEL_SELECTION;
}
await installAdapter(collected);
completed.push('Adapter installed and service restarted.');
@@ -229,7 +242,7 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
async function stepAppRegistration(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
}): Promise<'continue' | 'back'> {
note(
[
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
@@ -262,15 +275,17 @@ async function stepAppRegistration(args: {
);
}
await stepGate({
const gate = await stepGate({
stepName: 'teams-app-registration',
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
reshow: () => stepAppRegistration(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push(
`App registered: ${args.collected.appId} (${args.collected.appType})`,
);
return 'continue';
}
async function askAppType(args: {
@@ -313,7 +328,7 @@ async function askAppType(args: {
async function stepClientSecret(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
}): Promise<'continue' | 'back'> {
note(
[
`1. In your app registration, open "Certificates & secrets"`,
@@ -356,13 +371,15 @@ async function stepClientSecret(args: {
break;
}
await stepGate({
const gate = await stepGate({
stepName: 'teams-client-secret',
stepDescription: 'creating and copying the client secret',
reshow: () => stepClientSecret(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Client secret captured.');
return 'continue';
}
// ─── step: Azure Bot resource ──────────────────────────────────────────
@@ -370,7 +387,7 @@ async function stepClientSecret(args: {
async function stepAzureBot(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
}): Promise<'continue' | 'back'> {
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
const tenantFlag =
args.collected.appType === 'SingleTenant'
@@ -405,14 +422,16 @@ async function stepAzureBot(args: {
'Step 3 of 6 — Create Azure Bot resource',
);
await stepGate({
const gate = 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,
});
if (gate === 'back') return 'back';
args.completed.push('Azure Bot created; messaging endpoint configured.');
return 'continue';
}
// ─── step: enable Teams channel ────────────────────────────────────────
@@ -420,7 +439,7 @@ async function stepAzureBot(args: {
async function stepEnableTeamsChannel(args: {
collected: Collected;
completed: string[];
}): Promise<void> {
}): Promise<'continue' | 'back'> {
note(
[
'1. Open your Azure Bot resource → Channels',
@@ -431,13 +450,15 @@ async function stepEnableTeamsChannel(args: {
].join('\n'),
'Step 4 of 6 — Enable Teams channel on the bot',
);
await stepGate({
const gate = await stepGate({
stepName: 'teams-enable-channel',
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
reshow: () => stepEnableTeamsChannel(args),
args,
});
if (gate === 'back') return 'back';
args.completed.push('Teams channel enabled on the bot.');
return 'continue';
}
// ─── step: manifest zip ────────────────────────────────────────────────
@@ -490,7 +511,7 @@ async function stepSideload(args: {
collected: Collected;
completed: string[];
zipPath: string;
}): Promise<void> {
}): Promise<'continue' | 'back'> {
note(
[
'1. Open Microsoft Teams',
@@ -505,13 +526,15 @@ async function stepSideload(args: {
].join('\n'),
'Step 5 of 6 — Sideload the app into Teams',
);
await stepGate({
const gate = await stepGate({
stepName: 'teams-sideload',
stepDescription: 'uploading the generated zip into Teams as a custom app',
reshow: () => stepSideload(args),
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
args,
});
if (gate === 'back') return 'back';
args.completed.push('App sideloaded into Teams.');
return 'continue';
}
// ─── step: install adapter ─────────────────────────────────────────────
@@ -623,9 +646,9 @@ async function finishWithHandoff(
async function stepGate(args: {
stepName: string;
stepDescription: string;
reshow: () => Promise<void> | Promise<unknown>;
reshow: () => Promise<'continue' | 'back'>;
args: { collected: Collected; completed: string[] };
}): Promise<void> {
}): Promise<'continue' | 'back'> {
while (true) {
const choice = ensureAnswer(
await brightSelect({
@@ -634,10 +657,12 @@ async function stepGate(args: {
{ value: 'done', label: "Done — let's continue" },
{ value: 'help', label: 'Stuck — hand me off to Claude' },
{ value: 'reshow', label: 'Show me the steps again' },
{ value: 'back', label: '← Back to channel selection' },
],
}),
);
if (choice === 'done') return;
if (choice === 'done') return 'continue';
if (choice === 'back') return 'back';
if (choice === 'help') {
await offerHandoff({
step: args.stepName,
@@ -647,8 +672,7 @@ async function stepGate(args: {
continue;
}
if (choice === 'reshow') {
await args.reshow();
return;
return args.reshow();
}
}
}

View File

@@ -43,7 +43,7 @@ export interface AssistContext {
* rather than us stuffing contents into the prompt. Keys are step names as
* they appear in fail() calls; values are repo-relative paths.
*/
const STEP_FILES: Record<string, string[]> = {
export const STEP_FILES: Record<string, string[]> = {
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
environment: ['setup/environment.ts'],
container: [
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
],
};
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
/**
* Returns `true` if the user ran a Claude-suggested fix command; callers
@@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean {
}
}
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({

View File

@@ -23,10 +23,19 @@
* attempting to parse it as a real answer.
*/
import { execSync, spawn } from 'child_process';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import {
type AssistContext,
BIG_PICTURE_FILES,
ensureClaudeReady,
offerClaudeAssist,
STEP_FILES,
} from './claude-assist.js';
import { ensureAnswer } from './runner.js';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
@@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string {
return lines.join('\n');
}
/**
* Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either
* the interactive failure handoff (default) or the non-interactive assist.
*
* Drop-in replacement for `offerClaudeAssist` at failure call sites.
*/
export async function offerClaudeOnFailure(
ctx: AssistContext,
projectRoot: string = process.cwd(),
): Promise<boolean> {
if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') {
return offerClaudeAssist(ctx, projectRoot);
}
return offerFailureHandoff(ctx, projectRoot);
}
/**
* Interactive Claude handoff for setup failures. Same role as
* `offerClaudeAssist` but spawns an interactive session instead of
* parsing a structured REASON/COMMAND response.
*
* Returns `true` if Claude was launched (the user may have fixed
* things during the session), `false` if skipped/declined/unavailable.
*/
async function offerFailureHandoff(
ctx: AssistContext,
projectRoot: string,
): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer(
await p.confirm({
message: 'Want to debug this with Claude?',
initialValue: true,
}),
);
if (!want) return false;
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
note(
[
"Launching Claude to help debug this failure.",
"It has the context of what went wrong.",
"",
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
].join('\n'),
'Handing off to Claude',
);
return new Promise<boolean>((resolve) => {
const child = spawn(
'claude',
[
'--append-system-prompt',
systemPrompt,
'--permission-mode',
'acceptEdits',
],
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {
p.log.error("Couldn't launch Claude. Continuing without handoff.");
resolve(false);
});
});
}
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
const references = [
...BIG_PICTURE_FILES,
...stepRefs,
'logs/setup.log',
ctx.rawLogPath
? path.relative(projectRoot, ctx.rawLogPath)
: 'logs/setup-steps/',
].filter((v, i, a) => a.indexOf(v) === i);
const lines: string[] = [
"The user is running NanoClaw's interactive setup flow and hit a failure.",
'',
`Failed step: ${ctx.stepName}`,
`Error: ${ctx.msg}`,
];
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
lines.push(
'',
'Your job: help them diagnose and fix this issue. Read the referenced files',
'and logs to understand what went wrong, then help them fix it. You can read',
'files, run commands, check logs, and explain what happened. Be concise.',
"When they're ready to resume setup, tell them to type /exit.",
'',
'Relevant files (read as needed with the Read tool):',
);
for (const f of references) lines.push(` - ${f}`);
return lines.join('\n');
}

View File

@@ -18,7 +18,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeAssist } from './claude-assist.js';
import { offerClaudeOnFailure } from './claude-handoff.js';
import { emit as phEmit } from './diagnostics.js';
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
@@ -367,7 +367,7 @@ export async function fail(
if (hint) p.log.message(k.dim(hint));
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath });
const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath });
// If the user just ran a Claude-suggested fix, offer to resume the flow
// at the step that failed instead of aborting. We re-exec via spawnSync

View File

@@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [
surface: 'flag',
type: 'string',
},
{
key: 'assistMode',
envVar: 'NANOCLAW_SETUP_ASSIST_MODE',
label: 'Assist mode',
help: 'Use non-interactive Claude assist on failure instead of interactive handoff.',
surface: 'flag',
type: 'boolean',
default: false,
},
];
// ─── name derivation ───────────────────────────────────────────────────

View File

@@ -18,7 +18,7 @@
import * as p from '@clack/prompts';
import k from 'kleur';
import { offerClaudeAssist } from './claude-assist.js';
import { offerClaudeOnFailure } from './claude-handoff.js';
import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
@@ -212,7 +212,7 @@ async function handleStall(
// offerClaudeAssist runs its own spinner and may propose a fix command.
// We don't attempt to restart the stalled build from here — if Claude
// proposes a command the user accepts, they can retry setup afterwards.
await offerClaudeAssist({
await offerClaudeOnFailure({
stepName,
msg: `The ${stepName} step has produced no output for 60 seconds.`,
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',

View File

@@ -307,8 +307,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
// Start local HTTP server to receive forwarded Gateway events (including interactions)
const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken);
// Exponential backoff capped at 1h. Without this, an unrecoverable
// failure (e.g., TokenInvalid) restarts ~10×/sec and Discord's
// Cloudflare layer issues a multi-hour IP block. A run that lasts
// longer than 5 minutes counts as healthy and resets the counter.
let consecutiveFailures = 0;
const startGateway = () => {
if (gatewayAbort?.signal.aborted) return;
const startedAt = Date.now();
// Capture the long-running listener promise via waitUntil
let listenerPromise: Promise<unknown> | undefined;
gatewayAdapter.startGatewayListener!(
@@ -323,21 +329,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
).then(() => {
// startGatewayListener resolves immediately with a Response;
// the actual work is in the listenerPromise passed to waitUntil
if (listenerPromise) {
listenerPromise
.then(() => {
if (!gatewayAbort?.signal.aborted) {
log.info('Gateway listener expired, restarting', { adapter: adapter.name });
startGateway();
}
})
.catch((err) => {
if (!gatewayAbort?.signal.aborted) {
log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err });
setTimeout(startGateway, 5000);
}
if (!listenerPromise) return;
const reschedule = (err?: unknown) => {
if (gatewayAbort?.signal.aborted) return;
const ranForMs = Date.now() - startedAt;
if (ranForMs > 5 * 60 * 1000) consecutiveFailures = 0;
else consecutiveFailures++;
const delayMs = Math.min(60 * 60 * 1000, 2 ** consecutiveFailures * 1000);
if (err) {
log.error('Gateway listener error, retrying', {
adapter: adapter.name,
err,
consecutiveFailures,
delayMs,
});
}
} else {
log.info('Gateway listener expired, restarting', {
adapter: adapter.name,
consecutiveFailures,
delayMs,
});
}
setTimeout(startGateway, delayMs);
};
listenerPromise.then(() => reschedule()).catch(reschedule);
});
};
startGateway();

View File

@@ -171,7 +171,13 @@ CREATE TABLE IF NOT EXISTS messages_in (
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL
content TEXT NOT NULL,
-- For agent-to-agent inbound rows: the source session that emitted the
-- triggering outbound. Used as a return path when the target replies —
-- the reply routes back to this exact session, not to the source agent
-- group's "newest" session. NULL on channel-side inbound and on a2a rows
-- written before this column existed.
source_session_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id);

View File

@@ -10,7 +10,7 @@ import fs from 'fs';
import path from 'path';
import { describe, it, expect, afterEach } from 'vitest';
import { migrateMessagesInTable } from './session-db.js';
import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js';
const TEST_DIR = '/tmp/nanoclaw-session-db-test';
const DB_PATH = path.join(TEST_DIR, 'inbound.db');
@@ -55,4 +55,40 @@ describe('migrateMessagesInTable', () => {
expect(row.series_id).toBe('legacy-1');
db.close();
});
it('adds source_session_id on a legacy DB, leaves existing rows NULL, is idempotent', () => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.exec(`
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE,
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending',
process_after TEXT,
recurrence TEXT,
tries INTEGER DEFAULT 0,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL
);
`);
db.prepare(
"INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES (?, ?, 'chat', datetime('now'), 'pending', '{}')",
).run('legacy-2', 2);
migrateMessagesInTable(db);
migrateMessagesInTable(db); // idempotent
const cols = (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name);
expect(cols).toContain('source_session_id');
expect(getInboundSourceSessionId(db, 'legacy-2')).toBeNull();
expect(getInboundSourceSessionId(db, 'does-not-exist')).toBeNull();
db.close();
});
});

View File

@@ -108,14 +108,21 @@ export function insertMessage(
* Host countDueMessages gates on this; container reads everything.
*/
trigger?: 0 | 1;
/**
* For agent-to-agent inbound: the source session id that emitted the
* outbound message which became this inbound row. Used as the return
* path for the target's reply. NULL on channel-side inbound.
*/
sourceSessionId?: string | null;
},
): void {
db.prepare(
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger)
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`,
`INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id)
VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId)`,
).run({
...message,
trigger: message.trigger ?? 1,
sourceSessionId: message.sourceSessionId ?? null,
seq: nextEvenSeq(db),
});
}
@@ -239,6 +246,7 @@ export interface OutboundMessage {
channel_type: string | null;
thread_id: string | null;
content: string;
in_reply_to: string | null;
}
export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] {
@@ -305,4 +313,47 @@ export function migrateMessagesInTable(db: Database.Database): void {
// the agent" semantics, so backfill 1 and default 1 for new inserts.
db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run();
}
if (!cols.has('source_session_id')) {
// For agent-to-agent return-path routing. NULL on existing rows is fine —
// their replies fall back to the legacy "newest active session" lookup.
db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run();
}
}
/**
* Look up an inbound row's source_session_id by its message id. Returns null
* if the row doesn't exist or the column is NULL (channel inbound or
* pre-migration a2a inbound). Used by a2a routing to route replies back to
* the originating session.
*/
export function getInboundSourceSessionId(db: Database.Database, messageId: string): string | null {
const row = db.prepare('SELECT source_session_id FROM messages_in WHERE id = ?').get(messageId) as
| { source_session_id: string | null }
| undefined;
return row?.source_session_id ?? null;
}
/**
* Find the source_session_id of the most recent a2a inbound row from a
* specific peer (by agent group id). Used as a peer-affinity fallback in
* a2a routing when an outbound reply has no `in_reply_to` (e.g. the
* container's send_message MCP tool path didn't thread the batch's
* in_reply_to through).
*
* Heuristic: "the last time this peer talked to me, which session was it?"
* Returns null when no prior a2a inbound from that peer carries a
* non-null source_session_id (typical for pre-migration installs).
*/
export function getMostRecentPeerSourceSessionId(db: Database.Database, peerAgentGroupId: string): string | null {
const row = db
.prepare(
`SELECT source_session_id FROM messages_in
WHERE channel_type = 'agent'
AND platform_id = ?
AND source_session_id IS NOT NULL
ORDER BY seq DESC
LIMIT 1`,
)
.get(peerAgentGroupId) as { source_session_id: string | null } | undefined;
return row?.source_session_id ?? null;
}

View File

@@ -26,8 +26,9 @@ vi.mock('./config.js', async () => {
const TEST_DIR = '/tmp/nanoclaw-test-delivery';
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from './db/index.js';
import { resolveSession, outboundDbPath } from './session-manager.js';
import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup, createMessagingGroupAgent } from './db/index.js';
import { getDeliveredIds } from './db/session-db.js';
import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js';
import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js';
function now(): string {
@@ -146,3 +147,118 @@ describe('deliverSessionMessages — concurrent invocations', () => {
expect(callCount).toBe(1);
});
});
describe('deliverSessionMessages — retry and permanent failure', () => {
it('retries on adapter failure and marks failed after MAX_DELIVERY_ATTEMPTS (3)', async () => {
seedAgentAndChannel();
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
insertOutbound('ag-1', session.id, 'out-flaky');
let callCount = 0;
setDeliveryAdapter({
async deliver() {
callCount++;
throw new Error('network timeout');
},
});
// Attempt 1
await deliverSessionMessages(session);
expect(callCount).toBe(1);
// Attempt 2
await deliverSessionMessages(session);
expect(callCount).toBe(2);
// Attempt 3 — should mark as permanently failed
await deliverSessionMessages(session);
expect(callCount).toBe(3);
// Attempt 4 — message is now in delivered (as failed), adapter not called
await deliverSessionMessages(session);
expect(callCount).toBe(3);
// Verify the message is in the delivered table with 'failed' status
const inDb = openInboundDb('ag-1', session.id);
const delivered = getDeliveredIds(inDb);
inDb.close();
expect(delivered.has('out-flaky')).toBe(true);
});
it('clears attempt counter on successful delivery', async () => {
seedAgentAndChannel();
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
insertOutbound('ag-1', session.id, 'out-retry-ok');
let callCount = 0;
setDeliveryAdapter({
async deliver() {
callCount++;
if (callCount === 1) throw new Error('transient');
return 'plat-ok';
},
});
// Attempt 1 — fails
await deliverSessionMessages(session);
expect(callCount).toBe(1);
// Attempt 2 — succeeds
await deliverSessionMessages(session);
expect(callCount).toBe(2);
// Attempt 3 — not called, message already delivered
await deliverSessionMessages(session);
expect(callCount).toBe(2);
});
});
describe('deliverSessionMessages — permission check', () => {
it('rejects delivery to an unauthorized channel destination', async () => {
seedAgentAndChannel();
// Create a second messaging group that the agent is NOT wired to
createMessagingGroup({
id: 'mg-2',
channel_type: 'discord',
platform_id: 'discord:456',
name: 'Unauthorized Chat',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
// Session is on mg-1 (telegram)
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
// Insert an outbound message targeting mg-2 (discord) — not the origin chat
const outDb = new Database(outboundDbPath('ag-1', session.id));
outDb.prepare(
`INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content)
VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`,
).run('out-unauth', JSON.stringify({ text: 'sneaky' }));
outDb.close();
const calls: string[] = [];
setDeliveryAdapter({
async deliver(_ct, _pid, _tid, _kind, content) {
calls.push(content);
return 'plat-msg';
},
});
// Deliver 3 times to exhaust retries
await deliverSessionMessages(session);
await deliverSessionMessages(session);
await deliverSessionMessages(session);
// Adapter never called — permission check throws before reaching it
expect(calls).toHaveLength(0);
// Message is marked as permanently failed
const inDb = openInboundDb('ag-1', session.id);
const delivered = getDeliveredIds(inDb);
inDb.close();
expect(delivered.has('out-unauth')).toBe(true);
});
});

View File

@@ -239,6 +239,7 @@ async function deliverMessage(
channel_type: string | null;
thread_id: string | null;
content: string;
in_reply_to: string | null;
},
session: Session,
inDb: Database.Database,

View File

@@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON =
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
},
hooks: {
PreCompact: [
{
hooks: [
{
type: 'command',
command: 'bun /app/src/compact-instructions.ts',
},
],
},
],
},
},
null,
2,
@@ -71,6 +83,8 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
@@ -90,3 +104,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
});
}
}
const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts';
/**
* Patch an existing settings.json to add the PreCompact hook if missing.
* Runs on every group init so pre-existing groups pick up the hook.
*/
function ensurePreCompactHook(settingsFile: string, initialized: string[]): void {
try {
const raw = fs.readFileSync(settingsFile, 'utf-8');
const settings = JSON.parse(raw);
// Check if there's already a PreCompact hook with our command.
const existing = settings.hooks?.PreCompact as unknown[] | undefined;
if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return;
// Add the hook, preserving existing hooks.
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
settings.hooks.PreCompact.push({
hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }],
});
fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n');
initialized.push('settings.json (added PreCompact hook)');
} catch {
// Don't break init if settings.json is malformed — it'll use whatever's there.
}
}

View File

@@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
initTestDb,
closeDb,
getDb,
runMigrations,
createAgentGroup,
createMessagingGroup,
@@ -19,6 +20,7 @@ import {
import {
resolveSession,
writeSessionMessage,
writeSessionRouting,
initSessionFolder,
sessionDir,
inboundDbPath,
@@ -595,6 +597,396 @@ describe('router', () => {
});
});
describe('routing metadata preservation', () => {
beforeEach(() => {
createAgentGroup({
id: 'ag-1',
name: 'Test Agent',
folder: 'test-agent',
agent_provider: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'discord',
platform_id: 'chan-123',
name: 'General',
is_group: 1,
unknown_sender_policy: 'public',
created_at: now(),
});
createMessagingGroupAgent({
id: 'mga-1',
messaging_group_id: 'mg-1',
agent_group_id: 'ag-1',
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
});
it('routed message carries platformId, channelType, threadId on the messages_in row', async () => {
const { routeInbound } = await import('./router.js');
await routeInbound({
channelType: 'discord',
platformId: 'chan-123',
threadId: 'thread-42',
message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() },
});
const session = findSession('mg-1', null);
const db = new Database(inboundDbPath('ag-1', session!.id));
const row = db
.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?')
.get('msg-r1%') as {
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
};
db.close();
expect(row.platform_id).toBe('chan-123');
expect(row.channel_type).toBe('discord');
expect(row.thread_id).toBe('thread-42');
});
it('fan-out gives each agent its own routing, not leaked from sibling', async () => {
const { routeInbound } = await import('./router.js');
createAgentGroup({
id: 'ag-2',
name: 'Agent Two',
folder: 'agent-two',
agent_provider: null,
created_at: now(),
});
createMessagingGroupAgent({
id: 'mga-2',
messaging_group_id: 'mg-1',
agent_group_id: 'ag-2',
engage_mode: 'pattern',
engage_pattern: '.',
sender_scope: 'all',
ignored_message_policy: 'drop',
session_mode: 'shared',
priority: 0,
created_at: now(),
});
await routeInbound({
channelType: 'discord',
platformId: 'chan-123',
threadId: 'thread-fanout',
message: { id: 'msg-fo', kind: 'chat', content: JSON.stringify({ text: 'fan' }), timestamp: now() },
});
// Both agents should have the message with correct routing
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
for (const agId of ['ag-1', 'ag-2']) {
const sessions = getSessionsByAgentGroup(agId);
expect(sessions).toHaveLength(1);
const db = new Database(inboundDbPath(agId, sessions[0].id));
const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in LIMIT 1').get() as {
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
};
db.close();
expect(row.platform_id).toBe('chan-123');
expect(row.channel_type).toBe('discord');
expect(row.thread_id).toBe('thread-fanout');
}
});
});
describe('writeSessionRouting', () => {
it('populates session_routing from the messaging group', () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent',
folder: 'agent',
agent_provider: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'telegram',
platform_id: 'tg:12345',
name: 'Chat',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
writeSessionRouting('ag-1', session.id);
const db = new Database(inboundDbPath('ag-1', session.id));
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
| {
channel_type: string | null;
platform_id: string | null;
thread_id: string | null;
}
| undefined;
db.close();
expect(row).toBeDefined();
expect(row!.channel_type).toBe('telegram');
expect(row!.platform_id).toBe('tg:12345');
expect(row!.thread_id).toBeNull();
});
it('writes null routing for agent-shared session (no messaging group)', () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent',
folder: 'agent',
agent_provider: null,
created_at: now(),
});
const { session } = resolveSession('ag-1', null, null, 'agent-shared');
writeSessionRouting('ag-1', session.id);
const db = new Database(inboundDbPath('ag-1', session.id));
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
| {
channel_type: string | null;
platform_id: string | null;
thread_id: string | null;
}
| undefined;
db.close();
expect(row).toBeDefined();
expect(row!.channel_type).toBeNull();
expect(row!.platform_id).toBeNull();
expect(row!.thread_id).toBeNull();
});
it('includes thread_id from per-thread session', () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent',
folder: 'agent',
agent_provider: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-1',
channel_type: 'discord',
platform_id: 'chan-123',
name: 'General',
is_group: 1,
unknown_sender_policy: 'public',
created_at: now(),
});
const { session } = resolveSession('ag-1', 'mg-1', 'thread-77', 'per-thread');
writeSessionRouting('ag-1', session.id);
const db = new Database(inboundDbPath('ag-1', session.id));
const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as
| {
channel_type: string | null;
platform_id: string | null;
thread_id: string | null;
}
| undefined;
db.close();
expect(row).toBeDefined();
expect(row!.channel_type).toBe('discord');
expect(row!.platform_id).toBe('chan-123');
expect(row!.thread_id).toBe('thread-77');
});
});
describe('agent-shared session resolution', () => {
it('resolves to the same session on repeated calls', () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent',
folder: 'agent',
agent_provider: null,
created_at: now(),
});
const { session: s1, created: c1 } = resolveSession('ag-1', null, null, 'agent-shared');
const { session: s2, created: c2 } = resolveSession('ag-1', null, null, 'agent-shared');
expect(c1).toBe(true);
expect(c2).toBe(false);
expect(s1.id).toBe(s2.id);
});
it('agent-shared session has null messaging_group_id', () => {
createAgentGroup({
id: 'ag-1',
name: 'Agent',
folder: 'agent',
agent_provider: null,
created_at: now(),
});
const { session } = resolveSession('ag-1', null, null, 'agent-shared');
expect(session.messaging_group_id).toBeNull();
});
});
describe('agent-to-agent routing', () => {
beforeEach(() => {
createAgentGroup({
id: 'ag-pa',
name: 'PA',
folder: 'pa-agent',
agent_provider: null,
created_at: now(),
});
createMessagingGroup({
id: 'mg-slack',
channel_type: 'slack',
platform_id: 'C-GENERAL',
name: 'Slack General',
is_group: 1,
unknown_sender_policy: 'public',
created_at: now(),
});
createAgentGroup({
id: 'ag-researcher',
name: 'Researcher',
folder: 'researcher-agent',
agent_provider: null,
created_at: now(),
});
// Wire bidirectional A2A destinations (table created by runMigrations)
const db = getDb();
db.prepare(
`INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES ('ag-pa', 'researcher', 'agent', 'ag-researcher', ?)`,
).run(now());
db.prepare(
`INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES ('ag-researcher', 'pa', 'agent', 'ag-pa', ?)`,
).run(now());
});
it('A2A outbound lands in a session for the target agent', async () => {
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
await routeAgentMessage(
{ id: 'out-a2a-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research this' }), in_reply_to: null },
paSlackSession,
);
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
const researcherSessions = getSessionsByAgentGroup('ag-researcher');
expect(researcherSessions.length).toBeGreaterThanOrEqual(1);
const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id));
const rows = rDb.prepare('SELECT platform_id, channel_type, content FROM messages_in').all() as Array<{
platform_id: string | null;
channel_type: string | null;
content: string;
}>;
rDb.close();
expect(rows).toHaveLength(1);
expect(rows[0].channel_type).toBe('agent');
expect(rows[0].platform_id).toBe('ag-pa');
expect(JSON.parse(rows[0].content).text).toBe('research this');
});
it('A2A return path routes to originating session, not newest (#2332)', async () => {
// PA has Slack session, then gets wired to Discord (newer session).
// Researcher responds to PA. With the return-path fix, the reply
// routes back to the Slack session (originator) not Discord (newest).
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
createMessagingGroup({
id: 'mg-discord',
channel_type: 'discord',
platform_id: 'chan-discord',
name: 'Discord',
is_group: 0,
unknown_sender_policy: 'public',
created_at: now(),
});
const { session: paDiscordSession } = resolveSession('ag-pa', 'mg-discord', null, 'shared');
// PA sends from Slack
await routeAgentMessage(
{ id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }), in_reply_to: null },
paSlackSession,
);
// Researcher responds back to PA
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
const researcherSession = getSessionsByAgentGroup('ag-researcher')[0];
await routeAgentMessage(
{ id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }), in_reply_to: null },
researcherSession,
);
const slackDb = new Database(inboundDbPath('ag-pa', paSlackSession.id));
const slackA2a = slackDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all();
slackDb.close();
const discordDb = new Database(inboundDbPath('ag-pa', paDiscordSession.id));
const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all();
discordDb.close();
// Fixed: response lands in Slack (origin) not Discord (newest)
expect(slackA2a).toHaveLength(1);
expect(discordA2a).toHaveLength(0);
});
it('BUG: A2A-only session gets null session_routing (#2332)', async () => {
// Researcher only has an agent-shared session (no channel wiring).
// writeSessionRouting writes nulls because messaging_group_id is null.
const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js');
const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared');
await routeAgentMessage(
{ id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }), in_reply_to: null },
paSession,
);
const { getSessionsByAgentGroup } = await import('./db/sessions.js');
const researcherSessions = getSessionsByAgentGroup('ag-researcher');
expect(researcherSessions).toHaveLength(1);
writeSessionRouting('ag-researcher', researcherSessions[0].id);
const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id));
const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as
| {
channel_type: string | null;
platform_id: string | null;
}
| undefined;
rDb.close();
// BUG: session_routing is all null — researcher has no default routing
expect(routing).toBeDefined();
expect(routing!.channel_type).toBeNull();
expect(routing!.platform_id).toBeNull();
});
});
describe('delivery', () => {
it('should detect undelivered messages in outbound DB', () => {
createAgentGroup({

View File

@@ -12,6 +12,7 @@ import {
CLAIM_STUCK_MS,
_resetStuckProcessingRowsForTesting,
decideStuckAction,
parseSqliteUtc,
} from './host-sweep.js';
import type { Session } from './types.js';
@@ -292,3 +293,44 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => {
expect(row.tries).toBe(1); // not bumped, the skip path held
});
});
describe('parseSqliteUtc', () => {
// Regression: SQLite TIMESTAMP strings have no zone marker, but Date.parse
// treats those as local time. On non-UTC hosts this made every claim look
// (TZ offset) hours stale and tripped kill-claim on freshly-claimed messages.
// The helper appends "Z" only when no marker is present, so parsing is
// always anchored to UTC regardless of host timezone.
const utcMs = Date.parse('2026-04-20T12:00:00.000Z');
it('treats a SQLite-style timestamp (no zone) as UTC', () => {
expect(parseSqliteUtc('2026-04-20 12:00:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00.000')).toBe(utcMs);
});
it('preserves an explicit Z marker', () => {
expect(parseSqliteUtc('2026-04-20T12:00:00.000Z')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T12:00:00z')).toBe(utcMs);
});
it('preserves an explicit numeric offset', () => {
// 14:00+02:00 == 12:00 UTC
expect(parseSqliteUtc('2026-04-20T14:00:00+02:00')).toBe(utcMs);
expect(parseSqliteUtc('2026-04-20T14:00:00+0200')).toBe(utcMs);
// 07:00-05:00 == 12:00 UTC
expect(parseSqliteUtc('2026-04-20T07:00:00-05:00')).toBe(utcMs);
});
it('returns NaN for unparseable input', () => {
expect(Number.isNaN(parseSqliteUtc('not a date'))).toBe(true);
});
it('does not drift across host timezones for SQLite-style input', () => {
// The helper itself is timezone-independent because it forces UTC parsing.
// (Verifying the regex branch — without the helper, `Date.parse` of the
// bare string returns different values depending on the host TZ.)
const bare = '2026-04-20T12:00:00';
expect(parseSqliteUtc(bare)).toBe(Date.parse(bare + 'Z'));
});
});

View File

@@ -47,6 +47,17 @@ import { openInboundDb, openOutboundDb, openOutboundDbRw, inboundDbPath, heartbe
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
import type { Session } from './types.js';
/**
* SQLite TIMESTAMP columns store UTC without a timezone marker. Date.parse
* treats timezoneless ISO strings as local time, so on non-UTC hosts every
* timestamp looks (TZ offset) hours stale — leading to spurious kill-claim
* decisions on freshly-claimed messages. Append "Z" when no zone marker is
* present so Date.parse interprets the string as UTC.
*/
export function parseSqliteUtc(s: string): number {
return Date.parse(/[zZ]|[+-]\d{2}:?\d{2}$/.test(s) ? s : s + 'Z');
}
const SWEEP_INTERVAL_MS = 60_000;
// Absolute idle ceiling for a running container. If the heartbeat file hasn't
// been touched in this long, the container is either stuck or doing genuinely
@@ -95,7 +106,7 @@ export function decideStuckAction(args: {
const tolerance = Math.max(CLAIM_STUCK_MS, declaredBashMs ?? 0);
for (const claim of claims) {
const claimedAt = Date.parse(claim.status_changed);
const claimedAt = parseSqliteUtc(claim.status_changed);
if (Number.isNaN(claimedAt)) continue;
const claimAge = now - claimedAt;
if (claimAge <= tolerance) continue;
@@ -275,7 +286,7 @@ function resetStuckProcessingRows(
// Already rescheduled for a future retry — don't bump tries again. The
// wake path (sweep step 2) will fire when process_after elapses and a
// fresh container will clean the orphan claim on startup.
if (msg.processAfter && Date.parse(msg.processAfter) > now) continue;
if (msg.processAfter && parseSqliteUtc(msg.processAfter) > now) continue;
if (msg.tries >= MAX_TRIES) {
markMessageFailed(inDb, msg.id);

View File

@@ -1,20 +1,54 @@
import { describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { isSafeAttachmentName } from './agent-route.js';
import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js';
import { createDestination } from './db/agent-destinations.js';
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
import { createSession, updateSession } from '../../db/sessions.js';
import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
vi.mock('../../container-runner.js', () => ({
wakeContainer: vi.fn().mockResolvedValue(undefined),
isContainerRunning: vi.fn().mockReturnValue(false),
getActiveContainerCount: vi.fn().mockReturnValue(0),
killContainer: vi.fn(),
}));
vi.mock('../../config.js', async () => {
const actual = await vi.importActual('../../config.js');
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-route' };
});
const TEST_DIR = '/tmp/nanoclaw-test-a2a-route';
function now(): string {
return new Date().toISOString();
}
function readInbound(agentGroupId: string, sessionId: string) {
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
const rows = db
.prepare('SELECT id, platform_id, channel_type, content, source_session_id FROM messages_in ORDER BY seq')
.all() as Array<{
id: string;
platform_id: string | null;
channel_type: string | null;
content: string;
source_session_id: string | null;
}>;
db.close();
return rows;
}
/**
* `forwardAttachedFiles` has a filesystem side that's awkward to unit-test
* without mocking DATA_DIR. The guarantee worth pinning is that the
* filename validator rejects everything that could escape the inbox dir —
* `forwardAttachedFiles` runs this guard before any I/O, so traversal is
* impossible as long as this matrix holds.
*/
describe('isSafeAttachmentName', () => {
it('accepts plain filenames', () => {
expect(isSafeAttachmentName('baby-duck.png')).toBe(true);
expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true);
expect(isSafeAttachmentName('report.v2.docx')).toBe(true);
expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..`
expect(isSafeAttachmentName('.hidden')).toBe(true);
});
it('rejects empty / sentinel values', () => {
@@ -44,3 +78,359 @@ describe('isSafeAttachmentName', () => {
expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false);
});
});
/**
* Return-path routing: when an a2a reply targets an agent group with multiple
* sessions, it must land in the *originating* session — not the newest one.
*
* Setup: agent A has two active sessions S1 (older) + S2 (newer).
* Agent B is the peer A talks to. Bidirectional destinations wired.
*/
describe('routeAgentMessage return-path', () => {
const A = 'ag-A';
const B = 'ag-B';
let S1: Session;
let S2: Session;
let SB: Session;
beforeEach(() => {
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
fs.mkdirSync(TEST_DIR, { recursive: true });
const db = initTestDb();
runMigrations(db);
createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() });
createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() });
// S1 (older), S2 (newer) — both active sessions on A.
S1 = {
id: 'sess-A-old',
agent_group_id: A,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-01-01T00:00:00.000Z',
};
S2 = {
id: 'sess-A-new',
agent_group_id: A,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-02-01T00:00:00.000Z',
};
SB = {
id: 'sess-B',
agent_group_id: B,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-01-15T00:00:00.000Z',
};
createSession(S1);
createSession(S2);
createSession(SB);
initSessionFolder(A, S1.id);
initSessionFolder(A, S2.id);
initSessionFolder(B, SB.id);
createDestination({
agent_group_id: A,
local_name: 'b',
target_type: 'agent',
target_id: B,
created_at: now(),
});
createDestination({
agent_group_id: B,
local_name: 'a',
target_type: 'agent',
target_id: A,
created_at: now(),
});
});
afterEach(() => {
closeDb();
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
});
it('forward direction: stamps source_session_id on the target inbound row', async () => {
// A.S1 emits an outbound a2a to B.
await routeAgentMessage(
{
id: 'msg-from-A-S1',
platform_id: B,
content: JSON.stringify({ text: 'hello B' }),
in_reply_to: null,
},
S1,
);
const bRows = readInbound(B, SB.id);
expect(bRows).toHaveLength(1);
expect(bRows[0].platform_id).toBe(A);
expect(bRows[0].source_session_id).toBe(S1.id); // <- the return address
});
it('reply direction: routes back to the originating session, not the newest', async () => {
// A.S1 sends to B.
await routeAgentMessage(
{
id: 'msg-from-A-S1',
platform_id: B,
content: JSON.stringify({ text: 'ping' }),
in_reply_to: null,
},
S1,
);
// Capture the synthetic id the host stamped on B's inbound — that's what
// B's container would reference as `in_reply_to` when replying.
const bRows = readInbound(B, SB.id);
const yId = bRows[0].id;
// B replies to that message.
await routeAgentMessage(
{
id: 'msg-from-B',
platform_id: A,
content: JSON.stringify({ text: 'pong' }),
in_reply_to: yId,
},
SB,
);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
// The reply lands in S1 (originator) even though S2 is newer.
expect(s1Rows).toHaveLength(1);
expect(s1Rows[0].platform_id).toBe(B);
expect(JSON.parse(s1Rows[0].content).text).toBe('pong');
expect(s2Rows).toHaveLength(0);
});
it('fallback: a2a with no in_reply_to falls through to newest-session lookup', async () => {
// No prior conversation. B initiates an a2a to A out of the blue.
await routeAgentMessage(
{
id: 'msg-from-B-fresh',
platform_id: A,
content: JSON.stringify({ text: 'unsolicited' }),
in_reply_to: null,
},
SB,
);
// Newest session wins (current heuristic, preserved).
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
expect(s1Rows).toHaveLength(0);
expect(s2Rows).toHaveLength(1);
});
it('peer-affinity fallback: with no in_reply_to, routes to most recent peer-source session', async () => {
// A.S1 sends to B (establishing affinity: B's last contact from A was via S1).
await routeAgentMessage(
{
id: 'msg-from-A-S1-pre',
platform_id: B,
content: JSON.stringify({ text: 'context-establishing' }),
in_reply_to: null,
},
S1,
);
// B sends a follow-up but its container forgot to set in_reply_to (e.g.
// emitted via an MCP tool path that doesn't thread the batch's in_reply_to
// through). The host should still route this to S1 because S1 is the
// session most recently in conversation with B — not the chronologically
// newest session of A.
await routeAgentMessage(
{
id: 'msg-from-B-followup',
platform_id: A,
content: JSON.stringify({ text: 'standing by' }),
in_reply_to: null,
},
SB,
);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
// Affinity wins: reply to S1, not the newer S2.
expect(s1Rows).toHaveLength(1);
expect(JSON.parse(s1Rows[0].content).text).toBe('standing by');
expect(s2Rows).toHaveLength(0);
});
it('stale origin fallback: closed origin session falls through to newest active', async () => {
// A.S1 sends to B, establishing source_session_id = S1.id on B's inbound.
await routeAgentMessage(
{ id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null },
S1,
);
const bRows = readInbound(B, SB.id);
const inboundId = bRows[0].id;
// Close S1 — simulates session cleanup or channel disconnect.
updateSession(S1.id, { status: 'closed' });
// B replies. origin points to S1 (closed), should fall through to S2.
await routeAgentMessage(
{ id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId },
SB,
);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
expect(s1Rows).toHaveLength(0);
expect(s2Rows).toHaveLength(1);
});
it('cross-agent-group guard: origin session belonging to wrong agent group is rejected', async () => {
// Third agent group C sends to B, stamping source_session_id = SC on B's inbound.
const C = 'ag-C';
createAgentGroup({ id: C, name: 'C', folder: 'c', agent_provider: null, created_at: now() });
const SC: Session = {
id: 'sess-C',
agent_group_id: C,
messaging_group_id: null,
thread_id: null,
agent_provider: null,
status: 'active',
container_status: 'stopped',
last_active: null,
created_at: '2026-03-01T00:00:00.000Z',
};
createSession(SC);
initSessionFolder(C, SC.id);
createDestination({ agent_group_id: C, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() });
await routeAgentMessage(
{ id: 'msg-from-C', platform_id: B, content: JSON.stringify({ text: 'from C' }), in_reply_to: null },
SC,
);
const bRows = readInbound(B, SB.id);
const cInboundId = bRows.find((r) => r.platform_id === C)!.id;
// B replies to A, but in_reply_to references the C-originated row.
// Guard rejects (SC belongs to C, not A) → falls through to newest of A.
await routeAgentMessage(
{ id: 'msg-reply-tamper', platform_id: A, content: JSON.stringify({ text: 'misdirected' }), in_reply_to: cInboundId },
SB,
);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
expect(s1Rows).toHaveLength(0);
expect(s2Rows).toHaveLength(1);
});
it('in_reply_to referencing a non-a2a row falls through to newest session', async () => {
// Write a channel message into B's inbound (no source_session_id).
writeSessionMessage(B, SB.id, {
id: 'channel-msg-1',
kind: 'chat',
timestamp: now(),
platformId: 'user-123',
channelType: 'slack',
threadId: null,
content: 'hello from slack',
});
// B replies to A with in_reply_to pointing to the channel message.
// source_session_id is null → peer-affinity finds nothing → newest of A.
await routeAgentMessage(
{ id: 'msg-reply-channel', platform_id: A, content: JSON.stringify({ text: 'response' }), in_reply_to: 'channel-msg-1' },
SB,
);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
expect(s1Rows).toHaveLength(0);
expect(s2Rows).toHaveLength(1);
});
it('self-message is allowed without a destination row', async () => {
// A targets itself — no agent_destinations row exists for A→A.
await routeAgentMessage(
{ id: 'self-msg', platform_id: A, content: JSON.stringify({ text: 'self-note' }), in_reply_to: null },
S1,
);
// Lands in S2 (newest active session of A via resolveSession fallback).
const s2Rows = readInbound(A, S2.id);
expect(s2Rows).toHaveLength(1);
expect(JSON.parse(s2Rows[0].content).text).toBe('self-note');
});
it('BUG: no volume cap on a2a routing — unbounded ping-pong is allowed (#2063)', async () => {
// Two agents can exchange unlimited messages with no rate limit or loop
// detection. This test documents the gap — it should FAIL once #2063 lands.
const errors: string[] = [];
for (let i = 0; i < 20; i++) {
try {
await routeAgentMessage(
{ id: `ping-${i}`, platform_id: B, content: JSON.stringify({ text: `ping ${i}` }), in_reply_to: null },
S1,
);
await routeAgentMessage(
{ id: `pong-${i}`, platform_id: A, content: JSON.stringify({ text: `pong ${i}` }), in_reply_to: null },
SB,
);
} catch (e) {
errors.push((e as Error).message);
break;
}
}
// BUG: all 40 messages go through — no cap, no throttle.
// Once loop prevention lands, this should throw or reject after a threshold.
const bRows = readInbound(B, SB.id);
const s1Rows = readInbound(A, S1.id);
const s2Rows = readInbound(A, S2.id);
expect(errors).toHaveLength(0);
expect(bRows).toHaveLength(20);
expect(s1Rows.length + s2Rows.length).toBe(20);
});
it('file forwarding: copies bytes from source outbox to target inbox', async () => {
// Place a file in S1's outbox for the message.
const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-file');
fs.mkdirSync(outboxDir, { recursive: true });
fs.writeFileSync(path.join(outboxDir, 'report.pdf'), 'fake-pdf-bytes');
await routeAgentMessage(
{
id: 'msg-with-file',
platform_id: B,
content: JSON.stringify({ text: 'see attached', files: ['report.pdf'] }),
in_reply_to: null,
},
S1,
);
const bRows = readInbound(B, SB.id);
expect(bRows).toHaveLength(1);
const parsed = JSON.parse(bRows[0].content);
expect(parsed.attachments).toHaveLength(1);
expect(parsed.attachments[0].name).toBe('report.pdf');
expect(parsed.attachments[0].type).toBe('file');
// Verify actual file bytes were copied to the target inbox.
const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath);
expect(fs.existsSync(targetPath)).toBe(true);
expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes');
});
});

View File

@@ -23,10 +23,11 @@ import path from 'path';
import { isSafeAttachmentName } from '../../attachment-safety.js';
import { getAgentGroup } from '../../db/agent-groups.js';
import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js';
import { getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { log } from '../../log.js';
import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js';
import type { Session } from '../../types.js';
import { hasDestination } from './db/agent-destinations.js';
@@ -101,6 +102,61 @@ export interface RoutableAgentMessage {
id: string;
platform_id: string | null;
content: string;
/**
* For replies, the id of the inbound message being replied to. The
* container's formatter sets this from the first inbound in the batch
* (`container/agent-runner/src/formatter.ts`). Used here to route the
* reply back to the originating session — see `resolveTargetSession`.
*/
in_reply_to: string | null;
}
/**
* Pick which session of `targetAgentGroupId` should receive this a2a message.
*
* Three layers, highest-fidelity first:
*
* 1. **Direct return-path** (in_reply_to lookup): if the message is a reply
* (`in_reply_to` set), open the source agent's inbound DB and read the
* triggering row's `source_session_id`. That column was stamped when the
* original outbound was routed — it's the session that started the
* conversation, and replies should land there even when the target has
* multiple active sessions.
*
* 2. **Peer-affinity fallback**: if (1) misses (in_reply_to is null or the
* referenced row isn't an a2a inbound), look up the most recent a2a
* inbound *from the target agent group* in source's inbound and use its
* `source_session_id`. The intuition: the last time this peer talked to
* me, which target session was driving? Route the reply there, since
* that's the session most plausibly in active conversation.
*
* 3. **Newest active session**: legacy heuristic. Used when no prior a2a
* has been recorded with `source_session_id` (e.g. fresh installs,
* pre-migration data).
*/
function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, targetAgentGroupId: string): Session {
const srcDb = openInboundDb(sourceSession.agent_group_id, sourceSession.id);
let originSessionId: string | null = null;
try {
if (msg.in_reply_to) {
originSessionId = getInboundSourceSessionId(srcDb, msg.in_reply_to);
}
if (!originSessionId) {
// Peer-affinity fallback — covers the case where the container's
// outbound write didn't carry in_reply_to (e.g. legacy MCP send_message
// path, container running pre-fix code).
originSessionId = getMostRecentPeerSourceSessionId(srcDb, targetAgentGroupId);
}
} finally {
srcDb.close();
}
if (originSessionId) {
const candidate = getSession(originSessionId);
if (candidate && candidate.agent_group_id === targetAgentGroupId && candidate.status === 'active') {
return candidate;
}
}
return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session;
}
export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise<void> {
@@ -119,7 +175,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
if (!getAgentGroup(targetAgentGroupId)) {
throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`);
}
const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared');
const targetSession = resolveTargetSession(msg, session, targetAgentGroupId);
const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// If the source message references files (via `send_file`), forward the
@@ -137,6 +193,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess
channelType: 'agent',
threadId: null,
content: forwardedContent,
sourceSessionId: session.id,
});
log.info('Agent message routed', {
from: session.agent_group_id,

View File

@@ -210,6 +210,12 @@ export function writeSessionMessage(
* a trigger-1 message does arrive.
*/
trigger?: 0 | 1;
/**
* For agent-to-agent inbound: the source session id that emitted the
* outbound message which became this inbound row. Used as the return
* path so the target's reply routes back to that exact session.
*/
sourceSessionId?: string | null;
},
): void {
// Extract base64 attachment data, save to inbox, replace with file paths
@@ -228,6 +234,7 @@ export function writeSessionMessage(
processAfter: message.processAfter ?? null,
recurrence: message.recurrence ?? null,
trigger: message.trigger ?? 1,
sourceSessionId: message.sourceSessionId ?? null,
});
} finally {
db.close();

View File

@@ -4,6 +4,6 @@ export default defineConfig({
test: {
// container/agent-runner tests run under Bun (they depend on bun:sqlite).
// See container/agent-runner/package.json "test" script.
include: ['src/**/*.test.ts', 'setup/**/*.test.ts'],
include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'],
},
});