diff --git a/.claude/skills/add-deltachat/SKILL.md b/.claude/skills/add-deltachat/SKILL.md index 45aa416..3dd5df6 100644 --- a/.claude/skills/add-deltachat/SKILL.md +++ b/.claude/skills/add-deltachat/SKILL.md @@ -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`. diff --git a/.claude/skills/add-deltachat/VERIFY.md b/.claude/skills/add-deltachat/VERIFY.md index 839fa85..ae25c58 100644 --- a/.claude/skills/add-deltachat/VERIFY.md +++ b/.claude/skills/add-deltachat/VERIFY.md @@ -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. diff --git a/.claude/skills/add-emacs/SKILL.md b/.claude/skills/add-emacs/SKILL.md index 82a5098..4a24eca 100644 --- a/.claude/skills/add-emacs/SKILL.md +++ b/.claude/skills/add-emacs/SKILL.md @@ -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';" ``` diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md index 095c285..03df0e2 100644 --- a/.claude/skills/add-gmail-tool/SKILL.md +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -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 --secret-ids +GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")') +CURRENT=$(onecli agents secrets --id | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id --secret-ids "$MERGED" +onecli agents secrets --id ``` ## Phase 2: Apply Code Changes diff --git a/.claude/skills/add-karpathy-llm-wiki/SKILL.md b/.claude/skills/add-karpathy-llm-wiki/SKILL.md index 12b9b37..79bfed9 100644 --- a/.claude/skills/add-karpathy-llm-wiki/SKILL.md +++ b/.claude/skills/add-karpathy-llm-wiki/SKILL.md @@ -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('', { 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', - '', - '', - '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', - '', - '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 ``` diff --git a/.claude/skills/add-mnemon/SKILL.md b/.claude/skills/add-mnemon/SKILL.md new file mode 100644 index 0000000..db0d029 --- /dev/null +++ b/.claude/skills/add-mnemon/SKILL.md @@ -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 `` 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 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 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 mnemon setup --target claude-code --yes --global +``` diff --git a/.claude/skills/add-ollama-provider/SKILL.md b/.claude/skills/add-ollama-provider/SKILL.md index 83f7e5a..fe42249 100644 --- a/.claude/skills/add-ollama-provider/SKILL.md +++ b/.claude/skills/add-ollama-provider/SKILL.md @@ -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='';") +AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='';") SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json ``` diff --git a/.claude/skills/add-opencode/SKILL.md b/.claude/skills/add-opencode/SKILL.md index 555f0fe..841baaa 100644 --- a/.claude/skills/add-opencode/SKILL.md +++ b/.claude/skills/add-opencode/SKILL.md @@ -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 --secret-ids , -``` +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=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` #### Example: DeepSeek diff --git a/.claude/skills/add-parallel/SKILL.md b/.claude/skills/add-parallel/SKILL.md index a9dff8f..c391f53 100644 --- a/.claude/skills/add-parallel/SKILL.md +++ b/.claude/skills/add-parallel/SKILL.md @@ -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 diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 7dcc8ad..6b63a5c 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -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 diff --git a/.claude/skills/add-vercel/SKILL.md b/.claude/skills/add-vercel/SKILL.md index dbd9780..be3b201 100644 --- a/.claude/skills/add-vercel/SKILL.md +++ b/.claude/skills/add-vercel/SKILL.md @@ -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 ``` diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 232725f..edec479 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -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 `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@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 `@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `@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 diff --git a/.claude/skills/debug/SKILL.md b/.claude/skills/debug/SKILL.md index 128b8c3..e624162 100644 --- a/.claude/skills/debug/SKILL.md +++ b/.claude/skills/debug/SKILL.md @@ -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-.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-.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: diff --git a/.claude/skills/init-first-agent/SKILL.md b/.claude/skills/init-first-agent/SKILL.md index 6b110d3..67ab80b 100644 --- a/.claude/skills/init-first-agent/SKILL.md +++ b/.claude/skills/init-first-agent/SKILL.md @@ -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//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` with the values from the script's output. +- `pnpm exec tsx scripts/q.ts data/v2-sessions//sessions//outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `` and `` 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//sessions/*/outbound.db` — confirm the session exists. diff --git a/.claude/skills/init-onecli/SKILL.md b/.claude/skills/init-onecli/SKILL.md index b3d441f..ab64b73 100644 --- a/.claude/skills/init-onecli/SKILL.md +++ b/.claude/skills/init-onecli/SKILL.md @@ -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=="") | .id') +CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")') +MERGED=$(printf '%s' "$CURRENT," | tr ',' '\n' | sort -u | paste -sd ',' -) +onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED" +onecli agents secrets --id "$AGENT_ID" +``` + +- `` — the `agentGroupId` field in `groups//container.json` +- `` — 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//.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. diff --git a/.claude/skills/manage-channels/SKILL.md b/.claude/skills/manage-channels/SKILL.md index 0b348d1..21b3e19 100644 --- a/.claude/skills/manage-channels/SKILL.md +++ b/.claude/skills/manage-channels/SKILL.md @@ -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 "" +``` ```sql SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups; diff --git a/.husky/pre-commit b/.husky/pre-commit index 799cd8f..379c43c 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec9fc3..b571baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --effort `. 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//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:` (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 diff --git a/CLAUDE.md b/CLAUDE.md index c17001b..1cf7e6f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ""`. 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//` | 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 [] [--flags] +ncl 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 (`:`) | +| 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-` skill is idempotent: `git fetch origin ` → 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//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 [--rebuild] [--message ]`. 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 | |-------|-------------| diff --git a/bin/ncl b/bin/ncl new file mode 100755 index 0000000..27cc09a --- /dev/null +++ b/bin/ncl @@ -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 "$@" diff --git a/container/Dockerfile b/container/Dockerfile index efa58b6..2622d06 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -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 diff --git a/container/agent-runner/bun.lock b/container/agent-runner/bun.lock index 3c08828..ee57204 100644 --- a/container/agent-runner/bun.lock +++ b/container/agent-runner/bun.lock @@ -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=="], diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json index e9af0b1..dcd4e45 100644 --- a/container/agent-runner/package.json +++ b/container/agent-runner/package.json @@ -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" diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts new file mode 100644 index 0000000..c835368 --- /dev/null +++ b/container/agent-runner/src/cli/ncl.ts @@ -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; +}; + +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; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + 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 [--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).every((v) => typeof v !== 'object' || v === null), + ); + + if (!isFlat) return JSON.stringify(data, null, 2) + '\n'; + + const keys = Object.keys(data[0] as Record); + const widths = keys.map((k) => + Math.max(k.length, ...data.map((r) => String((r as Record)[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)[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); +} diff --git a/container/agent-runner/src/compact-instructions.ts b/container/agent-runner/src/compact-instructions.ts new file mode 100644 index 0000000..b682061 --- /dev/null +++ b/container/agent-runner/src/compact-instructions.ts @@ -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:', + ' - for chat messages', + ' - for scheduled tasks', + ' - 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 ... blocks.', + ` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`, +]; + +console.log(instructions.join('\n')); diff --git a/container/agent-runner/src/config.ts b/container/agent-runner/src/config.ts index 3a022ab..1546011 100644 --- a/container/agent-runner/src/config.ts +++ b/container/agent-runner/src/config.ts @@ -16,6 +16,8 @@ export interface RunnerConfig { agentGroupId: string; maxMessagesPerPrompt: number; mcpServers: Record }>; + 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; diff --git a/container/agent-runner/src/current-batch.ts b/container/agent-runner/src/current-batch.ts new file mode 100644 index 0000000..b699c13 --- /dev/null +++ b/container/agent-runner/src/current-batch.ts @@ -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; +} + diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 871e43a..51a82d7 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -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, diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 88906ed..d3a1a33 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -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 []; diff --git a/container/agent-runner/src/destinations.test.ts b/container/agent-runner/src/destinations.test.ts new file mode 100644 index 0000000..14243f2 --- /dev/null +++ b/container/agent-runner/src/destinations.test.ts @@ -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(''); + 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(''); + expect(prompt).toContain('Default routing'); + expect(prompt).toContain('`casa`'); + }); +}); diff --git a/container/agent-runner/src/destinations.ts b/container/agent-runner/src/destinations.ts index 013bd3b..6a29390 100644 --- a/container/agent-runner/src/destinations.ts +++ b/container/agent-runner/src/destinations.ts @@ -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 `...`.', - '', - '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 `...` block.'); + lines.push('**Every response must be wrapped** in a `...` block.'); lines.push('You can include multiple `` blocks in one response to send to multiple destinations.'); lines.push('Text outside of `` blocks is scratchpad — logged but not sent anywhere.'); lines.push('Use `...` 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 `` 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.', ); diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 348d5ab..236dbfb 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -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 `${replyPrefix}${escapeXml(text)}${attachmentsSuffix}`; } +/** + * 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 `${parts.join('\n')}`; } 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 `${JSON.stringify(content.payload || content, null, 2)}`; } 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 `${JSON.stringify(content.result || null)}`; } /** diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 90c690f..d579592 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -91,6 +91,8 @@ async function main(): Promise { mcpServers, env: { ...process.env }, additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined, + model: config.model, + effort: config.effort, }); await runPollLoop({ diff --git a/container/agent-runner/src/integration.test.ts b/container/agent-runner/src/integration.test.ts index 3447c38..7396cfe 100644 --- a/container/agent-runner/src/integration.test.ts +++ b/container/agent-runner/src/integration.test.ts @@ -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({}, () => + 'reply-dreply-s', + ); + 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 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( + {}, + () => 'droppeddelivered', + ); + 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 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( + {}, + () => 'for discordfor slack', + ); + 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({}, () => 'hello slack'); + 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({}, () => 'reply'); + 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({}, () => 'Processed'); 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( + {}, + () => 'thinking about this...answerdone thinking', + ); + 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({}, () => 'done'); + 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 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(''); + + 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((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: 'ack' }; + while (!ended && !aborted) { + await new Promise((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 { return Promise.race([ @@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise { 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({}, () => 'should not run'); + 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'); + })(), + }; + } +} diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md new file mode 100644 index 0000000..b6f3173 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -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 [--flags] +ncl 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 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. diff --git a/container/agent-runner/src/mcp-tools/core.test.ts b/container/agent-runner/src/mcp-tools/core.test.ts new file mode 100644 index 0000000..4cef950 --- /dev/null +++ b/container/agent-runner/src/mcp-tools/core.test.ts @@ -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(); + }); +}); diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts index bf89ef8..48f87d5 100644 --- a/container/agent-runner/src/mcp-tools/core.ts +++ b/container/agent-runner/src/mcp-tools/core.ts @@ -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, diff --git a/container/agent-runner/src/mcp-tools/self-mod.instructions.md b/container/agent-runner/src/mcp-tools/self-mod.instructions.md index 15057e0..b2a4b3a 100644 --- a/container/agent-runner/src/mcp-tools/self-mod.instructions.md +++ b/container/agent-runner/src/mcp-tools/self-mod.instructions.md @@ -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). diff --git a/container/agent-runner/src/poll-loop.test.ts b/container/agent-runner/src/poll-loop.test.ts index 356108f..29b769b 100644 --- a/container/agent-runner/src/poll-loop.test.ts +++ b/container/agent-runner/src/poll-loop.test.ts @@ -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(' { 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(' { 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(' { @@ -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(' { @@ -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(' { + insertMessage('t1', 'task', { prompt: 'check status' }); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + seedDestination('github-ch', 'github', 'repo-1'); + insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1'); + const prompt = formatMessages(getPendingMessages()); + expect(prompt).toContain(' { + 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(' { it('should produce init + result events', async () => { const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`); diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index e825184..bbf45be 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -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 { 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 { // 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 { 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 `` 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 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 ... blocks * and dispatch each one to its resolved destination. Text outside of blocks - * (including ...) is normally scratchpad — logged but - * not sent. + * (including ...) is scratchpad — logged but not sent. * - * Single-destination shortcut: if the agent has exactly one configured - * destination AND the output contains zero blocks, the entire - * cleaned text (with 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 ... + * blocks, even with a single destination. Bare text is scratchpad only. */ function dispatchResultText(text: string, routing: RoutingContext): void { const MESSAGE_RE = /([\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 { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 6c30cc2..d8e78dd 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider { private mcpServers: Record; private env: Record; 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' }; diff --git a/container/agent-runner/src/providers/types.ts b/container/agent-runner/src/providers/types.ts index 55ab919..c679dbe 100644 --- a/container/agent-runner/src/providers/types.ts +++ b/container/agent-runner/src/providers/types.ts @@ -25,6 +25,16 @@ export interface ProviderOptions { mcpServers?: Record; env?: Record; 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 `` 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 }; diff --git a/container/skills/onecli-gateway/SKILL.md b/container/skills/onecli-gateway/SKILL.md new file mode 100644 index 0000000..2e77d45 --- /dev/null +++ b/container/skills/onecli-gateway/SKILL.md @@ -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. diff --git a/container/skills/onecli-gateway/instructions.md b/container/skills/onecli-gateway/instructions.md new file mode 100644 index 0000000..ad3fd95 --- /dev/null +++ b/container/skills/onecli-gateway/instructions.md @@ -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. diff --git a/docs/db-central.md b/docs/db-central.md index 8268acf..75c27f3 100644 --- a/docs/db-central.md +++ b/docs/db-central.md @@ -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//` 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//` 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//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. diff --git a/docs/db-session.md b/docs/db-session.md index 9370d90..2b9fd23 100644 --- a/docs/db-session.md +++ b/docs/db-session.md @@ -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); ``` diff --git a/migrate-v2.sh b/migrate-v2.sh index ef3bda8..46a6670 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -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) diff --git a/package.json b/package.json index 3f4794c..967717d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f74033..902b6ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': {} diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index e68caf4..a21355d 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 141k tokens, 71% of context window + + 174k tokens, 87% of context window @@ -15,8 +15,8 @@ tokens - - 141k + + 174k diff --git a/scripts/chat.ts b/scripts/chat.ts index 20194fb..e32fcee 100644 --- a/scripts/chat.ts +++ b/scripts/chat.ts @@ -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 @@ -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); } diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index 61a17d6..461e407 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -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 { 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, diff --git a/scripts/q.test.ts b/scripts/q.test.ts new file mode 100644 index 0000000..4901db5 --- /dev/null +++ b/scripts/q.test.ts @@ -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/); + }); +}); diff --git a/scripts/q.ts b/scripts/q.ts new file mode 100644 index 0000000..3d1ba74 --- /dev/null +++ b/scripts/q.ts @@ -0,0 +1,58 @@ +/** + * scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations. + * + * Usage: + * pnpm exec tsx scripts/q.ts "" + * + * 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 ""'); + process.exit(2); +} + +const db = new Database(dbPath); +try { + try { + const stmt = db.prepare(sql); + if (stmt.reader) { + const rows = stmt.all() as Record[]; + 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(); +} diff --git a/setup/auto.ts b/setup/auto.ts index 91ad83a..5428d03 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -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 { } 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 { } 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 { 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 { 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 { return choice; } -async function askOtherChannelName(): Promise { +async function askOtherChannelName(): Promise { + 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', }), ); diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 5730fca..c7c2b77 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise { "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', diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0918075..abe83a0 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -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 { + // 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 { [ "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', ); diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 3691beb..9375995 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise { +}): 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 { +}): 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 { +}): 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 { +}): 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 { +}): 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 | Promise; + reshow: () => Promise<'continue' | 'back'>; args: { collected: Collected; completed: string[] }; -}): Promise { +}): 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(); } } } diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index 187377e..8c0910d 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -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 = { +export const STEP_FILES: Record = { bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'], environment: ['setup/environment.ts'], container: [ @@ -81,7 +81,7 @@ const STEP_FILES: Record = { ], }; -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 { +export async function ensureClaudeReady(projectRoot: string): Promise { if (!isClaudeInstalled()) { const install = ensureAnswer( await p.confirm({ diff --git a/setup/lib/claude-handoff.ts b/setup/lib/claude-handoff.ts index 87023ef..892b397 100644 --- a/setup/lib/claude-handoff.ts +++ b/setup/lib/claude-handoff.ts @@ -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 { + 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 { + 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((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'); +} diff --git a/setup/lib/runner.ts b/setup/lib/runner.ts index 6ffffed..6adb02e 100644 --- a/setup/lib/runner.ts +++ b/setup/lib/runner.ts @@ -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 diff --git a/setup/lib/setup-config.ts b/setup/lib/setup-config.ts index 1fa6ad4..b8eb654 100644 --- a/setup/lib/setup-config.ts +++ b/setup/lib/setup-config.ts @@ -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 ─────────────────────────────────────────────────── diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 87c971e..f13dcd3 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -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.', diff --git a/setup/service.ts b/setup/service.ts index 777c0c5..a866a92 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise { }); 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( diff --git a/src/backfill-container-configs.ts b/src/backfill-container-configs.ts new file mode 100644 index 0000000..5551c90 --- /dev/null +++ b/src/backfill-container-configs.ts @@ -0,0 +1,78 @@ +/** + * One-time backfill: seed `container_configs` rows from existing + * `groups//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; + 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 }); + } +} diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index f403dfa..efeb32f 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -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 | 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(); diff --git a/src/claude-md-compose.ts b/src/claude-md-compose.ts index c0519e4..285f79a 100644 --- a/src/claude-md-compose.ts +++ b/src/claude-md-compose.ts @@ -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 = configRow + ? (JSON.parse(configRow.mcp_servers) as Record) + : {}; const desired = new Map(); // 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 `.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', diff --git a/src/cli/client.ts b/src/cli/client.ts new file mode 100644 index 0000000..93ed500 --- /dev/null +++ b/src/cli/client.ts @@ -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 [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 { + 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; + json: boolean; +} { + const positional: string[] = []; + const args: Record = {}; + 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 [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); +}); diff --git a/src/cli/commands/help.ts b/src/cli/commands/help.ts new file mode 100644 index 0000000..1cdb5c5 --- /dev/null +++ b/src/cli/commands/help.ts @@ -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 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 `-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 ? '' : ' '; + 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(); + 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 + } + } +} diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts new file mode 100644 index 0000000..5b05345 --- /dev/null +++ b/src/cli/commands/index.ts @@ -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(); diff --git a/src/cli/crud.ts b/src/cli/crud.ts new file mode 100644 index 0000000..9c7ed99 --- /dev/null +++ b/src/cli/crud.ts @@ -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 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, ctx: CallerContext) => Promise; +} + +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; +} + +// --------------------------------------------------------------------------- +// Resource registry (for help introspection) +// --------------------------------------------------------------------------- + +const resources = new Map(); + +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) => { + 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) => { + 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) => { + const values: Record = {}; + + 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) => { + const id = args.id as string; + if (!id) throw new Error(`${def.name} id is required`); + + const updates: Record = {}; + 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) => { + 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): Record { + const out: Record = {}; + 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, ctx), + }); + } + } +} diff --git a/src/cli/delivery-action.ts b/src/cli/delivery-action.ts new file mode 100644 index 0000000..5c693be --- /dev/null +++ b/src/cli/delivery-action.ts @@ -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) ?? {}; + + 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 }); +}); diff --git a/src/cli/dispatch.test.ts b/src/cli/dispatch.test.ts new file mode 100644 index 0000000..b63d712 --- /dev/null +++ b/src/cli/dispatch.test.ts @@ -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).belongs_to ?? 'g1', + }), +}); + +import { dispatch } from './dispatch.js'; +import type { CallerContext } from './frame.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --- Helpers --- + +function agentCtx(overrides?: Partial>): 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 }; + 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 }; + 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 }; + 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 }; + 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 }; + 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 + } + }); +}); diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts new file mode 100644 index 0000000..68db969 --- /dev/null +++ b/src/cli/dispatch.ts @@ -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 { + 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 = { + 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)[groupField] === ctx.agentGroupId, + ); + } else if (data && typeof data === 'object' && groupField in (data as Record)) { + if ((data as Record)[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); +} diff --git a/src/cli/format.ts b/src/cli/format.ts new file mode 100644 index 0000000..9b54599 --- /dev/null +++ b/src/cli/format.ts @@ -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[]); + } + return JSON.stringify(data, null, 2); +} + +function isFlatRecord(x: unknown): x is Record { + if (!x || typeof x !== 'object') return false; + for (const v of Object.values(x as Record)) { + if (v !== null && typeof v === 'object') return false; + } + return true; +} + +function renderTable(rows: Record[]): 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'); +} diff --git a/src/cli/frame.ts b/src/cli/frame.ts new file mode 100644 index 0000000..67cd61c --- /dev/null +++ b/src/cli/frame.ts @@ -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; +}; + +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; + }; diff --git a/src/cli/registry.ts b/src/cli/registry.ts new file mode 100644 index 0000000..a60e74a --- /dev/null +++ b/src/cli/registry.ts @@ -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 = { + 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) => TArgs; + handler: (args: TArgs, ctx: CallerContext) => Promise; +}; + +const registry = new Map(); + +export function register(def: CommandDef): 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)); +} diff --git a/src/cli/resources/approvals.ts b/src/cli/resources/approvals.ts new file mode 100644 index 0000000..c67f4bc --- /dev/null +++ b/src/cli/resources/approvals.ts @@ -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' }, +}); diff --git a/src/cli/resources/destinations.ts b/src/cli/resources/destinations.ts new file mode 100644 index 0000000..4a56029 --- /dev/null +++ b/src/cli/resources/destinations.ts @@ -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. ). 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 } }; + }, + }, + }, +}); diff --git a/src/cli/resources/dropped-messages.ts b/src/cli/resources/dropped-messages.ts new file mode 100644 index 0000000..88bdf26 --- /dev/null +++ b/src/cli/resources/dropped-messages.ts @@ -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' }, +}); diff --git a/src/cli/resources/groups.ts b/src/cli/resources/groups.ts new file mode 100644 index 0000000..83344e0 --- /dev/null +++ b/src/cli/resources/groups.ts @@ -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 { + 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 [--rebuild] [--message ]. ' + + '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 .', + 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 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 --name --command [--args ] [--env ].', + 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; + 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) : {}, + }; + 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 --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; + 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 and --apt or --npm .', + 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 or --npm '); + + 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 and --apt or --npm .', + 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 or --npm '); + + 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.', + }; + }, + }, + }, +}); diff --git a/src/cli/resources/index.ts b/src/cli/resources/index.ts new file mode 100644 index 0000000..816b32f --- /dev/null +++ b/src/cli/resources/index.ts @@ -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'; diff --git a/src/cli/resources/members.ts b/src/cli/resources/members.ts new file mode 100644 index 0000000..ac529be --- /dev/null +++ b/src/cli/resources/members.ts @@ -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 } }; + }, + }, + }, +}); diff --git a/src/cli/resources/messaging-groups.ts b/src/cli/resources/messaging-groups.ts new file mode 100644 index 0000000..0cda1c8 --- /dev/null +++ b/src/cli/resources/messaging-groups.ts @@ -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- (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' }, +}); diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts new file mode 100644 index 0000000..9d51815 --- /dev/null +++ b/src/cli/resources/roles.ts @@ -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 } }; + }, + }, + }, +}); diff --git a/src/cli/resources/sessions.ts b/src/cli/resources/sessions.ts new file mode 100644 index 0000000..f60fccc --- /dev/null +++ b/src/cli/resources/sessions.ts @@ -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' }, +}); diff --git a/src/cli/resources/user-dms.ts b/src/cli/resources/user-dms.ts new file mode 100644 index 0000000..50b2763 --- /dev/null +++ b/src/cli/resources/user-dms.ts @@ -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' }, +}); diff --git a/src/cli/resources/users.ts b/src/cli/resources/users.ts new file mode 100644 index 0000000..0c4fd56 --- /dev/null +++ b/src/cli/resources/users.ts @@ -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' }, +}); diff --git a/src/cli/resources/wirings.ts b/src/cli/resources/wirings.ts new file mode 100644 index 0000000..d52f8b1 --- /dev/null +++ b/src/cli/resources/wirings.ts @@ -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' }, +}); diff --git a/src/cli/socket-client.ts b/src/cli/socket-client.ts new file mode 100644 index 0000000..4c80c5d --- /dev/null +++ b/src/cli/socket-client.ts @@ -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 { + 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')); + } + }); + }); + } +} diff --git a/src/cli/socket-server.ts b/src/cli/socket-server.ts new file mode 100644 index 0000000..9027848 --- /dev/null +++ b/src/cli/socket-server.ts @@ -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 { + // 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((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 { + if (!server) return; + const s = server; + server = null; + await new Promise((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 { + 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; + return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null; +} diff --git a/src/cli/transport.ts b/src/cli/transport.ts new file mode 100644 index 0000000..14285ec --- /dev/null +++ b/src/cli/transport.ts @@ -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; +} diff --git a/src/container-config.ts b/src/container-config.ts index d972842..597ca92 100644 --- a/src/container-config.ts +++ b/src/container-config.ts @@ -1,26 +1,25 @@ /** - * Per-group container config, stored as a plain JSON file at - * `groups//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//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; - // Optional always-in-context guidance. When set, the host writes the - // content to `.claude-fragments/mcp-.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; 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, + 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; - 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// - * 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; -} diff --git a/src/container-restart.test.ts b/src/container-restart.test.ts new file mode 100644 index 0000000..d07d17f --- /dev/null +++ b/src/container-restart.test.ts @@ -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'); + }); +}); diff --git a/src/container-restart.ts b/src/container-restart.ts new file mode 100644 index 0000000..e09d6f3 --- /dev/null +++ b/src/container-restart.ts @@ -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; +} diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts index cd18a72..3c188f9 100644 --- a/src/container-runner.test.ts +++ b/src/container-runner.test.ts @@ -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'); }); }); diff --git a/src/container-runner.ts b/src/container-runner.ts index 27b0f5c..7201bfc 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -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 { } 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 { } /** 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 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 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//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 }); } diff --git a/src/db/container-configs.ts b/src/db/container-configs.ts new file mode 100644 index 0000000..219c73f --- /dev/null +++ b/src/db/container-configs.ts @@ -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 = { 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); +} diff --git a/src/db/index.ts b/src/db/index.ts index 0e4285a..57a1013 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -42,3 +42,12 @@ export { deletePendingApproval, getPendingApprovalsByAction, } from './sessions.js'; +export { + getContainerConfig, + getAllContainerConfigs, + createContainerConfig, + ensureContainerConfig, + updateContainerConfigScalars, + updateContainerConfigJson, + deleteContainerConfig, +} from './container-configs.js'; diff --git a/src/db/migrations/014-container-configs.ts b/src/db/migrations/014-container-configs.ts new file mode 100644 index 0000000..b9e3968 --- /dev/null +++ b/src/db/migrations/014-container-configs.ts @@ -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 + ); + `); + }, +}; diff --git a/src/db/migrations/015-cli-scope.ts b/src/db/migrations/015-cli-scope.ts new file mode 100644 index 0000000..6c0c7dd --- /dev/null +++ b/src/db/migrations/015-cli-scope.ts @@ -0,0 +1,10 @@ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration015: Migration = { + version: 15, + name: 'cli-scope', + up(db: Database.Database) { + db.prepare("ALTER TABLE container_configs ADD COLUMN cli_scope TEXT NOT NULL DEFAULT 'group'").run(); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index b46e678..0cefb37 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -10,6 +10,8 @@ import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; import { migration013 } from './013-approval-render-metadata.js'; +import { migration014 } from './014-container-configs.js'; +import { migration015 } from './015-cli-scope.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -31,6 +33,8 @@ const migrations: Migration[] = [ migration011, migration012, migration013, + migration014, + migration015, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/schema.ts b/src/db/schema.ts index 8433035..56701e6 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -7,8 +7,7 @@ export const SCHEMA = ` -- Agent workspaces: folder, skills, CLAUDE.md. -- All workspaces are equal; privilege lives on users, not groups. --- Container config (mcpServers, packages, imageTag, additionalMounts) lives --- in groups//container.json on disk, not in the DB. +-- Container config lives in the container_configs table (see migration 014). CREATE TABLE agent_groups ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -171,7 +170,16 @@ CREATE TABLE IF NOT EXISTS messages_in ( platform_id TEXT, channel_type TEXT, thread_id TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + -- For agent-to-agent inbound rows: the source session that emitted the + -- triggering outbound. Used as a return path when the target replies — + -- the reply routes back to this exact session, not to the source agent + -- group's "newest" session. NULL on channel-side inbound and on a2a rows + -- written before this column existed. + source_session_id TEXT, + on_wake INTEGER NOT NULL DEFAULT 0 + -- 1 = only deliver on the container's first poll (fresh start). + -- Dying containers (past first poll) skip these rows. ); CREATE INDEX IF NOT EXISTS idx_messages_in_series ON messages_in(series_id); diff --git a/src/db/session-db.test.ts b/src/db/session-db.test.ts index 5307900..a202100 100644 --- a/src/db/session-db.test.ts +++ b/src/db/session-db.test.ts @@ -10,7 +10,7 @@ import fs from 'fs'; import path from 'path'; import { describe, it, expect, afterEach } from 'vitest'; -import { migrateMessagesInTable } from './session-db.js'; +import { getInboundSourceSessionId, migrateMessagesInTable } from './session-db.js'; const TEST_DIR = '/tmp/nanoclaw-session-db-test'; const DB_PATH = path.join(TEST_DIR, 'inbound.db'); @@ -55,4 +55,40 @@ describe('migrateMessagesInTable', () => { expect(row.series_id).toBe('legacy-1'); db.close(); }); + + it('adds source_session_id on a legacy DB, leaves existing rows NULL, is idempotent', () => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = new Database(DB_PATH); + db.exec(` + CREATE TABLE messages_in ( + id TEXT PRIMARY KEY, + seq INTEGER UNIQUE, + kind TEXT NOT NULL, + timestamp TEXT NOT NULL, + status TEXT DEFAULT 'pending', + process_after TEXT, + recurrence TEXT, + tries INTEGER DEFAULT 0, + platform_id TEXT, + channel_type TEXT, + thread_id TEXT, + content TEXT NOT NULL + ); + `); + db.prepare( + "INSERT INTO messages_in (id, seq, kind, timestamp, status, content) VALUES (?, ?, 'chat', datetime('now'), 'pending', '{}')", + ).run('legacy-2', 2); + + migrateMessagesInTable(db); + migrateMessagesInTable(db); // idempotent + + const cols = (db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name); + expect(cols).toContain('source_session_id'); + + expect(getInboundSourceSessionId(db, 'legacy-2')).toBeNull(); + expect(getInboundSourceSessionId(db, 'does-not-exist')).toBeNull(); + db.close(); + }); }); diff --git a/src/db/session-db.ts b/src/db/session-db.ts index addc39d..15ba0e4 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -108,14 +108,27 @@ export function insertMessage( * Host countDueMessages gates on this; container reads everything. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path for the target's reply. NULL on channel-side inbound. + */ + sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): void { db.prepare( - `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger) - VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger)`, + `INSERT INTO messages_in (id, seq, kind, timestamp, status, platform_id, channel_type, thread_id, content, process_after, recurrence, series_id, trigger, source_session_id, on_wake) + VALUES (@id, @seq, @kind, @timestamp, 'pending', @platformId, @channelType, @threadId, @content, @processAfter, @recurrence, @id, @trigger, @sourceSessionId, @onWake)`, ).run({ ...message, trigger: message.trigger ?? 1, + onWake: message.onWake ?? 0, + sourceSessionId: message.sourceSessionId ?? null, seq: nextEvenSeq(db), }); } @@ -239,6 +252,7 @@ export interface OutboundMessage { channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; } export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] { @@ -305,4 +319,52 @@ export function migrateMessagesInTable(db: Database.Database): void { // the agent" semantics, so backfill 1 and default 1 for new inserts. db.prepare('ALTER TABLE messages_in ADD COLUMN trigger INTEGER NOT NULL DEFAULT 1').run(); } + if (!cols.has('source_session_id')) { + // For agent-to-agent return-path routing. NULL on existing rows is fine — + // their replies fall back to the legacy "newest active session" lookup. + db.prepare('ALTER TABLE messages_in ADD COLUMN source_session_id TEXT').run(); + } + if (!cols.has('on_wake')) { + // 1 = only deliver on the container's first poll (fresh start). + // All existing rows are normal messages, so default 0. + db.prepare('ALTER TABLE messages_in ADD COLUMN on_wake INTEGER NOT NULL DEFAULT 0').run(); + } +} + +/** + * Look up an inbound row's source_session_id by its message id. Returns null + * if the row doesn't exist or the column is NULL (channel inbound or + * pre-migration a2a inbound). Used by a2a routing to route replies back to + * the originating session. + */ +export function getInboundSourceSessionId(db: Database.Database, messageId: string): string | null { + const row = db.prepare('SELECT source_session_id FROM messages_in WHERE id = ?').get(messageId) as + | { source_session_id: string | null } + | undefined; + return row?.source_session_id ?? null; +} + +/** + * Find the source_session_id of the most recent a2a inbound row from a + * specific peer (by agent group id). Used as a peer-affinity fallback in + * a2a routing when an outbound reply has no `in_reply_to` (e.g. the + * container's send_message MCP tool path didn't thread the batch's + * in_reply_to through). + * + * Heuristic: "the last time this peer talked to me, which session was it?" + * Returns null when no prior a2a inbound from that peer carries a + * non-null source_session_id (typical for pre-migration installs). + */ +export function getMostRecentPeerSourceSessionId(db: Database.Database, peerAgentGroupId: string): string | null { + const row = db + .prepare( + `SELECT source_session_id FROM messages_in + WHERE channel_type = 'agent' + AND platform_id = ? + AND source_session_id IS NOT NULL + ORDER BY seq DESC + LIMIT 1`, + ) + .get(peerAgentGroupId) as { source_session_id: string | null } | undefined; + return row?.source_session_id ?? null; } diff --git a/src/delivery.test.ts b/src/delivery.test.ts index a5e1efd..aadfde8 100644 --- a/src/delivery.test.ts +++ b/src/delivery.test.ts @@ -26,8 +26,16 @@ vi.mock('./config.js', async () => { const TEST_DIR = '/tmp/nanoclaw-test-delivery'; -import { initTestDb, closeDb, runMigrations, createAgentGroup, createMessagingGroup } from './db/index.js'; -import { resolveSession, outboundDbPath } from './session-manager.js'; +import { + initTestDb, + closeDb, + runMigrations, + createAgentGroup, + createMessagingGroup, + createMessagingGroupAgent, +} from './db/index.js'; +import { getDeliveredIds } from './db/session-db.js'; +import { resolveSession, outboundDbPath, openInboundDb } from './session-manager.js'; import { deliverSessionMessages, setDeliveryAdapter } from './delivery.js'; function now(): string { @@ -146,3 +154,120 @@ describe('deliverSessionMessages — concurrent invocations', () => { expect(callCount).toBe(1); }); }); + +describe('deliverSessionMessages — retry and permanent failure', () => { + it('retries on adapter failure and marks failed after MAX_DELIVERY_ATTEMPTS (3)', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-flaky'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + throw new Error('network timeout'); + }, + }); + + // Attempt 1 + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — should mark as permanently failed + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Attempt 4 — message is now in delivered (as failed), adapter not called + await deliverSessionMessages(session); + expect(callCount).toBe(3); + + // Verify the message is in the delivered table with 'failed' status + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-flaky')).toBe(true); + }); + + it('clears attempt counter on successful delivery', async () => { + seedAgentAndChannel(); + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + insertOutbound('ag-1', session.id, 'out-retry-ok'); + + let callCount = 0; + setDeliveryAdapter({ + async deliver() { + callCount++; + if (callCount === 1) throw new Error('transient'); + return 'plat-ok'; + }, + }); + + // Attempt 1 — fails + await deliverSessionMessages(session); + expect(callCount).toBe(1); + + // Attempt 2 — succeeds + await deliverSessionMessages(session); + expect(callCount).toBe(2); + + // Attempt 3 — not called, message already delivered + await deliverSessionMessages(session); + expect(callCount).toBe(2); + }); +}); + +describe('deliverSessionMessages — permission check', () => { + it('rejects delivery to an unauthorized channel destination', async () => { + seedAgentAndChannel(); + + // Create a second messaging group that the agent is NOT wired to + createMessagingGroup({ + id: 'mg-2', + channel_type: 'discord', + platform_id: 'discord:456', + name: 'Unauthorized Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + // Session is on mg-1 (telegram) + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + + // Insert an outbound message targeting mg-2 (discord) — not the origin chat + const outDb = new Database(outboundDbPath('ag-1', session.id)); + outDb + .prepare( + `INSERT INTO messages_out (id, timestamp, kind, platform_id, channel_type, content) + VALUES (?, datetime('now'), 'chat', 'discord:456', 'discord', ?)`, + ) + .run('out-unauth', JSON.stringify({ text: 'sneaky' })); + outDb.close(); + + const calls: string[] = []; + setDeliveryAdapter({ + async deliver(_ct, _pid, _tid, _kind, content) { + calls.push(content); + return 'plat-msg'; + }, + }); + + // Deliver 3 times to exhaust retries + await deliverSessionMessages(session); + await deliverSessionMessages(session); + await deliverSessionMessages(session); + + // Adapter never called — permission check throws before reaching it + expect(calls).toHaveLength(0); + + // Message is marked as permanently failed + const inDb = openInboundDb('ag-1', session.id); + const delivered = getDeliveredIds(inDb); + inDb.close(); + expect(delivered.has('out-unauth')).toBe(true); + }); +}); diff --git a/src/delivery.ts b/src/delivery.ts index 036153a..a47fec2 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -239,6 +239,7 @@ async function deliverMessage( channel_type: string | null; thread_id: string | null; content: string; + in_reply_to: string | null; }, session: Session, inDb: Database.Database, diff --git a/src/group-init.ts b/src/group-init.ts index 437d10f..e6d919b 100644 --- a/src/group-init.ts +++ b/src/group-init.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { DATA_DIR, GROUPS_DIR } from './config.js'; -import { initContainerConfig } from './container-config.js'; +import { ensureContainerConfig } from './db/container-configs.js'; import { log } from './log.js'; import type { AgentGroup } from './types.js'; @@ -14,6 +14,18 @@ const DEFAULT_SETTINGS_JSON = CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', }, + hooks: { + PreCompact: [ + { + hooks: [ + { + type: 'command', + command: 'bun /app/src/compact-instructions.ts', + }, + ], + }, + ], + }, }, null, 2, @@ -53,12 +65,10 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s initialized.push('CLAUDE.local.md'); } - // groups//container.json — empty container config, replaces the - // former agent_groups.container_config DB column. Self-modification flows - // read and write this file directly. - if (initContainerConfig(group.folder)) { - initialized.push('container.json'); - } + // Ensure container_configs row exists in the DB. Idempotent — no-op if + // the row already exists (e.g. created by backfill or group creation). + ensureContainerConfig(group.id); + initialized.push('container_configs'); // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); @@ -71,6 +81,8 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s if (!fs.existsSync(settingsFile)) { fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON); initialized.push('settings.json'); + } else { + ensurePreCompactHook(settingsFile, initialized); } // Skills directory — created empty here; symlinks are synced at spawn @@ -90,3 +102,32 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s }); } } + +const PRE_COMPACT_COMMAND = 'bun /app/src/compact-instructions.ts'; + +/** + * Patch an existing settings.json to add the PreCompact hook if missing. + * Runs on every group init so pre-existing groups pick up the hook. + */ +function ensurePreCompactHook(settingsFile: string, initialized: string[]): void { + try { + const raw = fs.readFileSync(settingsFile, 'utf-8'); + const settings = JSON.parse(raw); + + // Check if there's already a PreCompact hook with our command. + const existing = settings.hooks?.PreCompact as unknown[] | undefined; + if (existing && JSON.stringify(existing).includes(PRE_COMPACT_COMMAND)) return; + + // Add the hook, preserving existing hooks. + if (!settings.hooks) settings.hooks = {}; + if (!settings.hooks.PreCompact) settings.hooks.PreCompact = []; + settings.hooks.PreCompact.push({ + hooks: [{ type: 'command', command: PRE_COMPACT_COMMAND }], + }); + + fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + '\n'); + initialized.push('settings.json (added PreCompact hook)'); + } catch { + // Don't break init if settings.json is malformed — it'll use whatever's there. + } +} diff --git a/src/host-core.test.ts b/src/host-core.test.ts index 043b6b1..e6b9153 100644 --- a/src/host-core.test.ts +++ b/src/host-core.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { initTestDb, closeDb, + getDb, runMigrations, createAgentGroup, createMessagingGroup, @@ -19,6 +20,7 @@ import { import { resolveSession, writeSessionMessage, + writeSessionRouting, initSessionFolder, sessionDir, inboundDbPath, @@ -595,6 +597,400 @@ describe('router', () => { }); }); +describe('routing metadata preservation', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-1', + name: 'Test Agent', + folder: 'test-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-1', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-1', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + }); + + it('routed message carries platformId, channelType, threadId on the messages_in row', async () => { + const { routeInbound } = await import('./router.js'); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-42', + message: { id: 'msg-r1', kind: 'chat', content: JSON.stringify({ sender: 'A', text: 'hi' }), timestamp: now() }, + }); + + const session = findSession('mg-1', null); + const db = new Database(inboundDbPath('ag-1', session!.id)); + const row = db + .prepare('SELECT platform_id, channel_type, thread_id FROM messages_in WHERE id LIKE ?') + .get('msg-r1%') as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-42'); + }); + + it('fan-out gives each agent its own routing, not leaked from sibling', async () => { + const { routeInbound } = await import('./router.js'); + + createAgentGroup({ + id: 'ag-2', + name: 'Agent Two', + folder: 'agent-two', + agent_provider: null, + created_at: now(), + }); + createMessagingGroupAgent({ + id: 'mga-2', + messaging_group_id: 'mg-1', + agent_group_id: 'ag-2', + engage_mode: 'pattern', + engage_pattern: '.', + sender_scope: 'all', + ignored_message_policy: 'drop', + session_mode: 'shared', + priority: 0, + created_at: now(), + }); + + await routeInbound({ + channelType: 'discord', + platformId: 'chan-123', + threadId: 'thread-fanout', + message: { id: 'msg-fo', kind: 'chat', content: JSON.stringify({ text: 'fan' }), timestamp: now() }, + }); + + // Both agents should have the message with correct routing + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + for (const agId of ['ag-1', 'ag-2']) { + const sessions = getSessionsByAgentGroup(agId); + expect(sessions).toHaveLength(1); + const db = new Database(inboundDbPath(agId, sessions[0].id)); + const row = db.prepare('SELECT platform_id, channel_type, thread_id FROM messages_in LIMIT 1').get() as { + platform_id: string | null; + channel_type: string | null; + thread_id: string | null; + }; + db.close(); + expect(row.platform_id).toBe('chan-123'); + expect(row.channel_type).toBe('discord'); + expect(row.thread_id).toBe('thread-fanout'); + } + }); +}); + +describe('writeSessionRouting', () => { + it('populates session_routing from the messaging group', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'telegram', + platform_id: 'tg:12345', + name: 'Chat', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', null, 'shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('telegram'); + expect(row!.platform_id).toBe('tg:12345'); + expect(row!.thread_id).toBeNull(); + }); + + it('writes null routing for agent-shared session (no messaging group)', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBeNull(); + expect(row!.platform_id).toBeNull(); + expect(row!.thread_id).toBeNull(); + }); + + it('includes thread_id from per-thread session', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-1', + channel_type: 'discord', + platform_id: 'chan-123', + name: 'General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + + const { session } = resolveSession('ag-1', 'mg-1', 'thread-77', 'per-thread'); + writeSessionRouting('ag-1', session.id); + + const db = new Database(inboundDbPath('ag-1', session.id)); + const row = db.prepare('SELECT channel_type, platform_id, thread_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + thread_id: string | null; + } + | undefined; + db.close(); + + expect(row).toBeDefined(); + expect(row!.channel_type).toBe('discord'); + expect(row!.platform_id).toBe('chan-123'); + expect(row!.thread_id).toBe('thread-77'); + }); +}); + +describe('agent-shared session resolution', () => { + it('resolves to the same session on repeated calls', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session: s1, created: c1 } = resolveSession('ag-1', null, null, 'agent-shared'); + const { session: s2, created: c2 } = resolveSession('ag-1', null, null, 'agent-shared'); + + expect(c1).toBe(true); + expect(c2).toBe(false); + expect(s1.id).toBe(s2.id); + }); + + it('agent-shared session has null messaging_group_id', () => { + createAgentGroup({ + id: 'ag-1', + name: 'Agent', + folder: 'agent', + agent_provider: null, + created_at: now(), + }); + + const { session } = resolveSession('ag-1', null, null, 'agent-shared'); + expect(session.messaging_group_id).toBeNull(); + }); +}); + +describe('agent-to-agent routing', () => { + beforeEach(() => { + createAgentGroup({ + id: 'ag-pa', + name: 'PA', + folder: 'pa-agent', + agent_provider: null, + created_at: now(), + }); + createMessagingGroup({ + id: 'mg-slack', + channel_type: 'slack', + platform_id: 'C-GENERAL', + name: 'Slack General', + is_group: 1, + unknown_sender_policy: 'public', + created_at: now(), + }); + createAgentGroup({ + id: 'ag-researcher', + name: 'Researcher', + folder: 'researcher-agent', + agent_provider: null, + created_at: now(), + }); + + // Wire bidirectional A2A destinations (table created by runMigrations) + const db = getDb(); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-pa', 'researcher', 'agent', 'ag-researcher', ?)`, + ).run(now()); + db.prepare( + `INSERT OR IGNORE INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) + VALUES ('ag-researcher', 'pa', 'agent', 'ag-pa', ?)`, + ).run(now()); + }); + + it('A2A outbound lands in a session for the target agent', async () => { + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + await routeAgentMessage( + { + id: 'out-a2a-1', + platform_id: 'ag-researcher', + content: JSON.stringify({ text: 'research this' }), + in_reply_to: null, + }, + paSlackSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions.length).toBeGreaterThanOrEqual(1); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const rows = rDb.prepare('SELECT platform_id, channel_type, content FROM messages_in').all() as Array<{ + platform_id: string | null; + channel_type: string | null; + content: string; + }>; + rDb.close(); + + expect(rows).toHaveLength(1); + expect(rows[0].channel_type).toBe('agent'); + expect(rows[0].platform_id).toBe('ag-pa'); + expect(JSON.parse(rows[0].content).text).toBe('research this'); + }); + + it('A2A return path routes to originating session, not newest (#2332)', async () => { + // PA has Slack session, then gets wired to Discord (newer session). + // Researcher responds to PA. With the return-path fix, the reply + // routes back to the Slack session (originator) not Discord (newest). + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSlackSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + + createMessagingGroup({ + id: 'mg-discord', + channel_type: 'discord', + platform_id: 'chan-discord', + name: 'Discord', + is_group: 0, + unknown_sender_policy: 'public', + created_at: now(), + }); + const { session: paDiscordSession } = resolveSession('ag-pa', 'mg-discord', null, 'shared'); + + // PA sends from Slack + await routeAgentMessage( + { id: 'out-fwd', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'research' }), in_reply_to: null }, + paSlackSession, + ); + + // Researcher responds back to PA + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSession = getSessionsByAgentGroup('ag-researcher')[0]; + + await routeAgentMessage( + { id: 'out-reply', platform_id: 'ag-pa', content: JSON.stringify({ text: 'found it' }), in_reply_to: null }, + researcherSession, + ); + + const slackDb = new Database(inboundDbPath('ag-pa', paSlackSession.id)); + const slackA2a = slackDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + slackDb.close(); + + const discordDb = new Database(inboundDbPath('ag-pa', paDiscordSession.id)); + const discordA2a = discordDb.prepare("SELECT * FROM messages_in WHERE channel_type = 'agent'").all(); + discordDb.close(); + + // Fixed: response lands in Slack (origin) not Discord (newest) + expect(slackA2a).toHaveLength(1); + expect(discordA2a).toHaveLength(0); + }); + + it('BUG: A2A-only session gets null session_routing (#2332)', async () => { + // Researcher only has an agent-shared session (no channel wiring). + // writeSessionRouting writes nulls because messaging_group_id is null. + const { routeAgentMessage } = await import('./modules/agent-to-agent/agent-route.js'); + + const { session: paSession } = resolveSession('ag-pa', 'mg-slack', null, 'shared'); + await routeAgentMessage( + { id: 'out-1', platform_id: 'ag-researcher', content: JSON.stringify({ text: 'go' }), in_reply_to: null }, + paSession, + ); + + const { getSessionsByAgentGroup } = await import('./db/sessions.js'); + const researcherSessions = getSessionsByAgentGroup('ag-researcher'); + expect(researcherSessions).toHaveLength(1); + + writeSessionRouting('ag-researcher', researcherSessions[0].id); + + const rDb = new Database(inboundDbPath('ag-researcher', researcherSessions[0].id)); + const routing = rDb.prepare('SELECT channel_type, platform_id FROM session_routing WHERE id = 1').get() as + | { + channel_type: string | null; + platform_id: string | null; + } + | undefined; + rDb.close(); + + // BUG: session_routing is all null — researcher has no default routing + expect(routing).toBeDefined(); + expect(routing!.channel_type).toBeNull(); + expect(routing!.platform_id).toBeNull(); + }); +}); + describe('delivery', () => { it('should detect undelivered messages in outbound DB', () => { createAgentGroup({ diff --git a/src/index.ts b/src/index.ts index 9ded3d6..6af9b01 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import { backfillContainerConfigs } from './backfill-container-configs.js'; import { DATA_DIR } from './config.js'; import { enforceStartupBackoff, resetCircuitBreaker } from './circuit-breaker.js'; import { migrateGroupsToClaudeLocal } from './claude-md-compose.js'; @@ -53,6 +54,12 @@ import './channels/index.js'; // append registry-based modules. Imported for side effects (registrations). import './modules/index.js'; +// CLI command barrel — populates the `ncl` registry before the CLI server +// accepts connections. +import './cli/commands/index.js'; +import './cli/delivery-action.js'; +import { startCliServer, stopCliServer } from './cli/socket-server.js'; + import type { ChannelAdapter, ChannelSetup } from './channels/adapter.js'; import { initChannelAdapters, teardownChannelAdapters, getChannelAdapter } from './channels/channel-registry.js'; @@ -68,7 +75,11 @@ async function main(): Promise { runMigrations(db); log.info('Central DB ready', { path: dbPath }); - // 1b. One-time filesystem cutover — idempotent, no-op after first run. + // 1b. Backfill container_configs from legacy container.json files. + // Idempotent — skips groups that already have a config row. + backfillContainerConfigs(); + + // 1c. One-time filesystem cutover — idempotent, no-op after first run. migrateGroupsToClaudeLocal(); // 2. Container runtime @@ -163,6 +174,9 @@ async function main(): Promise { startHostSweep(); log.info('Host sweep started'); + // 7. Start the `ncl` CLI socket server (data/ncl.sock). + await startCliServer(); + log.info('NanoClaw running'); } @@ -178,6 +192,7 @@ async function shutdown(signal: string): Promise { } stopDeliveryPolls(); stopHostSweep(); + await stopCliServer(); try { await teardownChannelAdapters(); } finally { diff --git a/src/modules/agent-to-agent/agent-route.test.ts b/src/modules/agent-to-agent/agent-route.test.ts index 4d48f6f..b0bc66e 100644 --- a/src/modules/agent-to-agent/agent-route.test.ts +++ b/src/modules/agent-to-agent/agent-route.test.ts @@ -1,20 +1,54 @@ -import { describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; -import { isSafeAttachmentName } from './agent-route.js'; +import { isSafeAttachmentName, routeAgentMessage } from './agent-route.js'; +import { createDestination } from './db/agent-destinations.js'; +import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js'; +import { createSession, updateSession } from '../../db/sessions.js'; +import { initSessionFolder, inboundDbPath, sessionDir, writeSessionMessage } from '../../session-manager.js'; +import type { Session } from '../../types.js'; + +vi.mock('../../container-runner.js', () => ({ + wakeContainer: vi.fn().mockResolvedValue(undefined), + isContainerRunning: vi.fn().mockReturnValue(false), + getActiveContainerCount: vi.fn().mockReturnValue(0), + killContainer: vi.fn(), +})); + +vi.mock('../../config.js', async () => { + const actual = await vi.importActual('../../config.js'); + return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-a2a-route' }; +}); + +const TEST_DIR = '/tmp/nanoclaw-test-a2a-route'; + +function now(): string { + return new Date().toISOString(); +} + +function readInbound(agentGroupId: string, sessionId: string) { + const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true }); + const rows = db + .prepare('SELECT id, platform_id, channel_type, content, source_session_id FROM messages_in ORDER BY seq') + .all() as Array<{ + id: string; + platform_id: string | null; + channel_type: string | null; + content: string; + source_session_id: string | null; + }>; + db.close(); + return rows; +} -/** - * `forwardAttachedFiles` has a filesystem side that's awkward to unit-test - * without mocking DATA_DIR. The guarantee worth pinning is that the - * filename validator rejects everything that could escape the inbox dir — - * `forwardAttachedFiles` runs this guard before any I/O, so traversal is - * impossible as long as this matrix holds. - */ describe('isSafeAttachmentName', () => { it('accepts plain filenames', () => { expect(isSafeAttachmentName('baby-duck.png')).toBe(true); expect(isSafeAttachmentName('file with spaces.pdf')).toBe(true); expect(isSafeAttachmentName('report.v2.docx')).toBe(true); - expect(isSafeAttachmentName('.hidden')).toBe(true); // leading dot is fine, just not `.` / `..` + expect(isSafeAttachmentName('.hidden')).toBe(true); }); it('rejects empty / sentinel values', () => { @@ -44,3 +78,369 @@ describe('isSafeAttachmentName', () => { expect(isSafeAttachmentName(undefined as unknown as string)).toBe(false); }); }); + +/** + * Return-path routing: when an a2a reply targets an agent group with multiple + * sessions, it must land in the *originating* session — not the newest one. + * + * Setup: agent A has two active sessions S1 (older) + S2 (newer). + * Agent B is the peer A talks to. Bidirectional destinations wired. + */ +describe('routeAgentMessage return-path', () => { + const A = 'ag-A'; + const B = 'ag-B'; + let S1: Session; + let S2: Session; + let SB: Session; + + beforeEach(() => { + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + fs.mkdirSync(TEST_DIR, { recursive: true }); + + const db = initTestDb(); + runMigrations(db); + + createAgentGroup({ id: A, name: 'A', folder: 'a', agent_provider: null, created_at: now() }); + createAgentGroup({ id: B, name: 'B', folder: 'b', agent_provider: null, created_at: now() }); + + // S1 (older), S2 (newer) — both active sessions on A. + S1 = { + id: 'sess-A-old', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-01T00:00:00.000Z', + }; + S2 = { + id: 'sess-A-new', + agent_group_id: A, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-02-01T00:00:00.000Z', + }; + SB = { + id: 'sess-B', + agent_group_id: B, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-01-15T00:00:00.000Z', + }; + createSession(S1); + createSession(S2); + createSession(SB); + initSessionFolder(A, S1.id); + initSessionFolder(A, S2.id); + initSessionFolder(B, SB.id); + + createDestination({ + agent_group_id: A, + local_name: 'b', + target_type: 'agent', + target_id: B, + created_at: now(), + }); + createDestination({ + agent_group_id: B, + local_name: 'a', + target_type: 'agent', + target_id: A, + created_at: now(), + }); + }); + + afterEach(() => { + closeDb(); + if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true }); + }); + + it('forward direction: stamps source_session_id on the target inbound row', async () => { + // A.S1 emits an outbound a2a to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'hello B' }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + expect(bRows[0].platform_id).toBe(A); + expect(bRows[0].source_session_id).toBe(S1.id); // <- the return address + }); + + it('reply direction: routes back to the originating session, not the newest', async () => { + // A.S1 sends to B. + await routeAgentMessage( + { + id: 'msg-from-A-S1', + platform_id: B, + content: JSON.stringify({ text: 'ping' }), + in_reply_to: null, + }, + S1, + ); + + // Capture the synthetic id the host stamped on B's inbound — that's what + // B's container would reference as `in_reply_to` when replying. + const bRows = readInbound(B, SB.id); + const yId = bRows[0].id; + + // B replies to that message. + await routeAgentMessage( + { + id: 'msg-from-B', + platform_id: A, + content: JSON.stringify({ text: 'pong' }), + in_reply_to: yId, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + + // The reply lands in S1 (originator) even though S2 is newer. + expect(s1Rows).toHaveLength(1); + expect(s1Rows[0].platform_id).toBe(B); + expect(JSON.parse(s1Rows[0].content).text).toBe('pong'); + expect(s2Rows).toHaveLength(0); + }); + + it('fallback: a2a with no in_reply_to falls through to newest-session lookup', async () => { + // No prior conversation. B initiates an a2a to A out of the blue. + await routeAgentMessage( + { + id: 'msg-from-B-fresh', + platform_id: A, + content: JSON.stringify({ text: 'unsolicited' }), + in_reply_to: null, + }, + SB, + ); + + // Newest session wins (current heuristic, preserved). + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('peer-affinity fallback: with no in_reply_to, routes to most recent peer-source session', async () => { + // A.S1 sends to B (establishing affinity: B's last contact from A was via S1). + await routeAgentMessage( + { + id: 'msg-from-A-S1-pre', + platform_id: B, + content: JSON.stringify({ text: 'context-establishing' }), + in_reply_to: null, + }, + S1, + ); + + // B sends a follow-up but its container forgot to set in_reply_to (e.g. + // emitted via an MCP tool path that doesn't thread the batch's in_reply_to + // through). The host should still route this to S1 because S1 is the + // session most recently in conversation with B — not the chronologically + // newest session of A. + await routeAgentMessage( + { + id: 'msg-from-B-followup', + platform_id: A, + content: JSON.stringify({ text: 'standing by' }), + in_reply_to: null, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + // Affinity wins: reply to S1, not the newer S2. + expect(s1Rows).toHaveLength(1); + expect(JSON.parse(s1Rows[0].content).text).toBe('standing by'); + expect(s2Rows).toHaveLength(0); + }); + + it('stale origin fallback: closed origin session falls through to newest active', async () => { + // A.S1 sends to B, establishing source_session_id = S1.id on B's inbound. + await routeAgentMessage( + { id: 'msg-fwd', platform_id: B, content: JSON.stringify({ text: 'hello' }), in_reply_to: null }, + S1, + ); + const bRows = readInbound(B, SB.id); + const inboundId = bRows[0].id; + + // Close S1 — simulates session cleanup or channel disconnect. + updateSession(S1.id, { status: 'closed' }); + + // B replies. origin points to S1 (closed), should fall through to S2. + await routeAgentMessage( + { id: 'msg-reply-stale', platform_id: A, content: JSON.stringify({ text: 'reply' }), in_reply_to: inboundId }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('cross-agent-group guard: origin session belonging to wrong agent group is rejected', async () => { + // Third agent group C sends to B, stamping source_session_id = SC on B's inbound. + const C = 'ag-C'; + createAgentGroup({ id: C, name: 'C', folder: 'c', agent_provider: null, created_at: now() }); + const SC: Session = { + id: 'sess-C', + agent_group_id: C, + messaging_group_id: null, + thread_id: null, + agent_provider: null, + status: 'active', + container_status: 'stopped', + last_active: null, + created_at: '2026-03-01T00:00:00.000Z', + }; + createSession(SC); + initSessionFolder(C, SC.id); + createDestination({ agent_group_id: C, local_name: 'b', target_type: 'agent', target_id: B, created_at: now() }); + + await routeAgentMessage( + { id: 'msg-from-C', platform_id: B, content: JSON.stringify({ text: 'from C' }), in_reply_to: null }, + SC, + ); + const bRows = readInbound(B, SB.id); + const cInboundId = bRows.find((r) => r.platform_id === C)!.id; + + // B replies to A, but in_reply_to references the C-originated row. + // Guard rejects (SC belongs to C, not A) → falls through to newest of A. + await routeAgentMessage( + { + id: 'msg-reply-tamper', + platform_id: A, + content: JSON.stringify({ text: 'misdirected' }), + in_reply_to: cInboundId, + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('in_reply_to referencing a non-a2a row falls through to newest session', async () => { + // Write a channel message into B's inbound (no source_session_id). + writeSessionMessage(B, SB.id, { + id: 'channel-msg-1', + kind: 'chat', + timestamp: now(), + platformId: 'user-123', + channelType: 'slack', + threadId: null, + content: 'hello from slack', + }); + + // B replies to A with in_reply_to pointing to the channel message. + // source_session_id is null → peer-affinity finds nothing → newest of A. + await routeAgentMessage( + { + id: 'msg-reply-channel', + platform_id: A, + content: JSON.stringify({ text: 'response' }), + in_reply_to: 'channel-msg-1', + }, + SB, + ); + + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(s1Rows).toHaveLength(0); + expect(s2Rows).toHaveLength(1); + }); + + it('self-message is allowed without a destination row', async () => { + // A targets itself — no agent_destinations row exists for A→A. + await routeAgentMessage( + { id: 'self-msg', platform_id: A, content: JSON.stringify({ text: 'self-note' }), in_reply_to: null }, + S1, + ); + + // Lands in S2 (newest active session of A via resolveSession fallback). + const s2Rows = readInbound(A, S2.id); + expect(s2Rows).toHaveLength(1); + expect(JSON.parse(s2Rows[0].content).text).toBe('self-note'); + }); + + it('BUG: no volume cap on a2a routing — unbounded ping-pong is allowed (#2063)', async () => { + // Two agents can exchange unlimited messages with no rate limit or loop + // detection. This test documents the gap — it should FAIL once #2063 lands. + const errors: string[] = []; + for (let i = 0; i < 20; i++) { + try { + await routeAgentMessage( + { id: `ping-${i}`, platform_id: B, content: JSON.stringify({ text: `ping ${i}` }), in_reply_to: null }, + S1, + ); + await routeAgentMessage( + { id: `pong-${i}`, platform_id: A, content: JSON.stringify({ text: `pong ${i}` }), in_reply_to: null }, + SB, + ); + } catch (e) { + errors.push((e as Error).message); + break; + } + } + // BUG: all 40 messages go through — no cap, no throttle. + // Once loop prevention lands, this should throw or reject after a threshold. + const bRows = readInbound(B, SB.id); + const s1Rows = readInbound(A, S1.id); + const s2Rows = readInbound(A, S2.id); + expect(errors).toHaveLength(0); + expect(bRows).toHaveLength(20); + expect(s1Rows.length + s2Rows.length).toBe(20); + }); + + it('file forwarding: copies bytes from source outbox to target inbox', async () => { + // Place a file in S1's outbox for the message. + const outboxDir = path.join(sessionDir(A, S1.id), 'outbox', 'msg-with-file'); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.writeFileSync(path.join(outboxDir, 'report.pdf'), 'fake-pdf-bytes'); + + await routeAgentMessage( + { + id: 'msg-with-file', + platform_id: B, + content: JSON.stringify({ text: 'see attached', files: ['report.pdf'] }), + in_reply_to: null, + }, + S1, + ); + + const bRows = readInbound(B, SB.id); + expect(bRows).toHaveLength(1); + const parsed = JSON.parse(bRows[0].content); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0].name).toBe('report.pdf'); + expect(parsed.attachments[0].type).toBe('file'); + + // Verify actual file bytes were copied to the target inbox. + const targetPath = path.join(sessionDir(B, SB.id), parsed.attachments[0].localPath); + expect(fs.existsSync(targetPath)).toBe(true); + expect(fs.readFileSync(targetPath, 'utf-8')).toBe('fake-pdf-bytes'); + }); +}); diff --git a/src/modules/agent-to-agent/agent-route.ts b/src/modules/agent-to-agent/agent-route.ts index 613a1ed..58e1419 100644 --- a/src/modules/agent-to-agent/agent-route.ts +++ b/src/modules/agent-to-agent/agent-route.ts @@ -23,10 +23,11 @@ import path from 'path'; import { isSafeAttachmentName } from '../../attachment-safety.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getInboundSourceSessionId, getMostRecentPeerSourceSessionId } from '../../db/session-db.js'; import { getSession } from '../../db/sessions.js'; import { wakeContainer } from '../../container-runner.js'; import { log } from '../../log.js'; -import { resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; +import { openInboundDb, resolveSession, sessionDir, writeSessionMessage } from '../../session-manager.js'; import type { Session } from '../../types.js'; import { hasDestination } from './db/agent-destinations.js'; @@ -101,6 +102,61 @@ export interface RoutableAgentMessage { id: string; platform_id: string | null; content: string; + /** + * For replies, the id of the inbound message being replied to. The + * container's formatter sets this from the first inbound in the batch + * (`container/agent-runner/src/formatter.ts`). Used here to route the + * reply back to the originating session — see `resolveTargetSession`. + */ + in_reply_to: string | null; +} + +/** + * Pick which session of `targetAgentGroupId` should receive this a2a message. + * + * Three layers, highest-fidelity first: + * + * 1. **Direct return-path** (in_reply_to lookup): if the message is a reply + * (`in_reply_to` set), open the source agent's inbound DB and read the + * triggering row's `source_session_id`. That column was stamped when the + * original outbound was routed — it's the session that started the + * conversation, and replies should land there even when the target has + * multiple active sessions. + * + * 2. **Peer-affinity fallback**: if (1) misses (in_reply_to is null or the + * referenced row isn't an a2a inbound), look up the most recent a2a + * inbound *from the target agent group* in source's inbound and use its + * `source_session_id`. The intuition: the last time this peer talked to + * me, which target session was driving? Route the reply there, since + * that's the session most plausibly in active conversation. + * + * 3. **Newest active session**: legacy heuristic. Used when no prior a2a + * has been recorded with `source_session_id` (e.g. fresh installs, + * pre-migration data). + */ +function resolveTargetSession(msg: RoutableAgentMessage, sourceSession: Session, targetAgentGroupId: string): Session { + const srcDb = openInboundDb(sourceSession.agent_group_id, sourceSession.id); + let originSessionId: string | null = null; + try { + if (msg.in_reply_to) { + originSessionId = getInboundSourceSessionId(srcDb, msg.in_reply_to); + } + if (!originSessionId) { + // Peer-affinity fallback — covers the case where the container's + // outbound write didn't carry in_reply_to (e.g. legacy MCP send_message + // path, container running pre-fix code). + originSessionId = getMostRecentPeerSourceSessionId(srcDb, targetAgentGroupId); + } + } finally { + srcDb.close(); + } + if (originSessionId) { + const candidate = getSession(originSessionId); + if (candidate && candidate.agent_group_id === targetAgentGroupId && candidate.status === 'active') { + return candidate; + } + } + return resolveSession(targetAgentGroupId, null, null, 'agent-shared').session; } export async function routeAgentMessage(msg: RoutableAgentMessage, session: Session): Promise { @@ -119,7 +175,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess if (!getAgentGroup(targetAgentGroupId)) { throw new Error(`target agent group ${targetAgentGroupId} not found for message ${msg.id}`); } - const { session: targetSession } = resolveSession(targetAgentGroupId, null, null, 'agent-shared'); + const targetSession = resolveTargetSession(msg, session, targetAgentGroupId); const a2aMsgId = `a2a-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; // If the source message references files (via `send_file`), forward the @@ -137,6 +193,7 @@ export async function routeAgentMessage(msg: RoutableAgentMessage, session: Sess channelType: 'agent', threadId: null, content: forwardedContent, + sourceSessionId: session.id, }); log.info('Agent message routed', { from: session.agent_group_id, diff --git a/src/modules/self-mod/apply.ts b/src/modules/self-mod/apply.ts index 5291937..c5318ff 100644 --- a/src/modules/self-mod/apply.ts +++ b/src/modules/self-mod/apply.ts @@ -3,17 +3,18 @@ * * The approvals module calls these when an admin clicks Approve on a * pending_approvals row whose action matches. Each handler mutates the - * container config, rebuilds/kills the container as needed, and lets the - * host sweep respawn it on the new image on the next message. + * container config in the DB, rebuilds/kills the container as needed, + * and writes an on_wake message so the fresh container picks up where + * the old one left off. * - * install_packages: rebuild image + kill container (apt/npm global installs - * must be baked into the image layer). - * add_mcp_server: kill container only — bun runs TS directly, so a pure - * MCP wiring change needs nothing more than a process restart. + * install_packages: update DB + rebuild image + kill container + on_wake. + * add_mcp_server: update DB + kill container + on_wake. */ -import { updateContainerConfig } from '../../container-config.js'; -import { buildAgentGroupImage, killContainer } from '../../container-runner.js'; +import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { getAgentGroup } from '../../db/agent-groups.js'; +import { getContainerConfig, updateContainerConfigJson } from '../../db/container-configs.js'; +import { getSession } from '../../db/sessions.js'; +import type { McpServerConfig } from '../../container-config.js'; import { log } from '../../log.js'; import { writeSessionMessage } from '../../session-manager.js'; import type { ApprovalHandler } from '../approvals/index.js'; @@ -24,10 +25,28 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, notify('install_packages approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - if (payload.apt) cfg.packages.apt.push(...(payload.apt as string[])); - if (payload.npm) cfg.packages.npm.push(...(payload.npm as string[])); - }); + + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('install_packages approved but container config missing.'); + return; + } + + // Append new packages to existing lists in the DB (deduplicated) + if (payload.apt) { + const existing = JSON.parse(configRow.packages_apt) as string[]; + for (const pkg of payload.apt as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } + updateContainerConfigJson(agentGroup.id, 'packages_apt', existing); + } + if (payload.npm) { + const existing = JSON.parse(configRow.packages_npm) as string[]; + for (const pkg of payload.npm as string[]) { + if (!existing.includes(pkg)) existing.push(pkg); + } + updateContainerConfigJson(agentGroup.id, 'packages_npm', existing); + } const pkgs = [ ...((payload.apt as string[] | undefined) || []), @@ -36,9 +55,6 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, log.info('Package install approved', { agentGroupId: session.agent_group_id, userId }); try { await buildAgentGroupImage(session.agent_group_id); - killContainer(session.id, 'rebuild applied'); - // Schedule a follow-up prompt a few seconds after kill so the host sweep - // respawns the container on the new image and the agent verifies + reports. writeSessionMessage(session.agent_group_id, session.id, { id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', @@ -51,10 +67,11 @@ export const applyInstallPackages: ApprovalHandler = async ({ session, payload, sender: 'system', senderId: 'system', }), - processAfter: new Date(Date.now() + 5000) - .toISOString() - .replace('T', ' ') - .replace(/\.\d+Z$/, ''), + onWake: 1, + }); + killContainer(session.id, 'rebuild applied', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); }); log.info('Container rebuild completed (bundled with install)', { agentGroupId: session.agent_group_id }); } catch (e) { @@ -71,15 +88,39 @@ export const applyAddMcpServer: ApprovalHandler = async ({ session, payload, use notify('add_mcp_server approved but agent group missing.'); return; } - updateContainerConfig(agentGroup.folder, (cfg) => { - cfg.mcpServers[payload.name as string] = { - command: payload.command as string, - args: (payload.args as string[]) || [], - env: (payload.env as Record) || {}, - }; - }); - killContainer(session.id, 'mcp server added'); - notify(`MCP server "${payload.name}" added. Your container will restart with it on the next message.`); + const configRow = getContainerConfig(agentGroup.id); + if (!configRow) { + notify('add_mcp_server approved but container config missing.'); + return; + } + + // Add the new MCP server to the existing map in the DB + const servers = JSON.parse(configRow.mcp_servers) as Record; + servers[payload.name as string] = { + command: payload.command as string, + args: (payload.args as string[]) || [], + env: (payload.env as Record) || {}, + }; + updateContainerConfigJson(agentGroup.id, 'mcp_servers', servers); + + writeSessionMessage(session.agent_group_id, session.id, { + id: `appr-note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + kind: 'chat', + timestamp: new Date().toISOString(), + platformId: session.agent_group_id, + channelType: 'agent', + threadId: null, + content: JSON.stringify({ + text: `MCP server "${payload.name}" added. Verify it's available (e.g. list your tools) and report the result to the user.`, + sender: 'system', + senderId: 'system', + }), + onWake: 1, + }); + killContainer(session.id, 'mcp server added', () => { + const s = getSession(session.id); + if (s) wakeContainer(s); + }); log.info('MCP server add approved', { agentGroupId: session.agent_group_id, userId }); }; diff --git a/src/session-manager.ts b/src/session-manager.ts index e3f3f7a..38c77f2 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -210,6 +210,17 @@ export function writeSessionMessage( * a trigger-1 message does arrive. */ trigger?: 0 | 1; + /** + * For agent-to-agent inbound: the source session id that emitted the + * outbound message which became this inbound row. Used as the return + * path so the target's reply routes back to that exact session. + */ + sourceSessionId?: string | null; + /** + * 1 = only deliver on the container's first poll (fresh start). + * Dying containers (past first poll) skip these rows. + */ + onWake?: 0 | 1; }, ): void { // Extract base64 attachment data, save to inbox, replace with file paths @@ -228,6 +239,8 @@ export function writeSessionMessage( processAfter: message.processAfter ?? null, recurrence: message.recurrence ?? null, trigger: message.trigger ?? 1, + sourceSessionId: message.sourceSessionId ?? null, + onWake: message.onWake ?? 0, }); } finally { db.close(); diff --git a/src/types.ts b/src/types.ts index b3e2470..26a40f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,10 +4,30 @@ export interface AgentGroup { id: string; name: string; folder: string; + /** @deprecated Use container_configs.provider instead. */ agent_provider: string | null; created_at: string; } +/** Per-agent-group container runtime config. Source of truth in the DB; + * materialized to `groups//container.json` at spawn time. */ +export interface ContainerConfigRow { + agent_group_id: string; + provider: string | null; + model: string | null; + effort: string | null; + image_tag: string | null; + assistant_name: string | null; + max_messages_per_prompt: number | null; + skills: string; // JSON: '"all"' | '["skill1","skill2"]' + mcp_servers: string; // JSON: Record + packages_apt: string; // JSON: string[] + packages_npm: string; // JSON: string[] + additional_mounts: string; // JSON: AdditionalMountConfig[] + cli_scope: string; // 'disabled' | 'group' | 'global' + updated_at: string; +} + export type UnknownSenderPolicy = 'strict' | 'request_approval' | 'public'; export interface MessagingGroup { diff --git a/vitest.config.ts b/vitest.config.ts index d961d1b..71afb78 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,6 @@ export default defineConfig({ test: { // container/agent-runner tests run under Bun (they depend on bun:sqlite). // See container/agent-runner/package.json "test" script. - include: ['src/**/*.test.ts', 'setup/**/*.test.ts'], + include: ['src/**/*.test.ts', 'setup/**/*.test.ts', 'scripts/**/*.test.ts'], }, });