Merge branch 'nanocoai:main' into main

This commit is contained in:
glifocat
2026-05-10 20:27:30 +02:00
committed by GitHub
116 changed files with 6498 additions and 486 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,13 @@ 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`) 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`):
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;

View File

@@ -1 +1,5 @@
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
pnpm run format:fix
if [ -n "$staged" ]; then
echo "$staged" | xargs git add
fi

View File

@@ -4,10 +4,22 @@ All notable changes to NanoClaw will be documented in this file.
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
## [Unreleased]
## [2.0.54] - 2026-05-10
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
## [2.0.48] - 2026-05-09
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
## [2.0.0] - 2026-04-22

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 |
@@ -70,15 +72,44 @@ Exactly one writer per file — no cross-mount lock contention. Heartbeat is a f
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
| `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). |
## Admin CLI (`ncl`)
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> help
ncl help
```
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
## Channels and Providers (skill-installed)
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
@@ -92,13 +123,35 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Container Config
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
## Container Restart
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
## 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 +195,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 |
|-------|-------------|

27
bin/ncl Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# ncl — NanoClaw CLI launcher.
#
# Resolves the project root from this script's location, cd's there so the
# host-resolved DATA_DIR matches the running host, and execs the TS entry
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
# to its full path) to invoke from anywhere:
#
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
# # or
# alias ncl="$(pwd)/bin/ncl"
set -euo pipefail
SCRIPT="${BASH_SOURCE[0]}"
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
while [ -h "$SCRIPT" ]; do
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
SCRIPT="$(readlink "$SCRIPT")"
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
done
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
exec pnpm exec tsx src/cli/client.ts "$@"

View File

@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.116
ARG CLAUDE_CODE_VERSION=2.1.128
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
@@ -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 && \
@@ -104,6 +110,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
chmod +x /usr/local/bin/ncl
# ---- Entrypoint --------------------------------------------------------------
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh

View File

@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -18,23 +18,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.138", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.138", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.138", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.138" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-rH6dFI3DBBsPBPcHTBdTZCHA14OCt2t4+6XYi2MJB/GlFrnZvlWmMIk2z9uxAiZ05Txg8YbftgSuE5A1qpAXwg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],

View File

@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"

View File

@@ -0,0 +1,254 @@
#!/usr/bin/env bun
/**
* ncl — NanoClaw CLI client (container edition).
*
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
* container (the session DBs exist at /workspace/) and uses a DB transport
* instead of the Unix socket transport.
*
* Writes a cli_request system message to outbound.db, polls inbound.db
* for the response. Self-contained — no imports from agent-runner.
*/
import { Database } from 'bun:sqlite';
// ---------------------------------------------------------------------------
// Frame types (mirrors src/cli/frame.ts on the host)
// ---------------------------------------------------------------------------
type RequestFrame = {
id: string;
command: string;
args: Record<string, unknown>;
};
type ResponseFrame =
| { id: string; ok: true; data: unknown }
| { id: string; ok: false; error: { code: string; message: string } };
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const INBOUND_DB = '/workspace/inbound.db';
const OUTBOUND_DB = '/workspace/outbound.db';
// ---------------------------------------------------------------------------
// DB transport
// ---------------------------------------------------------------------------
function generateId(): string {
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Write a cli_request to outbound.db.
*
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
* preventing seq collisions with concurrent agent-runner writes.
*/
function writeRequest(req: RequestFrame): void {
const db = new Database(OUTBOUND_DB);
db.exec('PRAGMA journal_mode = DELETE');
db.exec('PRAGMA busy_timeout = 5000');
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
try {
db.exec('BEGIN IMMEDIATE');
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
const max = Math.max(maxOut, maxIn);
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
db.prepare(
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
).run({
$id: req.id,
$seq: nextSeq,
$content: JSON.stringify({
action: 'cli_request',
requestId: req.id,
command: req.command,
args: req.args,
}),
});
db.exec('COMMIT');
} catch (e) {
db.exec('ROLLBACK');
throw e;
} finally {
inDb.close();
db.close();
}
}
/**
* Poll inbound.db for a cli_response matching our requestId.
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
*/
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const inDb = new Database(INBOUND_DB, { readonly: true });
inDb.exec('PRAGMA busy_timeout = 5000');
inDb.exec('PRAGMA mmap_size = 0');
try {
const row = inDb
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
if (row) {
// Mark as completed via processing_ack so agent-runner skips it
const outDb = new Database(OUTBOUND_DB);
outDb.exec('PRAGMA journal_mode = DELETE');
outDb.exec('PRAGMA busy_timeout = 5000');
outDb
.prepare(
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
)
.run(row.id);
outDb.close();
const parsed = JSON.parse(row.content);
return parsed.frame as ResponseFrame;
}
} finally {
inDb.close();
}
Bun.sleepSync(500);
}
return null;
}
// ---------------------------------------------------------------------------
// Arg parsing (mirrors host-side client.ts)
// ---------------------------------------------------------------------------
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
let json = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') {
json = true;
continue;
}
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i++;
}
continue;
}
positional.push(a);
}
if (positional.length === 0) {
process.stderr.write('ncl: missing command\n');
printUsage();
process.exit(2);
}
// Join all positionals with dashes. The dispatcher trims the last
// segment as a target ID if the full name isn't a registered command.
const command = positional.join('-');
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
);
}
// ---------------------------------------------------------------------------
// Formatting (mirrors src/cli/format.ts on the host)
// ---------------------------------------------------------------------------
function formatHuman(resp: ResponseFrame): string {
if (!resp.ok) {
return `error (${resp.error.code}): ${resp.error.message}\n`;
}
const data = resp.data;
if (!Array.isArray(data) || data.length === 0) {
return JSON.stringify(data, null, 2) + '\n';
}
const isFlat = data.every(
(r) =>
typeof r === 'object' &&
r !== null &&
!Array.isArray(r) &&
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
);
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
const keys = Object.keys(data[0] as Record<string, unknown>);
const widths = keys.map((k) =>
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
);
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
const rows = data.map((r) =>
keys
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
.join(' '),
);
return [header, sep, ...rows, ''].join('\n');
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
printUsage();
process.exit(0);
}
const { command, args, json } = parseArgv(argv);
const requestId = generateId();
const req: RequestFrame = { id: requestId, command, args };
writeRequest(req);
const resp = pollResponse(requestId, 30_000);
if (!resp) {
process.stderr.write('ncl: command timed out after 30s\n');
process.exit(2);
}
if (json) {
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
} else {
const output = formatHuman(resp);
if (!resp.ok) {
process.stderr.write(output);
process.exit(1);
}
process.stdout.write(output);
}

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

@@ -16,6 +16,8 @@ export interface RunnerConfig {
agentGroupId: string;
maxMessagesPerPrompt: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
}
const DEFAULT_MAX_MESSAGES = 10;
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;

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

@@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL
content TEXT NOT NULL,
on_wake INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,

View File

@@ -49,7 +49,7 @@ function getMaxMessagesPerPrompt(): number {
* sees the prior context it missed. Host's countDueMessages gates waking on
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(): MessageInRow[] {
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
const inbound = openInboundDb();
const outbound = getOutboundDb();
@@ -59,10 +59,11 @@ export function getPendingMessages(): MessageInRow[] {
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
AND (on_wake = 0 OR ?1 = 1)
ORDER BY seq DESC
LIMIT ?`,
LIMIT ?2`,
)
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
if (pending.length === 0) return [];

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

@@ -91,6 +91,8 @@ async function main(): Promise<void> {
mcpServers,
env: { ...process.env },
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
});
await runPollLoop({

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,83 @@
## Admin CLI (`ncl`)
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
### Usage
```
ncl <resource> <verb> [--flags]
ncl <resource> help
ncl help
```
### Scope
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
### Resources
Run `ncl help` for the full list. Common resources:
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| sessions | list, get | Active sessions (read-only) |
| destinations | list, add, remove | Where an agent group can send messages |
| members | list, add, remove | Unprivileged access gate for an agent group |
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
### When to use
- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config.
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
- **Checking who's in your group** — `ncl members list`.
- **Seeing your destinations** — `ncl destinations list`.
- **Answering questions about the system** — query `ncl` rather than guessing.
### Access rules
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
### Approval flow
Write commands require admin approval. Here's what happens:
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
4. Once the admin responds:
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
- **Rejected:** you get a system message saying the request was rejected.
You don't need to poll or retry — the result arrives automatically.
### Examples
```bash
# Read commands (no approval needed)
ncl groups get
ncl groups config get
ncl sessions list
ncl destinations list
ncl members list
# Write commands (approval required)
ncl groups restart
ncl groups restart --rebuild --message "Config updated."
ncl groups config update --model claude-sonnet-4-5-20250514
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
ncl groups config add-package --npm some-package
ncl members add --user telegram:jane
```
### Important
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
### Tips
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.

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

@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
```
Do not ask the user to give you credentials. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).

View File

@@ -14,13 +14,18 @@ afterEach(() => {
closeSessionDb();
});
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
)
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
}
describe('formatter', () => {
@@ -47,7 +52,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 +60,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 +79,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', () => {
@@ -129,6 +136,58 @@ describe('accumulate gate (trigger column)', () => {
});
});
describe('on_wake filtering', () => {
it('first poll returns on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('subsequent polls skip on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(0);
});
it('normal messages returned regardless of isFirstPoll', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
expect(getPendingMessages(true)).toHaveLength(1);
// Reset: mark completed so we can re-test with a fresh message
markCompleted(['m1']);
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
expect(getPendingMessages(false)).toHaveLength(1);
});
it('mixed batch: first poll returns both normal and on_wake messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(2);
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
});
it('mixed batch: subsequent poll returns only normal messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('on_wake defaults to 0 for inserts without explicit value', () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
)
.run();
// Should be returned even on non-first poll (on_wake=0)
expect(getPendingMessages(false)).toHaveLength(1);
});
});
describe('routing', () => {
it('should extract routing from messages', () => {
getInboundDb()
@@ -147,6 +206,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;
@@ -62,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
clearStaleProcessingAcks();
let pollCount = 0;
let isFirstPoll = true;
while (true) {
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
isFirstPoll = false;
pollCount++;
// Periodic heartbeat so we know the loop is alive
@@ -170,6 +177,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 +208,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 +378,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 +414,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 +466,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 +478,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

@@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider {
private mcpServers: Record<string, McpServerConfig>;
private env: Record<string, string | undefined>;
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
this.mcpServers = options.mcpServers ?? {};
this.additionalDirectories = options.additionalDirectories;
this.model = options.model;
this.effort = options.effort;
this.env = {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
@@ -293,6 +297,9 @@ export class ClaudeProvider implements AgentProvider {
],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
effort: this.effort as any,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
@@ -329,7 +336,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

@@ -25,6 +25,16 @@ export interface ProviderOptions {
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
/**
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
* to the underlying SDK. If omitted, the SDK default is used.
*/
model?: string;
/**
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
}
export interface QueryInput {
@@ -79,4 +89,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`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.

View File

@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
### 1.1 `agent_groups`
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
```sql
CREATE TABLE agent_groups (
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
);
```
### 1.15 `container_configs`
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
```sql
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
updated_at TEXT NOT NULL
);
```
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
---
## 2. Migration system
@@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.

View File

@@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task,
```sql
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL -- JSON; shape depends on kind
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL, -- JSON; shape depends on kind
source_session_id TEXT, -- agent-to-agent return path
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
);
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
```

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)

View File

@@ -1,10 +1,13 @@
{
"name": "nanoclaw",
"version": "2.0.33",
"version": "2.0.54",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
"main": "dist/index.js",
"bin": {
"ncl": "bin/ncl"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
@@ -16,6 +19,7 @@
"prepare": "husky",
"setup": "tsx setup/index.ts",
"setup:auto": "tsx setup/auto.ts",
"ncl": "tsx src/cli/client.ts",
"chat": "tsx scripts/chat.ts",
"auth": "tsx src/whatsapp-auth.ts",
"lint": "eslint src/",
@@ -26,7 +30,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="174k tokens, 87% of context window">
<title>174k tokens, 87% 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">174k</text>
<text x="71" y="14">174k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,5 +1,5 @@
/**
* nc — chat with your NanoClaw agent from the terminal.
* ncl — chat with your NanoClaw agent from the terminal.
*
* Usage:
* pnpm run chat <message...>
@@ -36,7 +36,7 @@ function main(): void {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
console.error('Start the service (launchctl/systemd) before running nc.');
console.error('Start the service (launchctl/systemd) before running ncl.');
} else {
console.error('CLI socket error:', err);
}

View File

@@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
granted_at: now,
});
}
// Owner's agent group gets global CLI access
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
} else if (args.role === 'admin') {
const alreadyAdmin = existingRoles.some(
(r) => r.role === 'admin' && r.agent_group_id === ag.id,

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',
);
@@ -308,9 +317,9 @@ async function collectSlackUserId(): Promise<string> {
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (top right)',
' 1. In Slack, click your profile picture (bottom left)',
' 2. Click "Profile"',
' 3. Click the three dots () → "Copy member ID"',
' 3. Click the three dots () → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);

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

@@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise<void> {
});
process.exit(1);
}
installCliSymlink(projectRoot, homeDir);
}
/**
* Symlink bin/ncl into ~/.local/bin so `ncl` is available from anywhere.
* Idempotent — overwrites an existing symlink but won't clobber a real file.
*/
function installCliSymlink(projectRoot: string, homeDir: string): void {
const source = path.join(projectRoot, 'bin', 'ncl');
const targetDir = path.join(homeDir, '.local', 'bin');
const target = path.join(targetDir, 'ncl');
try {
fs.mkdirSync(targetDir, { recursive: true });
// Remove existing symlink (but not a real file)
try {
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink()) {
fs.unlinkSync(target);
} else {
log.warn('~/.local/bin/ncl exists and is not a symlink — skipping', { target });
return;
}
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw err;
}
fs.symlinkSync(source, target);
log.info('Installed ncl CLI symlink', { target, source });
} catch (err) {
log.warn('Could not install ncl CLI symlink (non-fatal)', { err });
}
}
function setupLaunchd(

View File

@@ -0,0 +1,78 @@
/**
* One-time backfill: seed `container_configs` rows from existing
* `groups/<folder>/container.json` files and `agent_groups.agent_provider`.
*
* Runs after migrations, before channel adapters start. Idempotent — skips
* groups that already have a config row.
*/
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import type { McpServerConfig, AdditionalMountConfig } from './container-config.js';
import { getAllAgentGroups } from './db/agent-groups.js';
import { getContainerConfig, createContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import type { ContainerConfigRow } from './types.js';
interface LegacyContainerJson {
mcpServers?: Record<string, McpServerConfig>;
packages?: { apt?: string[]; npm?: string[] };
imageTag?: string;
additionalMounts?: AdditionalMountConfig[];
skills?: string[] | 'all';
provider?: string;
assistantName?: string;
maxMessagesPerPrompt?: number;
}
export function backfillContainerConfigs(): void {
const groups = getAllAgentGroups();
let backfilled = 0;
for (const group of groups) {
// Skip if already has a config row
if (getContainerConfig(group.id)) continue;
// Read legacy container.json from disk
const filePath = path.join(GROUPS_DIR, group.folder, 'container.json');
let legacy: LegacyContainerJson = {};
if (fs.existsSync(filePath)) {
try {
legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson;
} catch (err) {
log.warn('Backfill: failed to parse container.json, using defaults', {
folder: group.folder,
err: String(err),
});
}
}
// DB agent_provider wins over file provider (matches old cascade)
const provider = group.agent_provider || legacy.provider || null;
const row: ContainerConfigRow = {
agent_group_id: group.id,
provider,
model: null,
effort: null,
image_tag: legacy.imageTag ?? null,
assistant_name: legacy.assistantName ?? null,
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
skills: JSON.stringify(legacy.skills ?? 'all'),
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
cli_scope: 'group',
updated_at: new Date().toISOString(),
};
createContainerConfig(row);
backfilled++;
}
if (backfilled > 0) {
log.info('Backfilled container_configs from disk', { count: backfilled });
}
}

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

@@ -18,7 +18,8 @@ import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { readContainerConfig } from './container-config.js';
import type { McpServerConfig } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
@@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
// Desired fragment set.
const config = readContainerConfig(group.folder);
const configRow = getContainerConfig(group.id);
const mcpServers: Record<string, McpServerConfig> = configRow
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
: {};
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
// Skill fragments — every skill that ships an `instructions.md`.
@@ -75,13 +79,15 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// Built-in module fragments — every MCP tool source file that ships a
// sibling `<name>.instructions.md`. These describe how the agent should
// use that module's MCP tools (schedule_task, install_packages, etc.).
// Always included — these are built-in, not toggleable.
// Skip cli.instructions.md when cli_scope is disabled.
const cliDisabled = configRow?.cli_scope === 'disabled';
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
if (moduleName === 'cli' && cliDisabled) continue;
desired.set(`module-${moduleName}.md`, {
type: 'symlink',
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
@@ -91,7 +97,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// MCP server fragments — inline instructions from container.json for
// user-added external MCP servers.
for (const [name, mcp] of Object.entries(config.mcpServers)) {
for (const [name, mcp] of Object.entries(mcpServers)) {
if (mcp.instructions) {
desired.set(`mcp-${name}.md`, {
type: 'inline',

126
src/cli/client.ts Normal file
View File

@@ -0,0 +1,126 @@
/**
* `ncl` binary entry point.
*
* Parses argv, builds a request frame, sends it via the picked transport,
* formats the response, exits non-zero on error.
*
* Usage:
* ncl <resource> <verb> [target] [--key value ...] [--json]
*
* Examples:
* ncl groups list
* ncl groups get abc123
* ncl groups create --name foo --folder bar
* ncl groups update abc123 --name baz
* ncl help
* ncl groups help
*/
import { randomUUID } from 'crypto';
import { formatResponse } from './format.js';
import type { RequestFrame } from './frame.js';
import { SocketTransport } from './socket-client.js';
import type { Transport } from './transport.js';
async function main(): Promise<void> {
const argv = process.argv.slice(2);
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
printUsage();
process.exit(0);
}
const { command, args, json } = parseArgv(argv);
const req: RequestFrame = { id: randomUUID(), command, args };
const transport: Transport = pickTransport();
let res;
try {
res = await transport.sendFrame(req);
} catch (e) {
process.stderr.write(formatTransportError(e));
process.exit(2);
}
process.stdout.write(formatResponse(res, json ? 'json' : 'human'));
process.exit(res.ok ? 0 : 1);
}
function pickTransport(): Transport {
return new SocketTransport();
}
function parseArgv(argv: string[]): {
command: string;
args: Record<string, unknown>;
json: boolean;
} {
const positional: string[] = [];
const args: Record<string, unknown> = {};
let json = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') {
json = true;
continue;
}
if (a.startsWith('--')) {
const key = a.slice(2);
const next = argv[i + 1];
if (next === undefined || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i++;
}
continue;
}
positional.push(a);
}
if (positional.length === 0) {
process.stderr.write('ncl: missing command\n');
printUsage();
process.exit(2);
}
// Join all positionals with dashes to form the command name.
// If the full name isn't a command, the dispatcher will try trimming
// the last segment and using it as the target ID (e.g. `groups get abc`
// → command "groups-get", id "abc").
const command = positional.join('-');
return { command, args, json };
}
function printUsage(): void {
process.stdout.write(
[
'Usage: ncl <resource> <verb> [target] [--key value ...] [--json]',
'',
'Run `ncl help` to list available resources and commands.',
'',
].join('\n'),
);
}
function formatTransportError(e: unknown): string {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
return [
`ncl: cannot reach NanoClaw host (${msg}).`,
`Is the host running? Start it with: pnpm run dev`,
`Or, if installed as a service:`,
` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
` Linux: systemctl --user restart nanoclaw`,
``,
].join('\n');
}
return `ncl: transport error: ${msg}\n`;
}
main().catch((err) => {
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(2);
});

137
src/cli/commands/help.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Built-in help command. Introspects the resource and command registries.
*
* ncl help — list all resources and commands
* ncl groups help — show group resource details (verbs, columns, enums)
*/
import { getContainerConfig } from '../../db/container-configs.js';
import { getResource, getResources } from '../crud.js';
import type { CallerContext } from '../frame.js';
import { listCommands, register } from '../registry.js';
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
function getCliScope(ctx: CallerContext): string | undefined {
if (ctx.caller !== 'agent') return undefined;
return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group';
}
register({
name: 'help',
description: 'List available resources and commands.',
access: 'open',
parseArgs: () => ({}),
handler: async (_args, ctx) => {
const cliScope = getCliScope(ctx);
let resources = getResources();
if (cliScope === 'group') {
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
}
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
const lines: string[] = [];
if (cliScope === 'group') {
lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)');
lines.push('');
}
if (resources.length > 0) {
lines.push('Resources:');
for (const r of resources) {
const ops: string[] = [];
if (r.operations.list) ops.push('list');
if (r.operations.get) ops.push('get');
if (r.operations.create) ops.push('create');
if (r.operations.update) ops.push('update');
if (r.operations.delete) ops.push('delete');
if (r.customOperations) ops.push(...Object.keys(r.customOperations));
lines.push(` ${r.plural.padEnd(20)} ${r.description}`);
lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`);
}
}
if (commands.length > 0) {
if (lines.length > 0) lines.push('');
lines.push('Commands:');
for (const c of commands) {
lines.push(` ${c.name.padEnd(20)} ${c.description}`);
}
}
lines.push('');
lines.push('Run `ncl <resource> help` for detailed field information.');
return lines.join('\n');
},
});
// Register per-resource help commands. These are registered dynamically
// after the resources barrel has been imported.
// We use a lazy approach: register a catch-all pattern isn't possible with
// the flat registry, so we register `<plural>-help` for each resource
// in a post-import hook.
export function registerResourceHelpCommands(): void {
for (const res of getResources()) {
// Skip if already registered (e.g. from a previous call)
try {
register({
name: `${res.plural}-help`,
description: `Show ${res.name} resource details.`,
access: 'open',
resource: res.plural,
parseArgs: () => ({}),
handler: async (_args, ctx) => {
const cliScope = getCliScope(ctx);
const lines: string[] = [];
lines.push(`${res.plural}: ${res.description}`);
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
lines.push('');
lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.');
}
lines.push('');
// Verbs
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
const idHint = idAutoFilled ? '' : ' <id>';
const verbs: string[] = [];
if (res.operations.list) verbs.push(`list [open]`);
if (res.operations.get) verbs.push(`get${idHint} [open]`);
if (res.operations.create) verbs.push(`create [approval]`);
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
if (res.customOperations) {
for (const [verb, op] of Object.entries(res.customOperations)) {
verbs.push(`${verb} [${op.access}] — ${op.description}`);
}
}
lines.push('Verbs:');
for (const v of verbs) lines.push(` ${v}`);
lines.push('');
// Columns
const autoFilledFields =
cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set<string>();
lines.push('Fields:');
for (const col of res.columns) {
const tags: string[] = [];
if (autoFilledFields.has(col.name)) tags.push('auto-filled');
if (col.generated) tags.push('auto');
if (col.required) tags.push('required');
if (col.updatable) tags.push('updatable');
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
const flag = `--${col.name.replace(/_/g, '-')}`;
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`);
}
return lines.join('\n');
},
});
} catch {
// Already registered — skip
}
}
}

10
src/cli/commands/index.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Command barrel — populates the registry before the CLI server starts.
*
* Resource definitions register their CRUD commands on import.
* Help commands are registered after resources are loaded.
*/
import '../resources/index.js';
import { registerResourceHelpCommands } from './help.js';
registerResourceHelpCommands();

291
src/cli/crud.ts Normal file
View File

@@ -0,0 +1,291 @@
/**
* CRUD registration helper.
*
* Takes a declarative resource definition (table, columns, access levels)
* and auto-registers list/get/create/update/delete commands in the CLI
* registry. Column metadata doubles as documentation — `ncl <resource> help`
* is generated from the same definitions.
*/
import { randomUUID } from 'crypto';
import { getDb } from '../db/connection.js';
import { register } from './registry.js';
import type { CallerContext } from './frame.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type Access = 'open' | 'approval' | 'hidden';
export interface ColumnDef {
name: string;
type: 'string' | 'number' | 'boolean' | 'json';
description: string;
/** Auto-set on create — not user-provided. */
generated?: boolean;
/** Must be provided on create (ignored if generated). */
required?: boolean;
/** Can be changed via update. */
updatable?: boolean;
/** Default value on create when not provided. */
default?: unknown;
/** Allowed values (shown in help). */
enum?: string[];
}
export interface CustomOperation {
access: Access;
description: string;
args?: ColumnDef[];
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
}
export interface ResourceDef {
/** Singular name: 'group'. */
name: string;
/** Plural name: 'groups'. Used in command names. */
plural: string;
/** DB table name. */
table: string;
/** One-line description shown in help. */
description: string;
/** Primary key column name. */
idColumn: string;
columns: ColumnDef[];
/** Which standard CRUD operations are enabled. */
operations: {
list?: Access;
get?: Access;
create?: Access;
update?: Access;
delete?: Access;
};
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
customOperations?: Record<string, CustomOperation>;
}
// ---------------------------------------------------------------------------
// Resource registry (for help introspection)
// ---------------------------------------------------------------------------
const resources = new Map<string, ResourceDef>();
export function getResources(): ResourceDef[] {
return [...resources.values()].sort((a, b) => a.plural.localeCompare(b.plural));
}
export function getResource(plural: string): ResourceDef | undefined {
return resources.get(plural);
}
// ---------------------------------------------------------------------------
// Generic SQL handlers
// ---------------------------------------------------------------------------
function visibleColumns(def: ResourceDef): string[] {
return def.columns.map((c) => c.name);
}
function genericList(def: ResourceDef) {
const cols = visibleColumns(def).join(', ');
const filterableNames = new Set(def.columns.filter((c) => !c.generated).map((c) => c.name));
return async (args: Record<string, unknown>) => {
const limit = args.limit !== undefined ? Math.max(1, Number(args.limit)) : 200;
const filters: string[] = [];
const params: unknown[] = [];
for (const [k, v] of Object.entries(args)) {
if (k === 'id' || k === 'limit') continue;
if (filterableNames.has(k)) {
filters.push(`${k} = ?`);
params.push(v);
}
}
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
params.push(limit);
return getDb()
.prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`)
.all(...params);
};
}
function genericGet(def: ResourceDef) {
const cols = visibleColumns(def).join(', ');
return async (args: Record<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
const row = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
if (!row) throw new Error(`${def.name} not found: ${id}`);
return row;
};
}
function genericCreate(def: ResourceDef) {
return async (args: Record<string, unknown>) => {
const values: Record<string, unknown> = {};
for (const col of def.columns) {
if (col.generated) {
if (col.name === def.idColumn) {
values[col.name] = randomUUID();
} else if (col.name.endsWith('_at')) {
values[col.name] = new Date().toISOString();
}
continue;
}
const v = args[col.name];
if (v !== undefined) {
if (col.enum && !col.enum.includes(String(v))) {
throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`);
}
values[col.name] = col.type === 'number' ? Number(v) : v;
} else if (col.required) {
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
} else if (col.default !== undefined) {
values[col.name] = col.default;
}
}
const colNames = Object.keys(values);
const placeholders = colNames.map((c) => `@${c}`);
getDb()
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
.run(values);
return values;
};
}
function genericUpdate(def: ResourceDef) {
const updatableCols = def.columns.filter((c) => c.updatable);
return async (args: Record<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
const updates: Record<string, unknown> = {};
for (const col of updatableCols) {
const v = args[col.name];
if (v !== undefined) {
if (col.enum && !col.enum.includes(String(v))) {
throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`);
}
updates[col.name] = col.type === 'number' ? Number(v) : v;
}
}
if (Object.keys(updates).length === 0) {
throw new Error(
`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`,
);
}
const setClause = Object.keys(updates)
.map((k) => `${k} = @${k}`)
.join(', ');
const result = getDb()
.prepare(`UPDATE ${def.table} SET ${setClause} WHERE ${def.idColumn} = @_id`)
.run({ ...updates, _id: id });
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
const cols = visibleColumns(def).join(', ');
return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
};
}
function genericDelete(def: ResourceDef) {
return async (args: Record<string, unknown>) => {
const id = args.id as string;
if (!id) throw new Error(`${def.name} id is required`);
const result = getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id);
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
return { deleted: id };
};
}
// ---------------------------------------------------------------------------
// parseArgs helper: normalizes --hyphen-keys to underscore_keys
// ---------------------------------------------------------------------------
function normalizeArgs(raw: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw)) {
out[k.replace(/-/g, '_')] = v;
}
return out;
}
// ---------------------------------------------------------------------------
// registerResource
// ---------------------------------------------------------------------------
export function registerResource(def: ResourceDef): void {
resources.set(def.plural, def);
if (def.operations.list) {
register({
name: `${def.plural}-list`,
description: `List all ${def.plural}.`,
access: def.operations.list,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: genericList(def),
});
}
if (def.operations.get) {
register({
name: `${def.plural}-get`,
description: `Get a ${def.name} by ID.`,
access: def.operations.get,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: genericGet(def),
});
}
if (def.operations.create) {
register({
name: `${def.plural}-create`,
description: `Create a new ${def.name}.`,
access: def.operations.create,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: genericCreate(def),
});
}
if (def.operations.update) {
register({
name: `${def.plural}-update`,
description: `Update a ${def.name}.`,
access: def.operations.update,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: genericUpdate(def),
});
}
if (def.operations.delete) {
register({
name: `${def.plural}-delete`,
description: `Delete a ${def.name}.`,
access: def.operations.delete,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: genericDelete(def),
});
}
// Custom operations
if (def.customOperations) {
for (const [verb, op] of Object.entries(def.customOperations)) {
register({
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
description: op.description,
access: op.access,
resource: def.plural,
parseArgs: (raw) => normalizeArgs(raw),
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
});
}
}
}

View File

@@ -0,0 +1,59 @@
/**
* Delivery action handler for CLI requests from container agents.
*
* When an agent writes a `cli_request` system message to outbound.db,
* the delivery poll picks it up and calls this handler. We dispatch
* the command and write the response back to inbound.db.
*/
import type Database from 'better-sqlite3';
import { registerDeliveryAction } from '../delivery.js';
import { insertMessage } from '../db/session-db.js';
import { log } from '../log.js';
import { dispatch } from './dispatch.js';
import type { RequestFrame } from './frame.js';
import type { Session } from '../types.js';
registerDeliveryAction('cli_request', async (content, session, inDb) => {
const requestId = content.requestId as string;
const command = content.command as string;
const args = (content.args as Record<string, unknown>) ?? {};
if (!requestId || !command) {
log.warn('cli_request missing requestId or command', { sessionId: session.id });
return;
}
const req: RequestFrame = { id: requestId, command, args };
const ctx = {
caller: 'agent' as const,
sessionId: session.id,
agentGroupId: session.agent_group_id,
messagingGroupId: session.messaging_group_id ?? '',
};
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
const response = await dispatch(req, ctx);
// Write response to inbound.db so the container can read it.
// trigger=0: don't wake the agent — this is an inline response to a tool call.
insertMessage(inDb, {
id: `cli-resp-${requestId}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: null,
channelType: null,
threadId: null,
content: JSON.stringify({
type: 'cli_response',
requestId,
frame: response,
}),
processAfter: null,
recurrence: null,
trigger: 0,
});
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
});

405
src/cli/dispatch.test.ts Normal file
View File

@@ -0,0 +1,405 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
vi.mock('../log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockGetContainerConfig = vi.fn();
vi.mock('../db/container-configs.js', () => ({
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
}));
const mockGetAgentGroup = vi.fn();
vi.mock('../db/agent-groups.js', () => ({
getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args),
}));
const mockGetSession = vi.fn();
vi.mock('../db/sessions.js', () => ({
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
vi.mock('../modules/approvals/index.js', () => ({
registerApprovalHandler: vi.fn(),
requestApproval: vi.fn(),
}));
// Register a test command so dispatch has something to find
import { register } from './registry.js';
register({
name: 'test-cmd',
description: 'test command (non-group resource)',
resource: 'test',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'groups-test',
description: 'test command (groups resource)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'general-cmd',
description: 'test command (no resource, like help)',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'sessions-list',
description: 'test command (sessions resource)',
resource: 'sessions',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'destinations-list',
description: 'test command (destinations resource)',
resource: 'destinations',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'members-add',
description: 'test command (members resource)',
resource: 'members',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
register({
name: 'wirings-list',
description: 'test command (wirings resource — not allowed)',
resource: 'wirings',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({ echo: args }),
});
// Commands that return data shaped like real resources (for post-handler filtering tests)
register({
name: 'groups-list-data',
description: 'returns mock group rows',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => [
{ id: 'g1', name: 'my-group' },
{ id: 'g2', name: 'other-group' },
],
});
register({
name: 'sessions-get-data',
description: 'returns a mock session row',
resource: 'sessions',
access: 'open',
parseArgs: (raw) => raw,
handler: async (args) => ({
id: args.id,
agent_group_id: (args as Record<string, unknown>).belongs_to ?? 'g1',
}),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Helpers ---
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): CallerContext {
return {
caller: 'agent',
sessionId: 's1',
agentGroupId: 'g1',
messagingGroupId: 'mg1',
...overrides,
};
}
// --- Tests ---
describe('CLI scope enforcement', () => {
it('disabled: rejects all CLI requests from agent', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('disabled');
}
});
it('group: auto-fills --id with caller agent group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe('g1');
}
});
it('group: rejects cross-group access', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('scoped');
}
});
it('group: allows same-group id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx());
expect(resp.ok).toBe(true);
});
it('group: blocks cli_scope escalation', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('cli_scope');
}
});
it('group: blocks cli-scope escalation (hyphenated)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: blocks non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('test');
}
});
it('group: allows general commands with no resource (e.g. help)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
});
it('group: allows sessions, auto-fills --agent_group_id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.agent_group_id).toBe('g1');
// --id should NOT be auto-filled for sessions (it's session UUID, not group)
expect(data.echo.id).toBeUndefined();
}
});
it('group: allows destinations, auto-fills --id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBe('g1');
}
});
it('group: allows members, auto-fills --group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.group).toBe('g1');
}
});
it('group: blocks non-whitelisted resources (wirings)', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('wirings');
}
});
it('group: rejects cross-group --agent_group_id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('group: rejects cross-group --group', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('global: allows cross-group access', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx());
expect(resp.ok).toBe(true);
});
it('global: allows non-group resources', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
});
it('global: does not auto-fill --id', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as { echo: Record<string, unknown> };
expect(data.echo.id).toBeUndefined();
}
});
it('defaults to group when cli_scope is missing', async () => {
mockGetContainerConfig.mockReturnValue({});
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
}
});
it('host caller bypasses CLI scope enforcement', async () => {
// No config check should happen for host callers
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' });
expect(resp.ok).toBe(true);
expect(mockGetContainerConfig).not.toHaveBeenCalled();
});
// --- Post-handler filtering ---
it('group: groups list filters out other groups', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as Array<{ id: string }>;
expect(data).toHaveLength(1);
expect(data[0].id).toBe('g1');
}
});
it('group: sessions get rejects cross-group session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } },
agentCtx(),
);
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('different agent group');
}
});
it('group: sessions get allows own-group session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
const resp = await dispatch(
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } },
agentCtx(),
);
expect(resp.ok).toBe(true);
});
it('global: no post-handler filtering', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
const data = resp.data as Array<{ id: string }>;
expect(data).toHaveLength(2); // both groups returned
}
});
});

174
src/cli/dispatch.ts Normal file
View File

@@ -0,0 +1,174 @@
/**
* Transport-agnostic dispatcher. Both the socket server (host caller) and
* the per-session DB poller (container caller) call dispatch() with the
* same frame and a transport-supplied CallerContext.
*
* Approval gating for risky calls from the container is the only branch
* that differs by caller. Host callers and `open` commands run inline.
*/
import { getContainerConfig } from '../db/container-configs.js';
import { getAgentGroup } from '../db/agent-groups.js';
import { getSession } from '../db/sessions.js';
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
import { lookup } from './registry.js';
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
let cmd = lookup(req.command);
// Fallback: if the full command isn't registered, trim the last
// dash-segment and treat it as the target ID. This lets clients join
// all positional args with dashes (e.g. `ncl groups get abc123`
// → command "groups-get-abc123" → trim → "groups-get" + id "abc123").
if (!cmd) {
const idx = req.command.lastIndexOf('-');
if (idx > 0) {
const shortened = req.command.slice(0, idx);
const tail = req.command.slice(idx + 1);
const fallback = lookup(shortened);
if (fallback) {
cmd = fallback;
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
}
}
}
if (!cmd) {
return err(req.id, 'unknown-command', `no command "${req.command}"`);
}
// CLI scope enforcement for agent callers
if (ctx.caller === 'agent') {
const configRow = getContainerConfig(ctx.agentGroupId);
const cliScope = configRow?.cli_scope ?? 'group';
if (cliScope === 'disabled') {
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
}
if (cliScope === 'group') {
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
// Only allow whitelisted resources and general commands (no resource, like help)
if (cmd.resource && !allowed.has(cmd.resource)) {
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
}
// Enforce group scope on all agent-group-related args.
// Different resources use different arg names for the agent group ID.
// Only check --id for resources where it IS the agent group ID.
const groupArgs = ['agent_group_id', 'group'] as const;
for (const key of groupArgs) {
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
}
if (
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
req.args.id &&
req.args.id !== ctx.agentGroupId
) {
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
}
// Block cli_scope changes from group-scoped agents (privilege escalation)
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
}
// Auto-fill agent-group-related args so the agent doesn't need
// to pass its own group ID explicitly.
const fill: Record<string, unknown> = {
agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId,
group: req.args.group ?? ctx.agentGroupId,
};
// Only auto-fill --id for resources where it IS the agent group ID
// (groups, destinations). For sessions/members --id is a different key.
if (cmd.resource === 'groups' || cmd.resource === 'destinations') {
fill.id = req.args.id ?? ctx.agentGroupId;
}
req = { ...req, args: { ...req.args, ...fill } };
}
}
if (ctx.caller !== 'host' && cmd.access === 'approval') {
const session = getSession(ctx.sessionId);
if (!session) {
return err(req.id, 'handler-error', 'Session not found.');
}
const agentGroup = getAgentGroup(ctx.agentGroupId);
const agentName = agentGroup?.name ?? ctx.agentGroupId;
const argSummary = Object.entries(req.args)
.map(([k, v]) => `--${k} ${v}`)
.join(' ');
await requestApproval({
session,
agentName,
action: 'cli_command',
payload: { frame: { id: req.id, command: req.command, args: req.args } },
title: `CLI: ${req.command}`,
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
});
return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.');
}
let parsed: unknown;
try {
parsed = cmd.parseArgs(req.args);
} catch (e) {
return err(req.id, 'invalid-args', errMsg(e));
}
try {
let data = await cmd.handler(parsed, ctx);
// Post-handler group scope enforcement: filter/verify results belong
// to the caller's agent group. Catches leaks that pre-handler auto-fill
// can't prevent (e.g. `groups list` where the id arg is skipped by the
// generic list handler, or `sessions get` by UUID).
if (ctx.caller === 'agent' && cmd.resource) {
const configRow = getContainerConfig(ctx.agentGroupId);
if ((configRow?.cli_scope ?? 'group') === 'group') {
const groupField = cmd.resource === 'groups' ? 'id' : 'agent_group_id';
if (Array.isArray(data)) {
data = data.filter(
(row) =>
typeof row === 'object' &&
row !== null &&
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
);
} else if (data && typeof data === 'object' && groupField in (data as Record<string, unknown>)) {
if ((data as Record<string, unknown>)[groupField] !== ctx.agentGroupId) {
return err(req.id, 'forbidden', 'Resource belongs to a different agent group.');
}
}
}
}
return { id: req.id, ok: true, data };
} catch (e) {
return err(req.id, 'handler-error', errMsg(e));
}
}
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
const frame = payload.frame as RequestFrame;
const response = await dispatch(frame, { caller: 'host' });
if (response.ok) {
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`);
} else {
notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`);
}
});
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
return { id, ok: false, error: { code, message } };
}
function errMsg(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}

52
src/cli/format.ts Normal file
View File

@@ -0,0 +1,52 @@
/**
* Output formatting for the `ncl` binary. Two modes:
* - human (default): a small auto-table for arrays of flat records,
* JSON.stringify for everything else, plain "error: ..." line for !ok.
* - json: the response frame, pretty-printed.
*
* The MCP / agent side will always pass --json so it parses the frame
* itself. The DB transport (when it lands) skips this layer entirely —
* the agent sees frames directly.
*/
import type { ResponseFrame } from './frame.js';
export type FormatMode = 'human' | 'json';
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
if (!res.ok) {
return `error (${res.error.code}): ${res.error.message}\n`;
}
return formatHuman(res.data) + '\n';
}
function formatHuman(data: unknown): string {
if (data === null || data === undefined) return '';
if (typeof data === 'string') return data;
if (Array.isArray(data) && data.every(isFlatRecord)) {
return renderTable(data as Record<string, unknown>[]);
}
return JSON.stringify(data, null, 2);
}
function isFlatRecord(x: unknown): x is Record<string, unknown> {
if (!x || typeof x !== 'object') return false;
for (const v of Object.values(x as Record<string, unknown>)) {
if (v !== null && typeof v === 'object') return false;
}
return true;
}
function renderTable(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return '(no rows)';
const cols = Object.keys(rows[0]);
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(' ');
const lines = [
fmtRow(cols),
fmtRow(widths.map((w) => '─'.repeat(w))),
...rows.map((r) => fmtRow(cols.map((c) => String(r[c] ?? '')))),
];
return lines.join('\n');
}

45
src/cli/frame.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Wire format shared between the socket transport (host caller) and — when
* it lands — the DB transport (container agent caller).
*
* Same JSON whether it goes over a socket as a line or sits in a
* `frame_json TEXT` column on a session DB. Caller identity is NOT carried
* in the frame — it's filled in by whichever server-side adapter received
* the bytes (see CallerContext).
*/
export type RequestFrame = {
/** Correlation key set by the client. */
id: string;
/** Registry name, e.g. "list-groups". */
command: string;
/** Command-specific. Each command's parseArgs validates. */
args: Record<string, unknown>;
};
export type ResponseFrame =
| { id: string; ok: true; data: unknown }
| { id: string; ok: false; error: { code: ErrorCode; message: string } };
export type ErrorCode =
| 'unknown-command'
| 'invalid-args'
| 'permission-denied'
| 'forbidden'
| 'approval-pending'
| 'not-found'
| 'handler-error'
| 'transport-error';
/**
* Filled in by the transport adapter on the server side. Handlers read
* caller identity from here, never from the frame.
*/
export type CallerContext =
| { caller: 'host' }
| {
caller: 'agent';
sessionId: string;
agentGroupId: string;
messagingGroupId: string;
};

38
src/cli/registry.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Command registry — single source of truth for what `ncl` can do.
*
* Each command file under `commands/` calls `register()` at top level,
* and `commands/index.ts` imports them all for side effects so the
* registry is populated before the host's CLI server accepts connections.
*/
import type { CallerContext } from './frame.js';
export type Access = 'open' | 'approval' | 'hidden';
export type CommandDef<TArgs = unknown, TData = unknown> = {
name: string;
description: string;
access: Access;
/** Resource this command belongs to (for help grouping). */
resource?: string;
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
parseArgs: (raw: Record<string, unknown>) => TArgs;
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
};
const registry = new Map<string, CommandDef>();
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
if (registry.has(def.name)) {
throw new Error(`CLI command "${def.name}" already registered`);
}
registry.set(def.name, def as CommandDef);
}
export function lookup(name: string): CommandDef | undefined {
return registry.get(name);
}
export function listCommands(): CommandDef[] {
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
}

View File

@@ -0,0 +1,53 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'approval',
plural: 'approvals',
table: 'pending_approvals',
description:
'Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.',
idColumn: 'approval_id',
columns: [
{
name: 'approval_id',
type: 'string',
description: 'Unique approval identifier (also used as the card questionId).',
},
{
name: 'session_id',
type: 'string',
description: 'Session that requested the approval. Null for OneCLI credential approvals.',
},
{
name: 'request_id',
type: 'string',
description: 'Original request identifier (OneCLI request UUID or same as approval_id).',
},
{
name: 'action',
type: 'string',
description:
'Action type — matches the registered approval handler (e.g. install_packages, add_mcp_server, onecli_credential).',
},
{ name: 'payload', type: 'json', description: 'JSON payload carried through to the approval handler.' },
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
{ name: 'agent_group_id', type: 'string', description: 'Originating agent group.' },
{ name: 'channel_type', type: 'string', description: 'Channel the approval card was delivered on.' },
{ name: 'platform_id', type: 'string', description: 'Platform chat ID the card was delivered to.' },
{
name: 'platform_message_id',
type: 'string',
description: 'Platform message ID of the delivered card (for editing on expiry).',
},
{ name: 'expires_at', type: 'string', description: 'When this approval expires (OneCLI gateway TTL).' },
{
name: 'status',
type: 'string',
description: 'Current status.',
enum: ['pending', 'approved', 'rejected', 'expired'],
},
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
],
operations: { list: 'open', get: 'open' },
});

View File

@@ -0,0 +1,77 @@
import { getDb } from '../../db/connection.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'destination',
plural: 'destinations',
table: 'agent_destinations',
description:
'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.',
idColumn: 'agent_group_id',
columns: [
{
name: 'agent_group_id',
type: 'string',
description: 'The agent that owns this destination. References agent_groups.id.',
},
{
name: 'local_name',
type: 'string',
description:
'Name the agent uses to address this target (e.g. <message to="local_name">). Unique per agent. Lowercase, dash-separated.',
},
{
name: 'target_type',
type: 'string',
description: '"channel" for messaging group targets, "agent" for agent-to-agent targets.',
enum: ['channel', 'agent'],
},
{
name: 'target_id',
type: 'string',
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
},
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
],
operations: { list: 'open' },
customOperations: {
add: {
access: 'approval',
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
handler: async (args) => {
const agentGroupId = args.agent_group_id as string;
const localName = args.local_name as string;
const targetType = args.target_type as string;
const targetId = args.target_id as string;
if (!agentGroupId) throw new Error('--agent-group-id is required');
if (!localName) throw new Error('--local-name is required');
if (!targetType || !['channel', 'agent'].includes(targetType)) {
throw new Error('--target-type must be channel or agent');
}
if (!targetId) throw new Error('--target-id is required');
getDb()
.prepare(
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(agentGroupId, localName, targetType, targetId);
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
},
},
remove: {
access: 'approval',
description: 'Remove a destination from an agent. Use --agent-group-id and --local-name.',
handler: async (args) => {
const agentGroupId = args.agent_group_id as string;
const localName = args.local_name as string;
if (!agentGroupId) throw new Error('--agent-group-id is required');
if (!localName) throw new Error('--local-name is required');
const result = getDb()
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
.run(agentGroupId, localName);
if (result.changes === 0) throw new Error('destination not found');
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
},
},
},
});

View File

@@ -0,0 +1,28 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'dropped-message',
plural: 'dropped-messages',
table: 'unregistered_senders',
description:
"Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).",
idColumn: 'channel_type',
columns: [
{ name: 'channel_type', type: 'string', description: 'Channel adapter type of the dropped message.' },
{ name: 'platform_id', type: 'string', description: 'Platform chat ID where the message was dropped.' },
{ name: 'user_id', type: 'string', description: 'Sender user ID if resolved, null otherwise.' },
{ name: 'sender_name', type: 'string', description: 'Sender display name if available.' },
{
name: 'reason',
type: 'string',
description: 'Why the message was dropped.',
enum: ['no_agent_wired', 'no_agent_engaged', 'unknown_sender_strict', 'unknown_sender_request_approval'],
},
{ name: 'messaging_group_id', type: 'string', description: 'Messaging group ID if resolved.' },
{ name: 'agent_group_id', type: 'string', description: 'Target agent group ID if resolved.' },
{ name: 'message_count', type: 'number', description: 'Number of dropped messages from this sender on this chat.' },
{ name: 'first_seen', type: 'string', description: 'First drop timestamp.' },
{ name: 'last_seen', type: 'string', description: 'Most recent drop timestamp.' },
],
operations: { list: 'open' },
});

282
src/cli/resources/groups.ts Normal file
View File

@@ -0,0 +1,282 @@
import type { McpServerConfig } from '../../container-config.js';
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
import { restartAgentGroupContainers } from '../../container-restart.js';
import { getSession } from '../../db/sessions.js';
import { writeSessionMessage } from '../../session-manager.js';
import {
getContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
} from '../../db/container-configs.js';
import type { ContainerConfigRow } from '../../types.js';
import { registerResource } from '../crud.js';
/** Deserialize JSON columns for display. */
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
return {
agent_group_id: row.agent_group_id,
provider: row.provider,
model: row.model,
effort: row.effort,
image_tag: row.image_tag,
assistant_name: row.assistant_name,
max_messages_per_prompt: row.max_messages_per_prompt,
skills: JSON.parse(row.skills),
mcp_servers: JSON.parse(row.mcp_servers),
packages_apt: JSON.parse(row.packages_apt),
packages_npm: JSON.parse(row.packages_npm),
additional_mounts: JSON.parse(row.additional_mounts),
cli_scope: row.cli_scope,
updated_at: row.updated_at,
};
}
registerResource({
name: 'group',
plural: 'groups',
table: 'agent_groups',
description:
'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
name: 'name',
type: 'string',
description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.',
required: true,
updatable: true,
},
{
name: 'folder',
type: 'string',
description:
'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
required: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
customOperations: {
restart: {
access: 'approval',
description:
'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
'From inside a container, --id is auto-filled and only the calling session is restarted. ' +
'--rebuild rebuilds the container image first (required for package changes). ' +
'--message sets an on-wake instruction for the fresh container to act on when it starts — ' +
'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' +
'Without --message, the container stops and only starts again on the next user message.',
handler: async (args, ctx) => {
const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined);
if (!id) throw new Error('--id is required');
if (args.rebuild) {
await buildAgentGroupImage(id);
}
const message = args.message as string | undefined;
// From an agent: scope to the calling session only
if (ctx.caller === 'agent') {
if (message) {
writeSessionMessage(id, ctx.sessionId, {
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: id,
channelType: 'agent',
threadId: null,
content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }),
onWake: 1,
});
}
killContainer(
ctx.sessionId,
'restarted via ncl',
message
? () => {
const s = getSession(ctx.sessionId);
if (s) wakeContainer(s);
}
: undefined,
);
return { restarted: 1, rebuilt: !!args.rebuild };
}
// From the host: restart all running containers in the group
const count = restartAgentGroupContainers(id, 'restarted via ncl', message);
return { restarted: count, rebuilt: !!args.rebuild };
},
},
'config get': {
access: 'open',
description: 'Show the container config for a group. Use --id <group-id>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
return presentConfig(row);
},
},
'config update': {
access: 'approval',
description:
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
> = {};
if (args.provider !== undefined) updates.provider = args.provider as string;
if (args.model !== undefined) updates.model = args.model as string;
if (args.effort !== undefined) updates.effort = args.effort as string;
if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string;
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
if (args.max_messages_per_prompt !== undefined)
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
if (!['disabled', 'group', 'global'].includes(scope)) {
throw new Error('--cli-scope must be one of: disabled, group, global');
}
updates.cli_scope = scope;
}
if (Object.keys(updates).length === 0) {
throw new Error(
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
);
}
updateContainerConfigScalars(id, updates);
const updated = getContainerConfig(id)!;
return presentConfig(updated);
},
},
'config add-mcp-server': {
access: 'approval',
description:
'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' +
'Use --id <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const name = args.name as string;
if (!name) throw new Error('--name is required');
const command = args.command as string;
if (!command) throw new Error('--command is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
servers[name] = {
command,
args: args.args ? (JSON.parse(args.args as string) as string[]) : [],
env: args.env ? (JSON.parse(args.env as string) as Record<string, string>) : {},
};
updateContainerConfigJson(id, 'mcp_servers', servers);
return { added: name, servers };
},
},
'config remove-mcp-server': {
access: 'approval',
description:
'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id <group-id> --name <server-name>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const name = args.name as string;
if (!name) throw new Error('--name is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
if (!servers[name]) throw new Error(`MCP server "${name}" not found`);
delete servers[name];
updateContainerConfigJson(id, 'mcp_servers', servers);
return { removed: name };
},
},
'config add-package': {
access: 'approval',
description:
'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const apt = args.apt as string | undefined;
const npm = args.npm as string | undefined;
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
if (apt) {
const existing = JSON.parse(row.packages_apt) as string[];
if (!existing.includes(apt)) {
existing.push(apt);
updateContainerConfigJson(id, 'packages_apt', existing);
}
}
if (npm) {
const existing = JSON.parse(row.packages_npm) as string[];
if (!existing.includes(npm)) {
existing.push(npm);
updateContainerConfigJson(id, 'packages_npm', existing);
}
}
return {
added: { apt: apt || null, npm: npm || null },
note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.',
};
},
},
'config remove-package': {
access: 'approval',
description:
'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
handler: async (args) => {
const id = args.id as string;
if (!id) throw new Error('--id is required');
const row = getContainerConfig(id);
if (!row) throw new Error(`No container config for group: ${id}`);
const apt = args.apt as string | undefined;
const npm = args.npm as string | undefined;
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
if (apt) {
const existing = JSON.parse(row.packages_apt) as string[];
const filtered = existing.filter((p) => p !== apt);
updateContainerConfigJson(id, 'packages_apt', filtered);
}
if (npm) {
const existing = JSON.parse(row.packages_npm) as string[];
const filtered = existing.filter((p) => p !== npm);
updateContainerConfigJson(id, 'packages_npm', filtered);
}
return {
removed: { apt: apt || null, npm: npm || null },
note: 'Image rebuild required for package changes to take effect.',
};
},
},
},
});

View File

@@ -0,0 +1,15 @@
/**
* Resource barrel — imports each resource module for its side-effect
* `registerResource(...)` call.
*/
import './groups.js';
import './messaging-groups.js';
import './wirings.js';
import './users.js';
import './roles.js';
import './members.js';
import './destinations.js';
import './user-dms.js';
import './dropped-messages.js';
import './approvals.js';
import './sessions.js';

View File

@@ -0,0 +1,65 @@
import { getDb } from '../../db/connection.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'member',
plural: 'members',
table: 'agent_group_members',
description:
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
idColumn: 'user_id',
columns: [
{
name: 'user_id',
type: 'string',
description: 'The user to grant membership. Must reference an existing user (users.id).',
},
{
name: 'agent_group_id',
type: 'string',
description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).',
},
{
name: 'added_by',
type: 'string',
description: 'User ID of whoever added this member. Informational — not enforced.',
},
{ name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' },
],
operations: { list: 'open' },
customOperations: {
add: {
access: 'approval',
description: 'Add a user as a member of an agent group. Use --user and --group.',
handler: async (args) => {
const userId = args.user as string;
const groupId = args.group as string;
const addedBy = (args.added_by as string) ?? null;
if (!userId) throw new Error('--user is required');
if (!groupId) throw new Error('--group is required');
getDb()
.prepare(
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
VALUES (?, ?, ?, datetime('now'))`,
)
.run(userId, groupId, addedBy);
return { user_id: userId, agent_group_id: groupId };
},
},
remove: {
access: 'approval',
description: 'Remove a user from an agent group. Use --user and --group.',
handler: async (args) => {
const userId = args.user as string;
const groupId = args.group as string;
if (!userId) throw new Error('--user is required');
if (!groupId) throw new Error('--group is required');
const result = getDb()
.prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
.run(userId, groupId);
if (result.changes === 0) throw new Error('member not found');
return { removed: { user_id: userId, agent_group_id: groupId } };
},
},
},
});

View File

@@ -0,0 +1,58 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'messaging-group',
plural: 'messaging-groups',
table: 'messaging_groups',
description:
'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
name: 'channel_type',
type: 'string',
description:
'Channel adapter type — matches the adapter registered by /add-<channel> (e.g. telegram, discord, slack, whatsapp).',
required: true,
},
{
name: 'platform_id',
type: 'string',
description:
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
required: true,
},
{
name: 'name',
type: 'string',
description: 'Display name. Often auto-populated by the channel adapter.',
updatable: true,
},
{
name: 'is_group',
type: 'number',
description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.',
default: 0,
updatable: true,
},
{
name: 'unknown_sender_policy',
type: 'string',
description:
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
enum: ['strict', 'request_approval', 'public'],
default: 'strict',
updatable: true,
},
{
name: 'denied_at',
type: 'string',
description:
'Set when the owner explicitly denies registering this channel. While set, the router drops all messages silently without re-escalating. Cleared by any explicit wiring mutation.',
updatable: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
});

View File

@@ -0,0 +1,67 @@
import { getDb } from '../../db/connection.js';
import { registerResource } from '../crud.js';
registerResource({
name: 'role',
plural: 'roles',
table: 'user_roles',
description:
'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).',
idColumn: 'user_id',
columns: [
{ name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' },
{
name: 'role',
type: 'string',
description: '"owner" has full control, always global. "admin" can manage groups and approve actions.',
enum: ['owner', 'admin'],
},
{
name: 'agent_group_id',
type: 'string',
description:
'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.',
},
{ name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' },
{ name: 'granted_at', type: 'string', description: 'Auto-set.' },
],
operations: { list: 'open' },
customOperations: {
grant: {
access: 'approval',
description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.',
handler: async (args) => {
const userId = args.user as string;
const role = args.role as string;
const groupId = (args.group as string) ?? null;
const grantedBy = (args.granted_by as string) ?? null;
if (!userId) throw new Error('--user is required');
if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin');
if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)');
getDb()
.prepare(
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(userId, role, groupId, grantedBy);
return { user_id: userId, role, agent_group_id: groupId };
},
},
revoke: {
access: 'approval',
description: 'Revoke a role. Use --user, --role, and --group if scoped.',
handler: async (args) => {
const userId = args.user as string;
const role = args.role as string;
const groupId = (args.group as string) ?? null;
if (!userId) throw new Error('--user is required');
if (!role) throw new Error('--role is required');
const result = getDb()
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?')
.run(userId, role, groupId);
if (result.changes === 0) throw new Error('role not found');
return { revoked: { user_id: userId, role, agent_group_id: groupId } };
},
},
},
});

View File

@@ -0,0 +1,45 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'session',
plural: 'sessions',
table: 'sessions',
description:
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
{
name: 'messaging_group_id',
type: 'string',
description: 'Messaging group this session serves. Null for agent-shared sessions.',
},
{
name: 'thread_id',
type: 'string',
description: 'Thread ID. Only set for per-thread session mode.',
},
{
name: 'agent_provider',
type: 'string',
description: 'Provider override. Null means inherit from agent group.',
},
{
name: 'status',
type: 'string',
description: '"active" receives messages. "closed" is archived.',
enum: ['active', 'closed'],
},
{
name: 'container_status',
type: 'string',
description:
'"running" — container alive and polling. "stopped" — container exited; the sweep will restart it automatically when due messages arrive. "idle" — reserved, currently unused.',
enum: ['running', 'idle', 'stopped'],
},
{ name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' },
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open' },
});

View File

@@ -0,0 +1,21 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'user-dm',
plural: 'user-dms',
table: 'user_dms',
description:
"User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.",
idColumn: 'user_id',
columns: [
{ name: 'user_id', type: 'string', description: 'User this DM route is for.' },
{ name: 'channel_type', type: 'string', description: 'Channel adapter type.' },
{
name: 'messaging_group_id',
type: 'string',
description: 'The messaging group used to deliver DMs to this user on this channel.',
},
{ name: 'resolved_at', type: 'string', description: 'When this DM route was last resolved.' },
],
operations: { list: 'open' },
});

View File

@@ -0,0 +1,35 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'user',
plural: 'users',
table: 'users',
description:
'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).',
idColumn: 'id',
columns: [
{
name: 'id',
type: 'string',
description:
'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.',
required: true,
},
{
name: 'kind',
type: 'string',
description:
'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.',
required: true,
},
{
name: 'display_name',
type: 'string',
description:
'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.',
updatable: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
});

View File

@@ -0,0 +1,70 @@
import { registerResource } from '../crud.js';
registerResource({
name: 'wiring',
plural: 'wirings',
table: 'messaging_group_agents',
description:
'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.',
idColumn: 'id',
columns: [
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
{
name: 'messaging_group_id',
type: 'string',
description: 'The chat/channel to route from. References messaging_groups.id.',
required: true,
},
{
name: 'agent_group_id',
type: 'string',
description: 'The agent that handles messages. References agent_groups.id.',
required: true,
},
{
name: 'engage_mode',
type: 'string',
description:
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
enum: ['pattern', 'mention', 'mention-sticky'],
default: 'mention',
updatable: true,
},
{
name: 'engage_pattern',
type: 'string',
description:
'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.',
updatable: true,
},
{
name: 'sender_scope',
type: 'string',
description:
'"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.',
enum: ['all', 'known'],
default: 'all',
updatable: true,
},
{
name: 'ignored_message_policy',
type: 'string',
description:
'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.',
enum: ['drop', 'accumulate'],
default: 'drop',
updatable: true,
},
{
name: 'session_mode',
type: 'string',
description:
'"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent. Note: threaded adapters in group chats force per-thread regardless of this setting.',
enum: ['shared', 'per-thread', 'agent-shared'],
default: 'shared',
updatable: true,
},
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
],
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
});

63
src/cli/socket-client.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* SocketTransport — client side. Used by the `ncl` binary when running on
* the host (i.e. invoked from a shell or by Claude in the project).
*
* Wire format: line-delimited JSON. One request per connection; the server
* writes one response and closes.
*/
import net from 'net';
import path from 'path';
import { DATA_DIR } from '../config.js';
import type { RequestFrame, ResponseFrame } from './frame.js';
import type { Transport } from './transport.js';
export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'ncl.sock');
export class SocketTransport implements Transport {
constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {}
async sendFrame(req: RequestFrame): Promise<ResponseFrame> {
return new Promise((resolve, reject) => {
const client = net.createConnection(this.socketPath);
let buffer = '';
let settled = false;
const settle = (action: 'resolve' | 'reject', valueOrErr: ResponseFrame | Error): void => {
if (settled) return;
settled = true;
try {
client.end();
} catch (_e) {
// best-effort
}
if (action === 'resolve') resolve(valueOrErr as ResponseFrame);
else reject(valueOrErr as Error);
};
client.on('connect', () => {
client.write(JSON.stringify(req) + '\n');
});
client.on('data', (chunk) => {
buffer += chunk.toString('utf8');
const idx = buffer.indexOf('\n');
if (idx < 0) return;
const line = buffer.slice(0, idx);
try {
const frame = JSON.parse(line) as ResponseFrame;
settle('resolve', frame);
} catch (e) {
settle('reject', new Error(`malformed response from host: ${e instanceof Error ? e.message : String(e)}`));
}
});
client.on('error', (err) => settle('reject', err));
client.on('close', () => {
if (!settled) {
settle('reject', new Error('host closed connection before sending response'));
}
});
});
}
}

111
src/cli/socket-server.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Host-side socket listener. Started from src/index.ts, accepts one frame
* per connection, calls dispatch() with caller='host', writes the response
* frame, closes.
*
* Lives at data/ncl.sock (separate from data/cli.sock, which the existing
* chat-style CLI channel adapter owns). Socket file is chmod 0600 — only
* the user that started the host can connect.
*/
import fs from 'fs';
import net from 'net';
import { log } from '../log.js';
import { dispatch } from './dispatch.js';
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
import { DEFAULT_SOCKET_PATH } from './socket-client.js';
let server: net.Server | null = null;
export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): Promise<void> {
// Stale-socket cleanup — a previous run that crashed may have left the
// file behind, and net.createServer refuses to bind to an existing path.
try {
fs.unlinkSync(socketPath);
} catch (err) {
const e = err as NodeJS.ErrnoException;
if (e.code !== 'ENOENT') {
log.warn('Failed to unlink stale ncl socket (will try to bind anyway)', { socketPath, err });
}
}
const s = net.createServer((conn) => handleConnection(conn));
server = s;
await new Promise<void>((resolve, reject) => {
s.once('error', reject);
s.listen(socketPath, () => {
try {
fs.chmodSync(socketPath, 0o600);
} catch (err) {
log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err });
}
log.info('ncl CLI server listening', { socketPath });
resolve();
});
});
}
export async function stopCliServer(): Promise<void> {
if (!server) return;
const s = server;
server = null;
await new Promise<void>((resolve) => s.close(() => resolve()));
}
function handleConnection(conn: net.Socket): void {
let buffer = '';
conn.on('data', (chunk) => {
buffer += chunk.toString('utf8');
let idx: number;
while ((idx = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, idx).trim();
buffer = buffer.slice(idx + 1);
if (!line) continue;
void handleFrame(conn, line);
}
});
conn.on('error', (err) => {
log.warn('ncl CLI server connection error', { err });
});
}
async function handleFrame(conn: net.Socket, line: string): Promise<void> {
let req: RequestFrame;
try {
const parsed: unknown = JSON.parse(line);
if (!isRequestFrame(parsed)) throw new Error('bad request shape');
req = parsed;
} catch (e) {
write(conn, {
id: 'unknown',
ok: false,
error: {
code: 'transport-error',
message: `bad frame: ${e instanceof Error ? e.message : String(e)}`,
},
});
return;
}
// Host caller — connecting to data/ncl.sock requires file-system access
// to a 0600 socket owned by the host user, so we treat the socket path
// itself as the auth boundary.
const ctx: CallerContext = { caller: 'host' };
const res = await dispatch(req, ctx);
write(conn, res);
}
function write(conn: net.Socket, frame: ResponseFrame): void {
try {
conn.write(JSON.stringify(frame) + '\n');
conn.end();
} catch (err) {
log.warn('Failed to write ncl CLI response', { err });
}
}
function isRequestFrame(x: unknown): x is RequestFrame {
if (!x || typeof x !== 'object') return false;
const o = x as Record<string, unknown>;
return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null;
}

10
src/cli/transport.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Client-side transport interface. The `ncl` binary picks one of these and
* calls sendFrame; the caller doesn't know whether bytes traveled over a
* Unix socket (host) or through outbound.db / inbound.db rows (container).
*/
import type { RequestFrame, ResponseFrame } from './frame.js';
export interface Transport {
sendFrame(req: RequestFrame): Promise<ResponseFrame>;
}

View File

@@ -1,26 +1,25 @@
/**
* Per-group container config, stored as a plain JSON file at
* `groups/<folder>/container.json`. Mounted read-only inside the container
* at `/workspace/agent/container.json` — the runner reads it at startup but
* cannot modify it. Config changes go through the self-mod approval flow.
* Container config types and materialization.
*
* All fields are optional — a missing file or a partial file both resolve
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
* worth the ceremony here since there's only one writer in practice: the
* host, from the delivery thread that processes approved system actions).
* Source of truth is the `container_configs` table in the central DB.
* This module provides:
* - Type definitions for the file shape (read by the container runner)
* - `materializeContainerJson()` — writes `groups/<folder>/container.json`
* from the DB at spawn time
* - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group
*/
import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import { getContainerConfig } from './db/container-configs.js';
import { getAgentGroup } from './db/agent-groups.js';
import type { AgentGroup, ContainerConfigRow } from './types.js';
export interface McpServerConfig {
command: string;
args?: string[];
env?: Record<string, string>;
// Optional always-in-context guidance. When set, the host writes the
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
// into the composed CLAUDE.md.
instructions?: string;
}
@@ -30,101 +29,61 @@ export interface AdditionalMountConfig {
readonly?: boolean;
}
/** Shape of the materialized `container.json` file read by the container runner. */
export interface ContainerConfig {
mcpServers: Record<string, McpServerConfig>;
packages: { apt: string[]; npm: string[] };
imageTag?: string;
additionalMounts: AdditionalMountConfig[];
/** Which skills to enable — array of skill names or "all" (default). */
skills: string[] | 'all';
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
provider?: string;
/** Agent group display name (used in transcript archiving). */
groupName?: string;
/** Assistant display name (used in system prompt / responses). */
assistantName?: string;
/** Agent group ID — set by the host, read by the runner. */
agentGroupId?: string;
/** Max messages per prompt. Falls back to code default if unset. */
maxMessagesPerPrompt?: number;
model?: string;
effort?: string;
}
function emptyConfig(): ContainerConfig {
/** Build a `ContainerConfig` from a DB row + agent group identity. */
export function configFromDb(row: ContainerConfigRow, group: AgentGroup): ContainerConfig {
return {
mcpServers: {},
packages: { apt: [], npm: [] },
additionalMounts: [],
skills: 'all',
mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
packages: {
apt: JSON.parse(row.packages_apt) as string[],
npm: JSON.parse(row.packages_npm) as string[],
},
imageTag: row.image_tag ?? undefined,
additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[],
skills: JSON.parse(row.skills) as string[] | 'all',
provider: row.provider ?? undefined,
groupName: group.name,
assistantName: row.assistant_name ?? group.name,
agentGroupId: group.id,
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
model: row.model ?? undefined,
effort: row.effort ?? undefined,
};
}
function configPath(folder: string): string {
return path.join(GROUPS_DIR, folder, 'container.json');
}
/**
* Read the container config for a group, returning sensible defaults for
* any missing fields (or an entirely empty config if the file is absent).
* Never throws for missing / malformed files — corruption logs a warning
* via console.error and falls back to empty.
* Materialize `container.json` from the DB. Called at spawn time so the
* container always sees fresh config. Returns the `ContainerConfig` for
* use by the caller (buildMounts, buildContainerArgs, etc.).
*/
export function readContainerConfig(folder: string): ContainerConfig {
const p = configPath(folder);
if (!fs.existsSync(p)) return emptyConfig();
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
return {
mcpServers: raw.mcpServers ?? {},
packages: {
apt: raw.packages?.apt ?? [],
npm: raw.packages?.npm ?? [],
},
imageTag: raw.imageTag,
additionalMounts: raw.additionalMounts ?? [],
skills: raw.skills ?? 'all',
provider: raw.provider,
groupName: raw.groupName,
assistantName: raw.assistantName,
agentGroupId: raw.agentGroupId,
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
};
} catch (err) {
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
return emptyConfig();
}
}
export function materializeContainerJson(agentGroupId: string): ContainerConfig {
const group = getAgentGroup(agentGroupId);
if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
/**
* Write the container config for a group, creating the groups/<folder>/
* directory if necessary. Pretty-printed JSON so diffs in the activation
* flow are reviewable.
*/
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
const p = configPath(folder);
const row = getContainerConfig(agentGroupId);
if (!row) throw new Error(`Container config not found for agent group: ${agentGroupId}`);
const config = configFromDb(row, group);
const p = path.join(GROUPS_DIR, group.folder, 'container.json');
const dir = path.dirname(p);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
}
/**
* Apply a mutator function to a group's container config and persist the
* result. Convenient for append-style changes like `install_packages` and
* `add_mcp_server` handlers.
*/
export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig {
const config = readContainerConfig(folder);
mutate(config);
writeContainerConfig(folder, config);
return config;
}
/**
* Initialize an empty container.json for a group if one doesn't already
* exist. Idempotent — used from `group-init.ts`.
*/
export function initContainerConfig(folder: string): boolean {
const p = configPath(folder);
if (fs.existsSync(p)) return false;
writeContainerConfig(folder, emptyConfig());
return true;
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// --- Mocks ---
vi.mock('./log.js', () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
const mockIsContainerRunning = vi.fn<(id: string) => boolean>();
const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>();
const mockWakeContainer = vi.fn();
vi.mock('./container-runner.js', () => ({
isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string),
killContainer: (...args: unknown[]) =>
mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
wakeContainer: (...args: unknown[]) => mockWakeContainer(...args),
}));
const mockGetSessionsByAgentGroup = vi.fn();
const mockGetSession = vi.fn();
vi.mock('./db/sessions.js', () => ({
getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args),
getSession: (...args: unknown[]) => mockGetSession(...args),
}));
const mockWriteSessionMessage = vi.fn();
vi.mock('./session-manager.js', () => ({
writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args),
}));
import { restartAgentGroupContainers } from './container-restart.js';
beforeEach(() => {
vi.clearAllMocks();
});
// --- Helpers ---
function makeSession(id: string, agentGroupId: string, status = 'active') {
return { id, agent_group_id: agentGroupId, status };
}
// --- Tests ---
describe('restartAgentGroupContainers', () => {
it('skips sessions without a running container', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockReturnValue(false);
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(0);
expect(mockKillContainer).not.toHaveBeenCalled();
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
});
it('skips non-active sessions', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]);
mockIsContainerRunning.mockReturnValue(true);
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(0);
expect(mockKillContainer).not.toHaveBeenCalled();
});
it('kills running containers and returns count', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockImplementation((id) => id === 's1');
const count = restartAgentGroupContainers('g1', 'test');
expect(count).toBe(1);
expect(mockKillContainer).toHaveBeenCalledTimes(1);
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
});
it('does not write wake message when wakeMessage is omitted', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
restartAgentGroupContainers('g1', 'test');
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
});
it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
// Should write an on-wake message
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1);
const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0];
expect(agentGroupId).toBe('g1');
expect(sessionId).toBe('s1');
expect(msg.onWake).toBe(1);
expect(JSON.parse(msg.content).text).toBe('Resuming.');
// Should pass an onExit callback to killContainer
expect(mockKillContainer).toHaveBeenCalledTimes(1);
const onExit = mockKillContainer.mock.calls[0][2];
expect(typeof onExit).toBe('function');
});
it('onExit callback calls wakeContainer with refreshed session', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
const freshSession = makeSession('s1', 'g1');
mockGetSession.mockReturnValue(freshSession);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
// Simulate container exit by calling the onExit callback
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
onExit();
expect(mockGetSession).toHaveBeenCalledWith('s1');
expect(mockWakeContainer).toHaveBeenCalledWith(freshSession);
});
it('onExit callback does not wake if session no longer exists', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
mockGetSession.mockReturnValue(undefined);
restartAgentGroupContainers('g1', 'test', 'Resuming.');
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
onExit();
expect(mockWakeContainer).not.toHaveBeenCalled();
});
it('handles multiple running sessions with wake message', () => {
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
mockIsContainerRunning.mockReturnValue(true);
const count = restartAgentGroupContainers('g1', 'test', 'Config updated.');
expect(count).toBe(2);
expect(mockKillContainer).toHaveBeenCalledTimes(2);
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2);
// Each session gets its own on-wake message
expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1');
expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2');
});
});

59
src/container-restart.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* Helper to restart all running containers for an agent group.
*
* Writes an on_wake message to each session, kills the container, then
* wakes a fresh container via the onExit callback — race-free.
*/
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
import { getSession, getSessionsByAgentGroup } from './db/sessions.js';
import { log } from './log.js';
import { writeSessionMessage } from './session-manager.js';
/**
* Kill all running containers for an agent group and respawn them.
*
* Only targets sessions that actually have a running container.
* If `wakeMessage` is provided, each session gets an on_wake message
* (picked up only by the fresh container's first poll) and a
* wakeContainer call on exit. Without it, containers are killed and
* only come back on the next real user message.
*/
export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number {
const sessions = getSessionsByAgentGroup(agentGroupId).filter(
(s) => s.status === 'active' && isContainerRunning(s.id),
);
for (const session of sessions) {
if (wakeMessage) {
writeSessionMessage(agentGroupId, session.id, {
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
kind: 'chat',
timestamp: new Date().toISOString(),
platformId: agentGroupId,
channelType: 'agent',
threadId: null,
content: JSON.stringify({
text: wakeMessage,
sender: 'system',
senderId: 'system',
}),
onWake: 1,
});
}
killContainer(
session.id,
reason,
wakeMessage
? () => {
const s = getSession(session.id);
if (s) wakeContainer(s);
}
: undefined,
);
}
if (sessions.length > 0) {
log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length });
}
return sessions.length;
}

View File

@@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest';
import { resolveProviderName } from './container-runner.js';
describe('resolveProviderName', () => {
it('prefers session over group and container.json', () => {
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
it('prefers session over container config', () => {
expect(resolveProviderName('codex', 'claude')).toBe('codex');
});
it('falls back to group when session is null', () => {
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
});
it('falls back to container.json when session and group are null', () => {
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
it('falls back to container config when session is null', () => {
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
});
it('defaults to claude when nothing is set', () => {
expect(resolveProviderName(null, null, undefined)).toBe('claude');
expect(resolveProviderName(null, undefined)).toBe('claude');
});
it('lowercases the resolved name', () => {
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
expect(resolveProviderName('CODEX', null)).toBe('codex');
expect(resolveProviderName(null, 'Claude')).toBe('claude');
});
it('treats empty string as unset (falls through)', () => {
expect(resolveProviderName('', 'codex', null)).toBe('codex');
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
expect(resolveProviderName('', 'opencode')).toBe('opencode');
expect(resolveProviderName(null, '')).toBe('claude');
});
});

View File

@@ -19,7 +19,9 @@ import {
ONECLI_URL,
TIMEZONE,
} from './config.js';
import { readContainerConfig, writeContainerConfig } from './container-config.js';
import { materializeContainerJson } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { updateContainerConfigScalars, updateContainerConfigJson } from './db/container-configs.js';
import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
import { composeGroupClaudeMd } from './claude-md-compose.js';
import { getAgentGroup } from './db/agent-groups.js';
@@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise<void> {
}
writeSessionRouting(agentGroup.id, session.id);
// Read container config once — threaded through provider resolution,
// buildMounts, and buildContainerArgs so we don't re-read the file.
const containerConfig = readContainerConfig(agentGroup.folder);
// Ensure container.json has the agent group identity fields the runner needs.
// Written at spawn time so the runner can read them from the RO mount.
ensureRuntimeFields(containerConfig, agentGroup);
// Materialize container.json from DB — writes fresh file and returns
// the config object, threaded through provider resolution, buildMounts,
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
@@ -191,10 +190,14 @@ async function spawnContainer(session: Session): Promise<void> {
}
/** Kill a container for a session. */
export function killContainer(sessionId: string, reason: string): void {
export function killContainer(sessionId: string, reason: string, onExit?: () => void): void {
const entry = activeContainers.get(sessionId);
if (!entry) return;
if (onExit) {
entry.process.once('close', onExit);
}
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
try {
stopContainer(entry.containerName);
@@ -204,22 +207,19 @@ export function killContainer(sessionId: string, reason: string): void {
}
/**
* Resolve the provider name for a session using the precedence documented in
* the provider-install skills:
* Resolve the provider name for a session:
*
* sessions.agent_provider
* → agent_groups.agent_provider
* → container.json `provider`
* → container_configs.provider
* → 'claude'
*
* Pure so the precedence can be unit-tested without a DB or filesystem.
*/
export function resolveProviderName(
sessionProvider: string | null | undefined,
agentGroupProvider: string | null | undefined,
containerConfigProvider: string | null | undefined,
): string {
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
}
function resolveProviderContribution(
@@ -227,7 +227,7 @@ function resolveProviderContribution(
agentGroup: AgentGroup,
containerConfig: import('./container-config.js').ContainerConfig,
): { provider: string; contribution: ProviderContainerContribution } {
const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider);
const provider = resolveProviderName(session.agent_provider, containerConfig.provider);
const fn = getProviderContainerConfig(provider);
const contribution = fn
? fn({
@@ -396,34 +396,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Ensure container.json has the runtime identity fields the runner needs.
* Written at spawn time so they're always current even if the DB values
* change (e.g. group rename). Only writes if values differ to avoid
* unnecessary file churn.
*/
function ensureRuntimeFields(
containerConfig: import('./container-config.js').ContainerConfig,
agentGroup: AgentGroup,
): void {
let dirty = false;
if (containerConfig.agentGroupId !== agentGroup.id) {
containerConfig.agentGroupId = agentGroup.id;
dirty = true;
}
if (containerConfig.groupName !== agentGroup.name) {
containerConfig.groupName = agentGroup.name;
dirty = true;
}
if (containerConfig.assistantName !== agentGroup.name) {
containerConfig.assistantName = agentGroup.name;
dirty = true;
}
if (dirty) {
writeContainerConfig(agentGroup.folder, containerConfig);
}
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
@@ -497,10 +469,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
const agentGroup = getAgentGroup(agentGroupId);
if (!agentGroup) throw new Error('Agent group not found');
const containerConfig = readContainerConfig(agentGroup.folder);
const aptPackages = containerConfig.packages.apt;
const npmPackages = containerConfig.packages.npm;
const configRow = getContainerConfig(agentGroup.id);
if (!configRow) throw new Error('Container config not found');
const aptPackages = JSON.parse(configRow.packages_apt) as string[];
const npmPackages = JSON.parse(configRow.packages_npm) as string[];
if (aptPackages.length === 0 && npmPackages.length === 0) {
throw new Error('No packages to install. Use install_packages first.');
}
@@ -530,15 +502,14 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
cwd: DATA_DIR,
stdio: 'pipe',
timeout: 300_000,
timeout: 900_000,
});
} finally {
fs.unlinkSync(tmpDockerfile);
}
// Store the image tag in groups/<folder>/container.json
containerConfig.imageTag = imageTag;
writeContainerConfig(agentGroup.folder, containerConfig);
// Store the image tag in the DB
updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag });
log.info('Per-agent-group image built', { agentGroupId, imageTag });
}

View File

@@ -0,0 +1,97 @@
import type { ContainerConfigRow } from '../types.js';
import { getDb } from './connection.js';
const SCALAR_COLUMNS = new Set([
'provider',
'model',
'effort',
'image_tag',
'assistant_name',
'max_messages_per_prompt',
'cli_scope',
]);
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
| ContainerConfigRow
| undefined;
}
export function getAllContainerConfigs(): ContainerConfigRow[] {
return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[];
}
/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */
export function createContainerConfig(config: ContainerConfigRow): void {
getDb()
.prepare(
`INSERT INTO container_configs (
agent_group_id, provider, model, effort, image_tag, assistant_name,
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
additional_mounts, updated_at
) VALUES (
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
@additional_mounts, @updated_at
)`,
)
.run(config);
}
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
export function ensureContainerConfig(agentGroupId: string): void {
getDb()
.prepare(
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
VALUES (?, ?)`,
)
.run(agentGroupId, new Date().toISOString());
}
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
export function updateContainerConfigScalars(
agentGroupId: string,
updates: Partial<
Pick<
ContainerConfigRow,
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
>
>,
): void {
const fields: string[] = [];
const values: Record<string, unknown> = { agent_group_id: agentGroupId };
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined) {
if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`);
fields.push(`${key} = @${key}`);
values[key] = value;
}
}
if (fields.length === 0) return;
fields.push('updated_at = @updated_at');
values.updated_at = new Date().toISOString();
getDb()
.prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`)
.run(values);
}
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
export function updateContainerConfigJson(
agentGroupId: string,
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
value: unknown,
): void {
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
const now = new Date().toISOString();
getDb()
.prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`)
.run(JSON.stringify(value), now, agentGroupId);
}
export function deleteContainerConfig(agentGroupId: string): void {
getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId);
}

View File

@@ -42,3 +42,12 @@ export {
deletePendingApproval,
getPendingApprovalsByAction,
} from './sessions.js';
export {
getContainerConfig,
getAllContainerConfigs,
createContainerConfig,
ensureContainerConfig,
updateContainerConfigScalars,
updateContainerConfigJson,
deleteContainerConfig,
} from './container-configs.js';

View File

@@ -0,0 +1,26 @@
import type Database from 'better-sqlite3';
import type { Migration } from './index.js';
export const migration014: Migration = {
version: 14,
name: 'container-configs',
up(db: Database.Database) {
db.exec(`
CREATE TABLE container_configs (
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
provider TEXT,
model TEXT,
effort TEXT,
image_tag TEXT,
assistant_name TEXT,
max_messages_per_prompt INTEGER,
skills TEXT NOT NULL DEFAULT '"all"',
mcp_servers TEXT NOT NULL DEFAULT '{}',
packages_apt TEXT NOT NULL DEFAULT '[]',
packages_npm TEXT NOT NULL DEFAULT '[]',
additional_mounts TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL
);
`);
},
};

Some files were not shown because too many files have changed in this diff Show More