Merge branch 'nanocoai:main' into main
This commit is contained in:
@@ -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:
|
Once the first message arrives the router auto-creates a `messaging_groups` row. Look up the chat ID:
|
||||||
|
|
||||||
```bash
|
```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"
|
"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`
|
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`
|
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
|
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
|
### 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:
|
The messaging group exists but may not be wired to an agent group. Run:
|
||||||
|
|
||||||
```bash
|
```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`.
|
If the group has no entry in `messaging_group_agents`, wire it with `/manage-channels`.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ grep "DeltaChat" logs/nanoclaw.error.log | tail -10
|
|||||||
## 4. Check messaging group was created
|
## 4. Check messaging group was created
|
||||||
|
|
||||||
```bash
|
```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"
|
"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:
|
If the message arrived but the agent didn't respond, the sender may not have access:
|
||||||
|
|
||||||
```bash
|
```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.
|
Grant access as shown in the SKILL.md "Grant user access" section.
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
|||||||
### No response from agent
|
### No response from agent
|
||||||
|
|
||||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
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`
|
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.
|
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
|
# Remove the NanoClaw block from your Emacs config
|
||||||
# Optionally clean up the messaging group:
|
# 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';"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c
|
|||||||
onecli agents list
|
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
|
```bash
|
||||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||||
|
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||||
|
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||||
|
onecli agents secrets --id <agent-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 2: Apply Code Changes
|
## Phase 2: Apply Code Changes
|
||||||
|
|||||||
@@ -71,38 +71,11 @@ AskUserQuestion: "Want periodic wiki health checks?"
|
|||||||
2. **Monthly**
|
2. **Monthly**
|
||||||
3. **Skip** — lint manually
|
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
|
```bash
|
||||||
pnpm exec tsx -e "
|
|
||||||
const Database = require('better-sqlite3');
|
|
||||||
const { CronExpressionParser } = require('cron-parser');
|
|
||||||
const db = new Database('store/messages.db');
|
|
||||||
const interval = CronExpressionParser.parse('<cron-expr>', { tz: process.env.TZ || 'UTC' });
|
|
||||||
const nextRun = interval.next().toISOString();
|
|
||||||
db.prepare('INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
|
|
||||||
'wiki-lint',
|
|
||||||
'<group_folder>',
|
|
||||||
'<chat_jid>',
|
|
||||||
'Run a wiki lint pass per the wiki container skill. Check for contradictions, orphan pages, stale content, missing cross-references, and gaps. Report findings and offer to fix issues.',
|
|
||||||
'cron',
|
|
||||||
'<cron-expr>',
|
|
||||||
'group',
|
|
||||||
nextRun,
|
|
||||||
'active',
|
|
||||||
new Date().toISOString()
|
|
||||||
);
|
|
||||||
db.close();
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use the group's `folder` and `chat_jid` from the registered groups table. Cron expressions: `0 10 * * 0` (weekly Sunday 10am) or `0 10 1 * *` (monthly 1st at 10am).
|
|
||||||
|
|
||||||
## Step 6: Build and restart
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm run build
|
|
||||||
./container/build.sh
|
|
||||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||||
# Linux: systemctl --user restart nanoclaw
|
# Linux: systemctl --user restart nanoclaw
|
||||||
```
|
```
|
||||||
|
|||||||
208
.claude/skills/add-mnemon/SKILL.md
Normal file
208
.claude/skills/add-mnemon/SKILL.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
name: add-mnemon
|
||||||
|
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add Mnemon — Persistent Memory
|
||||||
|
|
||||||
|
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
|
||||||
|
|
||||||
|
## Provider Compatibility
|
||||||
|
|
||||||
|
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
|
||||||
|
|
||||||
|
Check your provider:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
|
||||||
|
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
|
||||||
|
|
||||||
|
## Phase 1: Pre-flight
|
||||||
|
|
||||||
|
### Check if already applied
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
|
||||||
|
```
|
||||||
|
|
||||||
|
If already applied, skip to Phase 3 (Verify).
|
||||||
|
|
||||||
|
### Check latest mnemon version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
|
||||||
|
|
||||||
|
## Phase 2: Apply Changes (Claude Code path)
|
||||||
|
|
||||||
|
### 1. Dockerfile — install mnemon binary
|
||||||
|
|
||||||
|
Add after the AWS CLI block, before the Bun runtime section:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# ---- mnemon — persistent agent memory ----------------------------------------
|
||||||
|
ARG MNEMON_VERSION=0.1.1
|
||||||
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
|
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin mnemon && \
|
||||||
|
chmod +x /usr/local/bin/mnemon
|
||||||
|
|
||||||
|
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||||
|
```
|
||||||
|
|
||||||
|
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
|
||||||
|
|
||||||
|
### 2. Entrypoint — run mnemon setup on each container start
|
||||||
|
|
||||||
|
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# NanoClaw agent container entrypoint.
|
||||||
|
#
|
||||||
|
# ...existing header comment...
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||||
|
|
||||||
|
cat > /tmp/input.json
|
||||||
|
|
||||||
|
exec bun run /app/src/index.ts < /tmp/input.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
|
||||||
|
|
||||||
|
### 3. Rebuild and smoke-test the image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./container/build.sh
|
||||||
|
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: Restart and Verify
|
||||||
|
|
||||||
|
### Restart the service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user restart nanoclaw # Linux
|
||||||
|
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Confirm mnemon hooks are registered
|
||||||
|
|
||||||
|
After the next container starts, check that setup ran:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Then inspect the hooks inside the running container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||||
|
cat /home/node/.claude/settings.json | grep -A5 mnemon
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test memory recall
|
||||||
|
|
||||||
|
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
|
||||||
|
|
||||||
|
## Phase 2 (OpenCode path) — context injection
|
||||||
|
|
||||||
|
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
|
||||||
|
|
||||||
|
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
|
||||||
|
|
||||||
|
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
|
||||||
|
|
||||||
|
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
ARG MNEMON_VERSION=0.1.1
|
||||||
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
|
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin mnemon && \
|
||||||
|
chmod +x /usr/local/bin/mnemon
|
||||||
|
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||||
|
```
|
||||||
|
|
||||||
|
Then rebuild: `./container/build.sh`
|
||||||
|
|
||||||
|
### Verify (OpenCode)
|
||||||
|
|
||||||
|
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Also confirm the binary is present in the image:
|
||||||
|
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory Storage
|
||||||
|
|
||||||
|
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||||
|
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
|
||||||
|
|
||||||
|
## Migration Guide Update
|
||||||
|
|
||||||
|
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
|
||||||
|
|
||||||
|
**Dockerfile — after AWS CLI, before Bun runtime:**
|
||||||
|
```dockerfile
|
||||||
|
ARG MNEMON_VERSION=0.1.1
|
||||||
|
RUN ARCH=$(dpkg --print-architecture) && \
|
||||||
|
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /usr/local/bin mnemon && \
|
||||||
|
chmod +x /usr/local/bin/mnemon
|
||||||
|
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||||
|
```
|
||||||
|
|
||||||
|
**`container/entrypoint.sh` — add after `set -e`:**
|
||||||
|
```bash
|
||||||
|
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `mnemon: command not found` in container
|
||||||
|
|
||||||
|
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
|
||||||
|
|
||||||
|
### Memory not persisting across restarts
|
||||||
|
|
||||||
|
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
|
||||||
|
```
|
||||||
|
|
||||||
|
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
|
||||||
|
|
||||||
|
### Agent not using past memory
|
||||||
|
|
||||||
|
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec <container> cat /home/node/.claude/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
|
||||||
|
|
||||||
|
### Setup fails at container start
|
||||||
|
|
||||||
|
Run setup manually inside a running container to see the full error:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it <container> mnemon setup --target claude-code --yes --global
|
||||||
|
```
|
||||||
@@ -76,7 +76,7 @@ Then rebuild the container image: `./container/build.sh`
|
|||||||
|
|
||||||
Ask the user (plain text, not AskUserQuestion):
|
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"'`
|
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.
|
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
|
```bash
|
||||||
# Find the agent group ID
|
# Find the agent group ID
|
||||||
AG_ID=$(sqlite3 data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
AG_ID=$(pnpm exec tsx scripts/q.ts data/v2.db "SELECT id FROM agent_groups WHERE folder='<FOLDER>';")
|
||||||
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
SETTINGS=data/v2-sessions/$AG_ID/.claude-shared/settings.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||||
|
|
||||||
```bash
|
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
|
||||||
# Find the agent id and secret id, then:
|
|
||||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
```bash
|
||||||
|
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||||
|
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||||
|
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||||
|
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||||
|
onecli agents secrets --id "$AGENT_ID"
|
||||||
|
```
|
||||||
|
|
||||||
#### Example: DeepSeek
|
#### Example: DeepSeek
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ Look for: `Parallel AI MCP servers configured`
|
|||||||
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
- Check agent-runner logs for "Parallel AI MCP servers configured" message
|
||||||
|
|
||||||
**Task polling not working:**
|
**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"`
|
- Check task runs: `tail -f logs/nanoclaw.log | grep "scheduled task"`
|
||||||
- Ensure task prompt includes proper Parallel MCP tool names
|
- Ensure task prompt includes proper Parallel MCP tool names
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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
|
```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"
|
"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
|
```bash
|
||||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
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
|
INSERT OR IGNORE INTO messaging_group_agents
|
||||||
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
(id, messaging_group_id, agent_group_id, session_mode, priority, created_at)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -226,7 +226,7 @@ New Signal users (including the owner's Signal identity) are silently dropped wi
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
NOW=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
|
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)
|
INSERT OR REPLACE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||||
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
VALUES ('signal:UUID', 'owner', NULL, 'system', '$NOW');
|
||||||
INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
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
|
### Bot not responding
|
||||||
|
|
||||||
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
|
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)
|
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
|
### Lost connection mid-session
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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
|
```bash
|
||||||
# For each agent, add the Vercel secret to its assigned secrets list.
|
# set-secrets replaces the entire list — read and merge for each agent.
|
||||||
# First get current assignments, then set them with the new secret appended.
|
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||||
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 | jq -r '.data[].id'); do
|
||||||
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
|
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
|
||||||
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
|
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||||
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
|
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ Otherwise, run `/manage-channels` to wire this channel to an agent group.
|
|||||||
|
|
||||||
- **type**: `whatsapp`
|
- **type**: `whatsapp`
|
||||||
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
- **terminology**: WhatsApp calls them "groups" and "chats." A "chat" is a 1:1 DM; a "group" has multiple members.
|
||||||
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `sqlite3 data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
- **how-to-find-id**: DMs use `<phone>@s.whatsapp.net` (e.g. `14155551234@s.whatsapp.net`). Groups use `<id>@g.us`. To find your number: `node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"`. Groups are auto-discovered — check `pnpm exec tsx scripts/q.ts data/v2.db "SELECT platform_id, name FROM messaging_groups WHERE channel_type='whatsapp' AND is_group=1"`.
|
||||||
- **supports-threads**: no
|
- **supports-threads**: no
|
||||||
- **typical-use**: Interactive chat — direct messages or small groups
|
- **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.
|
- **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`
|
1. Auth exists: `test -f store/auth/creds.json`
|
||||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
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`
|
4. Service running: `systemctl --user status nanoclaw`
|
||||||
|
|
||||||
### "conflict" disconnection
|
### "conflict" disconnection
|
||||||
|
|||||||
@@ -57,7 +57,50 @@ Debug level shows:
|
|||||||
|
|
||||||
## Common Issues
|
## Common Issues
|
||||||
|
|
||||||
### 1. "Claude Code process exited with code 1"
|
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
|
||||||
|
|
||||||
|
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
|
||||||
|
```
|
||||||
|
WARN No adapter for channel type channelType="telegram"
|
||||||
|
WARN No adapter for channel type channelType="signal"
|
||||||
|
```
|
||||||
|
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
|
||||||
|
|
||||||
|
**Root cause: two NanoClaw service instances running simultaneously.**
|
||||||
|
|
||||||
|
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
|
||||||
|
|
||||||
|
**Diagnosis:**
|
||||||
|
```bash
|
||||||
|
# Check for duplicate running instances
|
||||||
|
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
|
||||||
|
|
||||||
|
# Check which services are active
|
||||||
|
systemctl --user list-units 'nanoclaw*' --all
|
||||||
|
|
||||||
|
# Confirm channel adapters registered by the current process
|
||||||
|
grep "Channel adapter started" logs/nanoclaw.log | tail -10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix:**
|
||||||
|
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
|
||||||
|
2. Stop and disable the stale duplicate service:
|
||||||
|
```bash
|
||||||
|
systemctl --user stop nanoclaw.service # or whichever is the old one
|
||||||
|
systemctl --user disable nanoclaw.service
|
||||||
|
```
|
||||||
|
3. If the remaining service unit is missing `EnvironmentFile`, add it:
|
||||||
|
```bash
|
||||||
|
# Edit the service unit — add this line under [Service]:
|
||||||
|
# EnvironmentFile=/home/[user]/nanoclaw/.env
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user restart nanoclaw-v2-<id>.service
|
||||||
|
```
|
||||||
|
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
|
||||||
|
|
||||||
|
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
|
||||||
|
|
||||||
|
### 2. "Claude Code process exited with code 1"
|
||||||
|
|
||||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
**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/
|
rm -rf data/sessions/{groupFolder}/.claude/
|
||||||
|
|
||||||
# Also clear the session ID from NanoClaw's tracking (stored in SQLite)
|
# 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:
|
To verify session resumption is working, check the logs for the same session ID across messages:
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ Tell the user:
|
|||||||
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
Wait for the user's confirmation. Then look up the most recent DM messaging groups:
|
||||||
|
|
||||||
```bash
|
```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.
|
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):
|
If they say it didn't arrive, then diagnose using the DB directly (no waiting loops required — the message either delivered or it didn't):
|
||||||
|
|
||||||
- `sqlite3 data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
- `pnpm exec tsx scripts/q.ts data/v2-sessions/<agent-group-id>/sessions/<session-id>/outbound.db "SELECT id, status, created_at FROM messages_out ORDER BY created_at DESC LIMIT 5"` — check for stuck `pending` rows. Replace `<agent-group-id>` and `<session-id>` with the values from the script's output.
|
||||||
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
- `grep -E 'Unauthorized channel destination|container.*exited|error' logs/nanoclaw.log | tail -20` — look for ACL rejections or container crashes.
|
||||||
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
- `ls data/v2-sessions/<agent-group-id>/sessions/*/outbound.db` — confirm the session exists.
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,41 @@ Tell the user:
|
|||||||
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||||
- To add rate limits or policies: `onecli rules create --help`
|
- To add rate limits or policies: `onecli rules create --help`
|
||||||
|
|
||||||
|
## Granting secrets to agents (safe merge)
|
||||||
|
|
||||||
|
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||||
|
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||||
|
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||||
|
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||||
|
onecli agents secrets --id "$AGENT_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||||
|
- `<new-secret-id>` — the `id` from `onecli secrets list`
|
||||||
|
- Multiple new secrets: append them comma-separated before the `printf` step
|
||||||
|
|
||||||
|
### git over HTTPS
|
||||||
|
|
||||||
|
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
|
||||||
|
|
||||||
|
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
|
||||||
|
|
||||||
|
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
|
||||||
|
"GIT_TERMINAL_PROMPT": "0",
|
||||||
|
"GIT_CONFIG_COUNT": "1",
|
||||||
|
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||||
|
"GIT_CONFIG_VALUE_0": "",
|
||||||
|
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
|
||||||
|
|
||||||
## Troubleshooting
|
## 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.
|
**"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.
|
||||||
|
|||||||
@@ -11,7 +11,13 @@ Privilege is a **user-level** concept, not a channel-level one (see `src/db/user
|
|||||||
|
|
||||||
## Assess Current State
|
## Assess Current State
|
||||||
|
|
||||||
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`):
|
Read the central DB (`data/v2.db`) using these canonical queries (column names match the schema, not the CLI flags — the `register` command's `--assistant-name` is stored in `agent_groups.name`).
|
||||||
|
|
||||||
|
Run each via the in-tree wrapper — the host setup deliberately ships no `sqlite3` CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec tsx scripts/q.ts data/v2.db "<query>"
|
||||||
|
```
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
SELECT id, name AS assistant_name, folder, agent_provider FROM agent_groups;
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
|
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
|
||||||
pnpm run format:fix
|
pnpm run format:fix
|
||||||
|
if [ -n "$staged" ]; then
|
||||||
|
echo "$staged" | xargs git add
|
||||||
|
fi
|
||||||
|
|||||||
16
CHANGELOG.md
16
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).
|
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||||
|
|
||||||
## [Unreleased]
|
## [2.0.54] - 2026-05-10
|
||||||
|
|
||||||
|
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
|
||||||
|
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
|
||||||
|
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
|
||||||
|
|
||||||
|
## [2.0.48] - 2026-05-09
|
||||||
|
|
||||||
|
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
|
||||||
|
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
|
||||||
|
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
|
||||||
|
|
||||||
|
## [2.0.45] - 2026-05-08
|
||||||
|
|
||||||
|
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
|
||||||
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
|
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
|
||||||
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
|
|
||||||
|
|
||||||
## [2.0.0] - 2026-04-22
|
## [2.0.0] - 2026-04-22
|
||||||
|
|
||||||
|
|||||||
63
CLAUDE.md
63
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/`.
|
`data/v2.db` holds everything that isn't per-session: users, user_roles, agent_groups, messaging_groups, wiring, pending_approvals, user_dms, chat_sdk_* (for the Chat SDK bridge), schema_version. Migrations live at `src/db/migrations/`.
|
||||||
|
|
||||||
|
For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than the `sqlite3` CLI: `pnpm exec tsx scripts/q.ts <db> "<sql>"`. The host setup intentionally avoids depending on the `sqlite3` binary (`setup/verify.ts:5`); the wrapper goes through the `better-sqlite3` dep that setup already installs and verifies. Default-output format matches `sqlite3 -list` (pipe-separated, no header) so existing skill text reads identically.
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
| File | Purpose |
|
| 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/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
| `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/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/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) |
|
| `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/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||||
| `container/skills/` | Container skills mounted into every agent session |
|
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
| `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). |
|
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||||
|
|
||||||
|
## Admin CLI (`ncl`)
|
||||||
|
|
||||||
|
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
|
||||||
|
|
||||||
|
```
|
||||||
|
ncl <resource> <verb> [<id>] [--flags]
|
||||||
|
ncl <resource> help
|
||||||
|
ncl help
|
||||||
|
```
|
||||||
|
|
||||||
|
| Resource | Verbs | What it is |
|
||||||
|
|----------|-------|------------|
|
||||||
|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||||
|
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
|
||||||
|
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
|
||||||
|
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
|
||||||
|
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
|
||||||
|
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||||
|
| destinations | list, add, remove | Where an agent group can send messages |
|
||||||
|
| sessions | list, get | Active sessions (read-only) |
|
||||||
|
| user-dms | list | Cold-DM cache (read-only) |
|
||||||
|
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||||
|
| approvals | list, get | Pending approval requests (read-only) |
|
||||||
|
|
||||||
|
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
|
||||||
|
|
||||||
## Channels and Providers (skill-installed)
|
## 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:
|
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
|
||||||
@@ -92,13 +123,35 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
|
|||||||
|
|
||||||
One tier of agent self-modification today:
|
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.
|
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
|
||||||
|
|
||||||
|
## Container Config
|
||||||
|
|
||||||
|
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
|
||||||
|
|
||||||
|
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
|
||||||
|
|
||||||
|
| Value | Behavior |
|
||||||
|
|-------|----------|
|
||||||
|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||||
|
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||||
|
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||||
|
|
||||||
|
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||||
|
|
||||||
|
## Container Restart
|
||||||
|
|
||||||
|
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
|
||||||
|
|
||||||
|
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
|
||||||
|
|
||||||
|
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
|
||||||
|
|
||||||
## Secrets / Credentials / OneCLI
|
## 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
|
### 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`).
|
- **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`).
|
- **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`).
|
- **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 |
|
| Skill | When to Use |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
|
|||||||
27
bin/ncl
Executable file
27
bin/ncl
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# ncl — NanoClaw CLI launcher.
|
||||||
|
#
|
||||||
|
# Resolves the project root from this script's location, cd's there so the
|
||||||
|
# host-resolved DATA_DIR matches the running host, and execs the TS entry
|
||||||
|
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
|
||||||
|
# to its full path) to invoke from anywhere:
|
||||||
|
#
|
||||||
|
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
|
||||||
|
# # or
|
||||||
|
# alias ncl="$(pwd)/bin/ncl"
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT="${BASH_SOURCE[0]}"
|
||||||
|
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
|
||||||
|
while [ -h "$SCRIPT" ]; do
|
||||||
|
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
SCRIPT="$(readlink "$SCRIPT")"
|
||||||
|
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
|
||||||
|
done
|
||||||
|
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
|
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
exec pnpm exec tsx src/cli/client.ts "$@"
|
||||||
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
|
|||||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||||
# across all users.
|
# across all users.
|
||||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
ARG CLAUDE_CODE_VERSION=2.1.128
|
||||||
ARG AGENT_BROWSER_VERSION=latest
|
ARG AGENT_BROWSER_VERSION=latest
|
||||||
ARG VERCEL_VERSION=52.2.1
|
ARG VERCEL_VERSION=52.2.1
|
||||||
ARG BUN_VERSION=1.3.12
|
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".
|
# the SDK fails at spawn time with "native binary not found".
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
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 \
|
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
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 \
|
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
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 --------------------------------------------------------------
|
# ---- Entrypoint --------------------------------------------------------------
|
||||||
COPY entrypoint.sh /app/entrypoint.sh
|
COPY entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "nanoclaw-agent-runner",
|
"name": "nanoclaw-agent-runner",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0",
|
"zod": "^4.0.0",
|
||||||
@@ -18,23 +18,23 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"test": "bun test"
|
"test": "bun test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"cron-parser": "^5.0.0",
|
"cron-parser": "^5.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
|
|||||||
254
container/agent-runner/src/cli/ncl.ts
Normal file
254
container/agent-runner/src/cli/ncl.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* ncl — NanoClaw CLI client (container edition).
|
||||||
|
*
|
||||||
|
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
|
||||||
|
* container (the session DBs exist at /workspace/) and uses a DB transport
|
||||||
|
* instead of the Unix socket transport.
|
||||||
|
*
|
||||||
|
* Writes a cli_request system message to outbound.db, polls inbound.db
|
||||||
|
* for the response. Self-contained — no imports from agent-runner.
|
||||||
|
*/
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Frame types (mirrors src/cli/frame.ts on the host)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type RequestFrame = {
|
||||||
|
id: string;
|
||||||
|
command: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResponseFrame =
|
||||||
|
| { id: string; ok: true; data: unknown }
|
||||||
|
| { id: string; ok: false; error: { code: string; message: string } };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Paths
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const INBOUND_DB = '/workspace/inbound.db';
|
||||||
|
const OUTBOUND_DB = '/workspace/outbound.db';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DB transport
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function generateId(): string {
|
||||||
|
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a cli_request to outbound.db.
|
||||||
|
*
|
||||||
|
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
|
||||||
|
* preventing seq collisions with concurrent agent-runner writes.
|
||||||
|
*/
|
||||||
|
function writeRequest(req: RequestFrame): void {
|
||||||
|
const db = new Database(OUTBOUND_DB);
|
||||||
|
db.exec('PRAGMA journal_mode = DELETE');
|
||||||
|
db.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||||
|
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.exec('BEGIN IMMEDIATE');
|
||||||
|
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
|
||||||
|
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||||
|
const max = Math.max(maxOut, maxIn);
|
||||||
|
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||||
|
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||||
|
).run({
|
||||||
|
$id: req.id,
|
||||||
|
$seq: nextSeq,
|
||||||
|
$content: JSON.stringify({
|
||||||
|
action: 'cli_request',
|
||||||
|
requestId: req.id,
|
||||||
|
command: req.command,
|
||||||
|
args: req.args,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
inDb.close();
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll inbound.db for a cli_response matching our requestId.
|
||||||
|
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
|
||||||
|
*/
|
||||||
|
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||||
|
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
inDb.exec('PRAGMA mmap_size = 0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = inDb
|
||||||
|
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||||
|
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
// Mark as completed via processing_ack so agent-runner skips it
|
||||||
|
const outDb = new Database(OUTBOUND_DB);
|
||||||
|
outDb.exec('PRAGMA journal_mode = DELETE');
|
||||||
|
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||||
|
outDb
|
||||||
|
.prepare(
|
||||||
|
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||||
|
)
|
||||||
|
.run(row.id);
|
||||||
|
outDb.close();
|
||||||
|
|
||||||
|
const parsed = JSON.parse(row.content);
|
||||||
|
return parsed.frame as ResponseFrame;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
inDb.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.sleepSync(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Arg parsing (mirrors host-side client.ts)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseArgv(argv: string[]): {
|
||||||
|
command: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
json: boolean;
|
||||||
|
} {
|
||||||
|
const positional: string[] = [];
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
let json = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--json') {
|
||||||
|
json = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith('--')) {
|
||||||
|
args[key] = true;
|
||||||
|
} else {
|
||||||
|
args[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
positional.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positional.length === 0) {
|
||||||
|
process.stderr.write('ncl: missing command\n');
|
||||||
|
printUsage();
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all positionals with dashes. The dispatcher trims the last
|
||||||
|
// segment as a target ID if the full name isn't a registered command.
|
||||||
|
const command = positional.join('-');
|
||||||
|
|
||||||
|
return { command, args, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
process.stdout.write(
|
||||||
|
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting (mirrors src/cli/format.ts on the host)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function formatHuman(resp: ResponseFrame): string {
|
||||||
|
if (!resp.ok) {
|
||||||
|
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = resp.data;
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
return JSON.stringify(data, null, 2) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isFlat = data.every(
|
||||||
|
(r) =>
|
||||||
|
typeof r === 'object' &&
|
||||||
|
r !== null &&
|
||||||
|
!Array.isArray(r) &&
|
||||||
|
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
|
||||||
|
|
||||||
|
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||||
|
const widths = keys.map((k) =>
|
||||||
|
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
|
||||||
|
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||||
|
const rows = data.map((r) =>
|
||||||
|
keys
|
||||||
|
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
|
||||||
|
.join(' '),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [header, sep, ...rows, ''].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { command, args, json } = parseArgv(argv);
|
||||||
|
const requestId = generateId();
|
||||||
|
const req: RequestFrame = { id: requestId, command, args };
|
||||||
|
|
||||||
|
writeRequest(req);
|
||||||
|
|
||||||
|
const resp = pollResponse(requestId, 30_000);
|
||||||
|
|
||||||
|
if (!resp) {
|
||||||
|
process.stderr.write('ncl: command timed out after 30s\n');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||||
|
} else {
|
||||||
|
const output = formatHuman(resp);
|
||||||
|
if (!resp.ok) {
|
||||||
|
process.stderr.write(output);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.stdout.write(output);
|
||||||
|
}
|
||||||
34
container/agent-runner/src/compact-instructions.ts
Normal file
34
container/agent-runner/src/compact-instructions.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* PreCompact hook script — outputs custom compaction instructions to stdout.
|
||||||
|
*
|
||||||
|
* Claude Code captures the stdout of PreCompact shell hooks and passes it
|
||||||
|
* as `customInstructions` to the compaction prompt. This ensures the
|
||||||
|
* compaction summary preserves message routing context that the agent needs
|
||||||
|
* to correctly address responses.
|
||||||
|
*
|
||||||
|
* Invoked by the PreCompact hook in .claude-shared/settings.json:
|
||||||
|
* "command": "bun /app/src/compact-instructions.ts"
|
||||||
|
*/
|
||||||
|
import { getAllDestinations } from './destinations.js';
|
||||||
|
|
||||||
|
const destinations = getAllDestinations();
|
||||||
|
const names = destinations.map((d) => d.name);
|
||||||
|
|
||||||
|
const instructions = [
|
||||||
|
'Preserve the following in the compaction summary:',
|
||||||
|
'',
|
||||||
|
'1. For recent messages, keep the full XML structure including all attributes:',
|
||||||
|
' - <message from="..." sender="..." time="..."> for chat messages',
|
||||||
|
' - <task from="..." time="..."> for scheduled tasks',
|
||||||
|
' - <webhook from="..." source="..." event="..."> for webhooks',
|
||||||
|
' The message content can be summarized if long, but the XML tags and attributes must remain.',
|
||||||
|
'',
|
||||||
|
'2. Preserve the chronological message/reply sequence of recent exchanges.',
|
||||||
|
' The agent needs to see: who said what, in what order, and from which destination.',
|
||||||
|
'',
|
||||||
|
'3. The `from` attribute identifies which destination sent the message.',
|
||||||
|
' The agent MUST wrap all responses in <message to="name">...</message> blocks.',
|
||||||
|
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log(instructions.join('\n'));
|
||||||
@@ -16,6 +16,8 @@ export interface RunnerConfig {
|
|||||||
agentGroupId: string;
|
agentGroupId: string;
|
||||||
maxMessagesPerPrompt: number;
|
maxMessagesPerPrompt: number;
|
||||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||||
|
model?: string;
|
||||||
|
effort?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_MESSAGES = 10;
|
const DEFAULT_MAX_MESSAGES = 10;
|
||||||
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
|
|||||||
agentGroupId: (raw.agentGroupId as string) || '',
|
agentGroupId: (raw.agentGroupId as string) || '',
|
||||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||||
|
model: (raw.model as string) || undefined,
|
||||||
|
effort: (raw.effort as string) || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return _config;
|
return _config;
|
||||||
|
|||||||
29
container/agent-runner/src/current-batch.ts
Normal file
29
container/agent-runner/src/current-batch.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Per-batch context the poll loop publishes for downstream consumers
|
||||||
|
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||||
|
*
|
||||||
|
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||||
|
* message in the batch the agent is currently processing. MCP tools like
|
||||||
|
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||||
|
* row so the host's a2a return-path routing can correlate replies back to
|
||||||
|
* the originating session.
|
||||||
|
*
|
||||||
|
* This is module-level state on purpose: the agent-runner is single-process
|
||||||
|
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||||
|
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||||
|
* completes (or errors out).
|
||||||
|
*/
|
||||||
|
let currentInReplyTo: string | null = null;
|
||||||
|
|
||||||
|
export function setCurrentInReplyTo(id: string | null): void {
|
||||||
|
currentInReplyTo = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearCurrentInReplyTo(): void {
|
||||||
|
currentInReplyTo = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentInReplyTo(): string | null {
|
||||||
|
return currentInReplyTo;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
|||||||
platform_id TEXT,
|
platform_id TEXT,
|
||||||
channel_type TEXT,
|
channel_type TEXT,
|
||||||
thread_id TEXT,
|
thread_id TEXT,
|
||||||
content TEXT NOT NULL
|
content TEXT NOT NULL,
|
||||||
|
on_wake INTEGER NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
CREATE TABLE delivered (
|
CREATE TABLE delivered (
|
||||||
message_out_id TEXT PRIMARY KEY,
|
message_out_id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function getMaxMessagesPerPrompt(): number {
|
|||||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||||
* trigger=1 separately (see src/db/session-db.ts).
|
* trigger=1 separately (see src/db/session-db.ts).
|
||||||
*/
|
*/
|
||||||
export function getPendingMessages(): MessageInRow[] {
|
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
|
||||||
const inbound = openInboundDb();
|
const inbound = openInboundDb();
|
||||||
const outbound = getOutboundDb();
|
const outbound = getOutboundDb();
|
||||||
|
|
||||||
@@ -59,10 +59,11 @@ export function getPendingMessages(): MessageInRow[] {
|
|||||||
`SELECT * FROM messages_in
|
`SELECT * FROM messages_in
|
||||||
WHERE status = 'pending'
|
WHERE status = 'pending'
|
||||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||||
|
AND (on_wake = 0 OR ?1 = 1)
|
||||||
ORDER BY seq DESC
|
ORDER BY seq DESC
|
||||||
LIMIT ?`,
|
LIMIT ?2`,
|
||||||
)
|
)
|
||||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||||
|
|
||||||
if (pending.length === 0) return [];
|
if (pending.length === 0) return [];
|
||||||
|
|
||||||
|
|||||||
63
container/agent-runner/src/destinations.test.ts
Normal file
63
container/agent-runner/src/destinations.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||||
|
|
||||||
|
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
|
||||||
|
import { buildSystemPromptAddendum } from './destinations.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initTestSessionDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeSessionDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||||
|
)
|
||||||
|
.run(name, displayName, channelType, platformId);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
|
||||||
|
it('includes default-routing nudge when there are >1 destinations', () => {
|
||||||
|
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||||
|
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
|
||||||
|
|
||||||
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
|
expect(prompt).toContain('Default routing');
|
||||||
|
expect(prompt).toContain('from="name"');
|
||||||
|
expect(prompt).toContain('`casa`');
|
||||||
|
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires explicit wrapping even for a single destination', () => {
|
||||||
|
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||||
|
|
||||||
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
|
expect(prompt).toContain('Every response must be wrapped');
|
||||||
|
expect(prompt).toContain('<message to="name">');
|
||||||
|
expect(prompt).toContain('`casa`');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles the no-destination case without crashing', () => {
|
||||||
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
|
expect(prompt).toContain('no configured destinations');
|
||||||
|
expect(prompt).not.toContain('Default routing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes default-routing and wrapping instructions for single destination', () => {
|
||||||
|
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||||
|
|
||||||
|
const prompt = buildSystemPromptAddendum('Casa');
|
||||||
|
|
||||||
|
expect(prompt).toContain('Every response must be wrapped');
|
||||||
|
expect(prompt).toContain('<message to="name">');
|
||||||
|
expect(prompt).toContain('Default routing');
|
||||||
|
expect(prompt).toContain('`casa`');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -102,32 +102,28 @@ function buildDestinationsSection(): string {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single-destination shortcut: the agent just writes its response normally.
|
const lines = ['## Sending messages', ''];
|
||||||
if (all.length === 1) {
|
if (all.length === 1) {
|
||||||
const d = all[0];
|
const d = all[0];
|
||||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||||
return [
|
lines.push(`Your destination is \`${d.name}\`${label}.`);
|
||||||
'## Sending messages',
|
} else {
|
||||||
'',
|
lines.push('You can send messages to the following destinations:', '');
|
||||||
`Your messages are delivered to \`${d.name}\`${label}. Just write your response directly — no special wrapping needed.`,
|
|
||||||
'',
|
|
||||||
'To mark something as scratchpad (logged but not sent), wrap it in `<internal>...</internal>`.',
|
|
||||||
'',
|
|
||||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool.',
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ['## Sending messages', '', 'You can send messages to the following destinations:', ''];
|
|
||||||
for (const d of all) {
|
for (const d of all) {
|
||||||
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
const label = d.displayName && d.displayName !== d.name ? ` (${d.displayName})` : '';
|
||||||
lines.push(`- \`${d.name}\`${label}`);
|
lines.push(`- \`${d.name}\`${label}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('To send a message, wrap it in a `<message to="name">...</message>` block.');
|
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
|
||||||
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
|
||||||
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
|
||||||
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
lines.push(
|
||||||
|
'**Default routing**: when replying to an incoming message, address the same destination the message came `from` — every inbound `<message>` tag carries a `from="name"` attribute that names the origin destination. Only address a different destination when the request itself asks you to (e.g., "tell Laura that…").',
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
lines.push(
|
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.',
|
'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.',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -177,40 +177,49 @@ function formatSingleChat(msg: MessageInRow): string {
|
|||||||
const replyPrefix = formatReplyContext(content.replyTo);
|
const replyPrefix = formatReplyContext(content.replyTo);
|
||||||
const attachmentsSuffix = formatAttachments(content.attachments);
|
const attachmentsSuffix = formatAttachments(content.attachments);
|
||||||
|
|
||||||
// Look up the destination name for the origin (reverse map lookup).
|
const fromAttr = originAttr(msg);
|
||||||
// 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 || '')}"`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
return `<message${idAttr}${fromAttr} sender="${escapeXml(sender)}" time="${escapeXml(time)}"${replyAttr}>${replyPrefix}${escapeXml(text)}${attachmentsSuffix}</message>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a ` from="destination_name"` attribute string from a message's routing
|
||||||
|
* fields. Shared by all formatters so the agent always knows where a message
|
||||||
|
* originated — critical for explicit addressing.
|
||||||
|
*/
|
||||||
|
function originAttr(msg: MessageInRow): string {
|
||||||
|
const fromDest = findByRouting(msg.channel_type, msg.platform_id);
|
||||||
|
if (fromDest) return ` from="${escapeXml(fromDest.name)}"`;
|
||||||
|
if (msg.channel_type || msg.platform_id) {
|
||||||
|
return ` from="unknown:${escapeXml(msg.channel_type || '')}:${escapeXml(msg.platform_id || '')}"`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
function formatTaskMessage(msg: MessageInRow): string {
|
function formatTaskMessage(msg: MessageInRow): string {
|
||||||
const content = parseContent(msg.content);
|
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) {
|
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 || '');
|
parts.push('Instructions:', content.prompt || '');
|
||||||
return parts.join('\n');
|
return `<task${from} time="${escapeXml(time)}">${parts.join('\n')}</task>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWebhookMessage(msg: MessageInRow): string {
|
function formatWebhookMessage(msg: MessageInRow): string {
|
||||||
const content = parseContent(msg.content);
|
const content = parseContent(msg.content);
|
||||||
const source = content.source || 'unknown';
|
const source = content.source || 'unknown';
|
||||||
const event = content.event || 'unknown';
|
const event = content.event || 'unknown';
|
||||||
return `[WEBHOOK: ${source}/${event}]\n\n${JSON.stringify(content.payload || content, null, 2)}`;
|
const from = originAttr(msg);
|
||||||
|
return `<webhook${from} source="${escapeXml(source)}" event="${escapeXml(event)}">${JSON.stringify(content.payload || content, null, 2)}</webhook>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSystemMessage(msg: MessageInRow): string {
|
function formatSystemMessage(msg: MessageInRow): string {
|
||||||
const content = parseContent(msg.content);
|
const content = parseContent(msg.content);
|
||||||
return `[SYSTEM RESPONSE]\n\nAction: ${content.action || 'unknown'}\nStatus: ${content.status || 'unknown'}\nResult: ${JSON.stringify(content.result || null)}`;
|
const from = originAttr(msg);
|
||||||
|
return `<system_response${from} action="${escapeXml(content.action || 'unknown')}" status="${escapeXml(content.status || 'unknown')}">${JSON.stringify(content.result || null)}</system_response>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ async function main(): Promise<void> {
|
|||||||
mcpServers,
|
mcpServers,
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||||
|
model: config.model,
|
||||||
|
effort: config.effort,
|
||||||
});
|
});
|
||||||
|
|
||||||
await runPollLoop({
|
await runPollLoop({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|||||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
|
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
|
||||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||||
import { getPendingMessages } from './db/messages-in.js';
|
import { getPendingMessages } from './db/messages-in.js';
|
||||||
|
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||||
import { MockProvider } from './providers/mock.js';
|
import { MockProvider } from './providers/mock.js';
|
||||||
import { runPollLoop } from './poll-loop.js';
|
import { runPollLoop } from './poll-loop.js';
|
||||||
|
|
||||||
@@ -74,6 +75,163 @@ describe('poll loop integration', () => {
|
|||||||
await loopPromise.catch(() => {});
|
await loopPromise.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should resolve thread_id per-destination, not from global routing', async () => {
|
||||||
|
// Seed a second destination
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Insert messages from each destination with distinct thread IDs
|
||||||
|
insertMessage('m-discord', { sender: 'Alice', text: 'from discord' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread-1' });
|
||||||
|
insertMessage('m-slack', { sender: 'Bob', text: 'from slack' }, { platformId: 'chan-2', channelType: 'slack', threadId: 'slack-thread-99' });
|
||||||
|
|
||||||
|
// Agent replies to both destinations
|
||||||
|
const provider = new MockProvider({}, () =>
|
||||||
|
'<message to="discord-test">reply-d</message><message to="slack-test">reply-s</message>',
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
const discordOut = out.find((m) => m.platform_id === 'chan-1');
|
||||||
|
const slackOut = out.find((m) => m.platform_id === 'chan-2');
|
||||||
|
|
||||||
|
expect(discordOut).toBeDefined();
|
||||||
|
expect(discordOut!.thread_id).toBe('discord-thread-1');
|
||||||
|
expect(discordOut!.in_reply_to).toBe('m-discord');
|
||||||
|
|
||||||
|
expect(slackOut).toBeDefined();
|
||||||
|
expect(slackOut!.thread_id).toBe('slack-thread-99');
|
||||||
|
expect(slackOut!.in_reply_to).toBe('m-slack');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bare text produces no outbound messages (scratchpad only)', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
// Agent responds with bare text — no <message to="..."> wrapping
|
||||||
|
const provider = new MockProvider({}, () => 'I am thinking about this...');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
// Wait long enough for the poll loop to process
|
||||||
|
await sleep(1000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(0);
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unknown destination is dropped, valid destination is sent', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new MockProvider(
|
||||||
|
{},
|
||||||
|
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
// Only the valid destination should produce output
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toBe('delivered');
|
||||||
|
expect(out[0].platform_id).toBe('chan-1');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('multiple <message> blocks each produce an outbound message', async () => {
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new MockProvider(
|
||||||
|
{},
|
||||||
|
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(2);
|
||||||
|
const discord = out.find((m) => m.platform_id === 'chan-1');
|
||||||
|
const slack = out.find((m) => m.platform_id === 'chan-2');
|
||||||
|
expect(discord).toBeDefined();
|
||||||
|
expect(JSON.parse(discord!.content).text).toBe('for discord');
|
||||||
|
expect(slack).toBeDefined();
|
||||||
|
expect(JSON.parse(slack!.content).text).toBe('for slack');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends null thread_id when no prior inbound from destination', async () => {
|
||||||
|
// Seed a second destination that has NO inbound messages
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// Only insert a message from discord — slack-new has never sent anything
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
|
||||||
|
|
||||||
|
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].platform_id).toBe('chan-new');
|
||||||
|
expect(out[0].thread_id).toBeNull();
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
|
||||||
|
// Two messages from same destination, different threads
|
||||||
|
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
|
||||||
|
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
|
||||||
|
|
||||||
|
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].thread_id).toBe('thread-new');
|
||||||
|
expect(out[0].in_reply_to).toBe('m-new');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
it('should process messages arriving after loop starts', async () => {
|
it('should process messages arriving after loop starts', async () => {
|
||||||
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -91,8 +249,161 @@ describe('poll loop integration', () => {
|
|||||||
|
|
||||||
await loopPromise.catch(() => {});
|
await loopPromise.catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('internal tags between message blocks are stripped from scratchpad', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new MockProvider(
|
||||||
|
{},
|
||||||
|
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
|
||||||
|
);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toBe('answer');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles mixed task + chat batch with correct origin metadata', async () => {
|
||||||
|
// Seed destination for routing lookup
|
||||||
|
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
// Task with same routing — simulates a scheduled task in a channel session
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||||
|
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||||
|
)
|
||||||
|
.run(JSON.stringify({ prompt: 'daily check' }));
|
||||||
|
|
||||||
|
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].platform_id).toBe('chan-1');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject destination reminder after a compacted event', async () => {
|
||||||
|
// Two destinations — required for the reminder to fire (single-destination
|
||||||
|
// groups have a fallback path that works without <message to="…"> wrapping).
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new CompactingProvider();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
|
||||||
|
expect(reminder).toBeDefined();
|
||||||
|
expect(reminder).toContain('2 destinations');
|
||||||
|
expect(reminder).toContain('discord-test');
|
||||||
|
expect(reminder).toContain('discord-second');
|
||||||
|
expect(reminder).toContain('<message to="name">');
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT inject destination reminder with a single destination', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new CompactingProvider();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Only the original prompt push (if any) — no reminder, since beforeEach
|
||||||
|
// seeds exactly one destination.
|
||||||
|
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
|
||||||
|
expect(reminders).toHaveLength(0);
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider that emits a single compacted event mid-stream, then returns a
|
||||||
|
* result. Captures every push() call so tests can assert on the injected
|
||||||
|
* reminder content.
|
||||||
|
*/
|
||||||
|
class CompactingProvider {
|
||||||
|
readonly supportsNativeSlashCommands = false;
|
||||||
|
readonly pushes: string[] = [];
|
||||||
|
|
||||||
|
isSessionInvalid(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(_input: { prompt: string; cwd: string }) {
|
||||||
|
const pushes = this.pushes;
|
||||||
|
let ended = false;
|
||||||
|
let aborted = false;
|
||||||
|
let resolveWaiter: (() => void) | null = null;
|
||||||
|
|
||||||
|
async function* events() {
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'init' as const, continuation: 'compaction-test-session' };
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
|
||||||
|
|
||||||
|
// Wait for poll-loop to push the reminder (or end / abort)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveWaiter = resolve;
|
||||||
|
// Belt-and-braces: don't hang forever if the reminder never arrives
|
||||||
|
setTimeout(resolve, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
yield { type: 'activity' as const };
|
||||||
|
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
|
||||||
|
while (!ended && !aborted) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
resolveWaiter = resolve;
|
||||||
|
setTimeout(resolve, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
push(message: string) {
|
||||||
|
pushes.push(message);
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
end() {
|
||||||
|
ended = true;
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
abort() {
|
||||||
|
aborted = true;
|
||||||
|
resolveWaiter?.();
|
||||||
|
},
|
||||||
|
events: events(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: run poll loop until aborted or timeout
|
// Helper: run poll loop until aborted or timeout
|
||||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
@@ -119,3 +430,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
|
|||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('poll loop — provider error recovery', () => {
|
||||||
|
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new ThrowingProvider('API rate limit exceeded');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||||
|
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
|
||||||
|
|
||||||
|
// Input message should be marked completed despite the error
|
||||||
|
const pending = getPendingMessages();
|
||||||
|
expect(pending).toHaveLength(0);
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('poll loop — stale session recovery', () => {
|
||||||
|
it('clears continuation when provider reports session invalid', async () => {
|
||||||
|
// Pre-seed a continuation so the local variable in runPollLoop is set.
|
||||||
|
// Without this, the `if (continuation && isSessionInvalid)` check skips.
|
||||||
|
setContinuation('mock', 'pre-existing-session');
|
||||||
|
|
||||||
|
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||||
|
|
||||||
|
const provider = new InvalidSessionProvider();
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
// Error was written to outbound
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||||
|
|
||||||
|
// Continuation was cleared (isSessionInvalid returned true)
|
||||||
|
expect(getContinuation('mock')).toBeUndefined();
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('poll loop — /clear command', () => {
|
||||||
|
it('clears session, writes confirmation, skips query', async () => {
|
||||||
|
// Seed a continuation so we can verify it gets cleared
|
||||||
|
setContinuation('mock', 'existing-session-id');
|
||||||
|
expect(getContinuation('mock')).toBe('existing-session-id');
|
||||||
|
|
||||||
|
// Insert a /clear command
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||||
|
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||||
|
)
|
||||||
|
.run(JSON.stringify({ text: '/clear' }));
|
||||||
|
|
||||||
|
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||||
|
|
||||||
|
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
|
||||||
|
|
||||||
|
// Continuation was cleared
|
||||||
|
expect(getContinuation('mock')).toBeUndefined();
|
||||||
|
|
||||||
|
// Command message was completed
|
||||||
|
const pending = getPendingMessages();
|
||||||
|
expect(pending).toHaveLength(0);
|
||||||
|
|
||||||
|
await loopPromise.catch(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider that throws on every query, simulating API failures.
|
||||||
|
*/
|
||||||
|
class ThrowingProvider {
|
||||||
|
readonly supportsNativeSlashCommands = false;
|
||||||
|
private errorMessage: string;
|
||||||
|
|
||||||
|
constructor(errorMessage: string) {
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSessionInvalid(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(_input: { prompt: string; cwd: string }) {
|
||||||
|
const errorMessage = this.errorMessage;
|
||||||
|
return {
|
||||||
|
push() {},
|
||||||
|
end() {},
|
||||||
|
abort() {},
|
||||||
|
events: (async function* () {
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider that throws with an error that triggers isSessionInvalid.
|
||||||
|
* First emits an init event (setting continuation), then throws.
|
||||||
|
*/
|
||||||
|
class InvalidSessionProvider {
|
||||||
|
readonly supportsNativeSlashCommands = false;
|
||||||
|
|
||||||
|
isSessionInvalid(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
query(_input: { prompt: string; cwd: string }) {
|
||||||
|
return {
|
||||||
|
push() {},
|
||||||
|
end() {},
|
||||||
|
abort() {},
|
||||||
|
events: (async function* () {
|
||||||
|
yield { type: 'init' as const, continuation: 'doomed-session' };
|
||||||
|
throw new Error('session not found');
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
83
container/agent-runner/src/mcp-tools/cli.instructions.md
Normal file
83
container/agent-runner/src/mcp-tools/cli.instructions.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
## Admin CLI (`ncl`)
|
||||||
|
|
||||||
|
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
ncl <resource> <verb> [--flags]
|
||||||
|
ncl <resource> help
|
||||||
|
ncl help
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
Run `ncl help` for the full list. Common resources:
|
||||||
|
|
||||||
|
| Resource | Verbs | What it is |
|
||||||
|
|----------|-------|------------|
|
||||||
|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||||
|
| sessions | list, get | Active sessions (read-only) |
|
||||||
|
| destinations | list, add, remove | Where an agent group can send messages |
|
||||||
|
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||||
|
|
||||||
|
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||||
|
|
||||||
|
### When to use
|
||||||
|
|
||||||
|
- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config.
|
||||||
|
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||||
|
- **Checking who's in your group** — `ncl members list`.
|
||||||
|
- **Seeing your destinations** — `ncl destinations list`.
|
||||||
|
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||||
|
|
||||||
|
### Access rules
|
||||||
|
|
||||||
|
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||||
|
|
||||||
|
### Approval flow
|
||||||
|
|
||||||
|
Write commands require admin approval. Here's what happens:
|
||||||
|
|
||||||
|
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
|
||||||
|
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
|
||||||
|
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
|
||||||
|
4. Once the admin responds:
|
||||||
|
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
|
||||||
|
- **Rejected:** you get a system message saying the request was rejected.
|
||||||
|
|
||||||
|
You don't need to poll or retry — the result arrives automatically.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read commands (no approval needed)
|
||||||
|
ncl groups get
|
||||||
|
ncl groups config get
|
||||||
|
ncl sessions list
|
||||||
|
ncl destinations list
|
||||||
|
ncl members list
|
||||||
|
|
||||||
|
# Write commands (approval required)
|
||||||
|
ncl groups restart
|
||||||
|
ncl groups restart --rebuild --message "Config updated."
|
||||||
|
ncl groups config update --model claude-sonnet-4-5-20250514
|
||||||
|
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
|
||||||
|
ncl groups config add-package --npm some-package
|
||||||
|
ncl members add --user telegram:jane
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important
|
||||||
|
|
||||||
|
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
|
||||||
|
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
|
||||||
|
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
|
||||||
|
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
|
||||||
50
container/agent-runner/src/mcp-tools/core.test.ts
Normal file
50
container/agent-runner/src/mcp-tools/core.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Tests for the core MCP tools' interaction with the per-batch routing
|
||||||
|
* context. The agent-runner sets a current `inReplyTo` at the top of each
|
||||||
|
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||||
|
* send_file) must pick it up so a2a return-path routing on the host can
|
||||||
|
* correlate replies back to the originating session.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
|
||||||
|
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||||
|
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||||
|
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||||
|
import { sendMessage } from './core.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
initTestSessionDb();
|
||||||
|
// Seed a peer agent destination
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearCurrentInReplyTo();
|
||||||
|
closeSessionDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||||
|
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||||
|
setCurrentInReplyTo('inbound-msg-1');
|
||||||
|
|
||||||
|
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].in_reply_to).toBe('inbound-msg-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes null when no batch is active', async () => {
|
||||||
|
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||||
|
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||||
|
|
||||||
|
const out = getUndeliveredMessages();
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(out[0].in_reply_to).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
|
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||||
import { findByName, getAllDestinations } from '../destinations.js';
|
import { findByName, getAllDestinations } from '../destinations.js';
|
||||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||||
import { getSessionRouting } from '../db/session-routing.js';
|
import { getSessionRouting } from '../db/session-routing.js';
|
||||||
@@ -50,9 +51,7 @@ function destinationList(): string {
|
|||||||
*/
|
*/
|
||||||
function resolveRouting(
|
function resolveRouting(
|
||||||
to: string | undefined,
|
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) {
|
if (!to) {
|
||||||
// Default: reply to whatever thread/channel this session is bound to.
|
// Default: reply to whatever thread/channel this session is bound to.
|
||||||
const session = getSessionRouting();
|
const session = getSessionRouting();
|
||||||
@@ -82,9 +81,7 @@ function resolveRouting(
|
|||||||
// preserve the thread_id so replies land in the correct thread.
|
// preserve the thread_id so replies land in the correct thread.
|
||||||
const session = getSessionRouting();
|
const session = getSessionRouting();
|
||||||
const threadId =
|
const threadId =
|
||||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId
|
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
|
||||||
? session.thread_id
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
channel_type: dest.channelType!,
|
channel_type: dest.channelType!,
|
||||||
platform_id: dest.platformId!,
|
platform_id: dest.platformId!,
|
||||||
@@ -98,12 +95,14 @@ function resolveRouting(
|
|||||||
export const sendMessage: McpToolDefinition = {
|
export const sendMessage: McpToolDefinition = {
|
||||||
tool: {
|
tool: {
|
||||||
name: 'send_message',
|
name: 'send_message',
|
||||||
description:
|
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||||
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
properties: {
|
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' },
|
text: { type: 'string', description: 'Message content' },
|
||||||
},
|
},
|
||||||
required: ['text'],
|
required: ['text'],
|
||||||
@@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = {
|
|||||||
const id = generateId();
|
const id = generateId();
|
||||||
const seq = writeMessageOut({
|
const seq = writeMessageOut({
|
||||||
id,
|
id,
|
||||||
|
in_reply_to: getCurrentInReplyTo(),
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
platform_id: routing.platform_id,
|
platform_id: routing.platform_id,
|
||||||
channel_type: routing.channel_type,
|
channel_type: routing.channel_type,
|
||||||
@@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = {
|
|||||||
|
|
||||||
writeMessageOut({
|
writeMessageOut({
|
||||||
id,
|
id,
|
||||||
|
in_reply_to: getCurrentInReplyTo(),
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
platform_id: routing.platform_id,
|
platform_id: routing.platform_id,
|
||||||
channel_type: routing.channel_type,
|
channel_type: routing.channel_type,
|
||||||
|
|||||||
@@ -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"] })
|
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).
|
||||||
|
|||||||
@@ -14,13 +14,18 @@ afterEach(() => {
|
|||||||
closeSessionDb();
|
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()
|
getInboundDb()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
|
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
|
||||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
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', () => {
|
describe('formatter', () => {
|
||||||
@@ -47,7 +52,7 @@ describe('formatter', () => {
|
|||||||
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
|
insertMessage('m1', 'task', { prompt: 'Review open PRs' });
|
||||||
const messages = getPendingMessages();
|
const messages = getPendingMessages();
|
||||||
const prompt = formatMessages(messages);
|
const prompt = formatMessages(messages);
|
||||||
expect(prompt).toContain('[SCHEDULED TASK]');
|
expect(prompt).toContain('<task');
|
||||||
expect(prompt).toContain('Review open PRs');
|
expect(prompt).toContain('Review open PRs');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,15 +60,17 @@ describe('formatter', () => {
|
|||||||
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
|
insertMessage('m1', 'webhook', { source: 'github', event: 'push', payload: { ref: 'main' } });
|
||||||
const messages = getPendingMessages();
|
const messages = getPendingMessages();
|
||||||
const prompt = formatMessages(messages);
|
const prompt = formatMessages(messages);
|
||||||
expect(prompt).toContain('[WEBHOOK: github/push]');
|
expect(prompt).toContain('<webhook');
|
||||||
|
expect(prompt).toContain('source="github"');
|
||||||
|
expect(prompt).toContain('event="push"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should format system messages', () => {
|
it('should format system messages', () => {
|
||||||
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
|
insertMessage('m1', 'system', { action: 'register_group', status: 'success', result: { id: 'ag-1' } });
|
||||||
const messages = getPendingMessages();
|
const messages = getPendingMessages();
|
||||||
const prompt = formatMessages(messages);
|
const prompt = formatMessages(messages);
|
||||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
expect(prompt).toContain('<system_response');
|
||||||
expect(prompt).toContain('register_group');
|
expect(prompt).toContain('action="register_group"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle mixed kinds', () => {
|
it('should handle mixed kinds', () => {
|
||||||
@@ -72,7 +79,7 @@ describe('formatter', () => {
|
|||||||
const messages = getPendingMessages();
|
const messages = getPendingMessages();
|
||||||
const prompt = formatMessages(messages);
|
const prompt = formatMessages(messages);
|
||||||
expect(prompt).toContain('sender="John"');
|
expect(prompt).toContain('sender="John"');
|
||||||
expect(prompt).toContain('[SYSTEM RESPONSE]');
|
expect(prompt).toContain('<system_response');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should escape XML in content', () => {
|
it('should escape XML in content', () => {
|
||||||
@@ -129,6 +136,58 @@ describe('accumulate gate (trigger column)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('on_wake filtering', () => {
|
||||||
|
it('first poll returns on_wake=1 messages', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||||
|
const messages = getPendingMessages(true);
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].id).toBe('m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('subsequent polls skip on_wake=1 messages', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||||
|
const messages = getPendingMessages(false);
|
||||||
|
expect(messages).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normal messages returned regardless of isFirstPoll', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
|
||||||
|
expect(getPendingMessages(true)).toHaveLength(1);
|
||||||
|
|
||||||
|
// Reset: mark completed so we can re-test with a fresh message
|
||||||
|
markCompleted(['m1']);
|
||||||
|
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
|
||||||
|
expect(getPendingMessages(false)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed batch: first poll returns both normal and on_wake messages', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||||
|
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||||
|
const messages = getPendingMessages(true);
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed batch: subsequent poll returns only normal messages', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||||
|
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||||
|
const messages = getPendingMessages(false);
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0].id).toBe('m1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on_wake defaults to 0 for inserts without explicit value', () => {
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||||
|
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
// Should be returned even on non-first poll (on_wake=0)
|
||||||
|
expect(getPendingMessages(false)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('routing', () => {
|
describe('routing', () => {
|
||||||
it('should extract routing from messages', () => {
|
it('should extract routing from messages', () => {
|
||||||
getInboundDb()
|
getInboundDb()
|
||||||
@@ -147,6 +206,76 @@ describe('routing', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('origin metadata (from= attribute)', () => {
|
||||||
|
function seedDestination(name: string, channelType: string, platformId: string): void {
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||||
|
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||||
|
)
|
||||||
|
.run(name, name, channelType, platformId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
|
||||||
|
getInboundDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||||
|
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(id, kind, platformId, channelType, JSON.stringify(content));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('chat message includes from= when destination matches', () => {
|
||||||
|
seedDestination('discord-main', 'discord', 'chan-1');
|
||||||
|
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('from="discord-main"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat message falls back to raw routing when no destination matches', () => {
|
||||||
|
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('from="unknown:telegram:chat-999"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chat message omits from= when routing is null', () => {
|
||||||
|
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).not.toContain('from=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('task message includes from= when destination matches', () => {
|
||||||
|
seedDestination('slack-ops', 'slack', 'C-OPS');
|
||||||
|
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('<task');
|
||||||
|
expect(prompt).toContain('from="slack-ops"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('task message omits from= when routing is null', () => {
|
||||||
|
insertMessage('t1', 'task', { prompt: 'check status' });
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('<task');
|
||||||
|
expect(prompt).not.toContain('from=');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('webhook message includes from= when destination matches', () => {
|
||||||
|
seedDestination('github-ch', 'github', 'repo-1');
|
||||||
|
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('<webhook');
|
||||||
|
expect(prompt).toContain('from="github-ch"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('system message includes from= when destination matches', () => {
|
||||||
|
seedDestination('discord-main', 'discord', 'chan-1');
|
||||||
|
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
|
||||||
|
const prompt = formatMessages(getPendingMessages());
|
||||||
|
expect(prompt).toContain('<system_response');
|
||||||
|
expect(prompt).toContain('from="discord-main"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mock provider', () => {
|
describe('mock provider', () => {
|
||||||
it('should produce init + result events', async () => {
|
it('should produce init + result events', async () => {
|
||||||
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||||
import { writeMessageOut } from './db/messages-out.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 {
|
import {
|
||||||
clearContinuation,
|
formatMessages,
|
||||||
migrateLegacyContinuation,
|
extractRouting,
|
||||||
setContinuation,
|
categorizeMessage,
|
||||||
} from './db/session-state.js';
|
isClearCommand,
|
||||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
isRunnerCommand,
|
||||||
|
stripInternalTags,
|
||||||
|
type RoutingContext,
|
||||||
|
} from './formatter.js';
|
||||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
@@ -62,9 +67,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
clearStaleProcessingAcks();
|
clearStaleProcessingAcks();
|
||||||
|
|
||||||
let pollCount = 0;
|
let pollCount = 0;
|
||||||
|
let isFirstPoll = true;
|
||||||
while (true) {
|
while (true) {
|
||||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
// 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++;
|
pollCount++;
|
||||||
|
|
||||||
// Periodic heartbeat so we know the loop is alive
|
// Periodic heartbeat so we know the loop is alive
|
||||||
@@ -170,6 +177,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
// Process the query while concurrently polling for new messages
|
// Process the query while concurrently polling for new messages
|
||||||
const skippedSet = new Set(skipped);
|
const skippedSet = new Set(skipped);
|
||||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
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 {
|
try {
|
||||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||||
if (result.continuation && result.continuation !== continuation) {
|
if (result.continuation && result.continuation !== continuation) {
|
||||||
@@ -198,6 +208,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
|||||||
thread_id: routing.threadId,
|
thread_id: routing.threadId,
|
||||||
content: JSON.stringify({ text: `Error: ${errMsg}` }),
|
content: JSON.stringify({ text: `Error: ${errMsg}` }),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
clearCurrentInReplyTo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure completed even if processQuery ended without a result event
|
// Ensure completed even if processQuery ended without a result event
|
||||||
@@ -366,6 +378,23 @@ async function processQuery(
|
|||||||
if (event.text) {
|
if (event.text) {
|
||||||
dispatchResultText(event.text, routing);
|
dispatchResultText(event.text, routing);
|
||||||
}
|
}
|
||||||
|
} else if (event.type === 'compacted') {
|
||||||
|
// The SDK auto-compacted the conversation. After compaction the
|
||||||
|
// model often drops the learned `<message to="…">` wrapping
|
||||||
|
// discipline (the destinations are still in the system prompt,
|
||||||
|
// but the behavioral pattern is summarized away). Inject a
|
||||||
|
// reminder back into the live query so the next turn re-anchors
|
||||||
|
// on the destination model. Only do this when there's >1
|
||||||
|
// destination — single-destination groups have a fallback that
|
||||||
|
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||||
|
const destinations = getAllDestinations();
|
||||||
|
if (destinations.length > 1) {
|
||||||
|
const names = destinations.map((d) => d.name).join(', ');
|
||||||
|
query.push(
|
||||||
|
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||||
|
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -385,25 +414,26 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
|||||||
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
||||||
break;
|
break;
|
||||||
case 'error':
|
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;
|
break;
|
||||||
case 'progress':
|
case 'progress':
|
||||||
log(`Progress: ${event.message}`);
|
log(`Progress: ${event.message}`);
|
||||||
break;
|
break;
|
||||||
|
case 'compacted':
|
||||||
|
log(`Compacted: ${event.text}`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the agent's final text for <message to="name">...</message> blocks
|
* Parse the agent's final text for <message to="name">...</message> blocks
|
||||||
* and dispatch each one to its resolved destination. Text outside of blocks
|
* and dispatch each one to its resolved destination. Text outside of blocks
|
||||||
* (including <internal>...</internal>) is normally scratchpad — logged but
|
* (including <internal>...</internal>) is scratchpad — logged but not sent.
|
||||||
* not sent.
|
|
||||||
*
|
*
|
||||||
* Single-destination shortcut: if the agent has exactly one configured
|
* The agent must always wrap output in <message to="name">...</message>
|
||||||
* destination AND the output contains zero <message> blocks, the entire
|
* blocks, even with a single destination. Bare text is scratchpad only.
|
||||||
* cleaned text (with <internal> tags stripped) is sent to that destination.
|
|
||||||
* This preserves the simple case of one user on one channel — the agent
|
|
||||||
* doesn't need to know about wrapping syntax at all.
|
|
||||||
*/
|
*/
|
||||||
function dispatchResultText(text: string, routing: RoutingContext): void {
|
function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||||
@@ -436,30 +466,6 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
|||||||
|
|
||||||
const scratchpad = stripInternalTags(scratchpadParts.join(''));
|
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) {
|
if (scratchpad) {
|
||||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
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 {
|
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||||
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
const platformId = dest.type === 'channel' ? dest.platformId! : dest.agentGroupId!;
|
||||||
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
const channelType = dest.type === 'channel' ? dest.channelType! : 'agent';
|
||||||
// Inherit thread_id from the inbound routing context so replies land in the
|
// Resolve thread_id per-destination from the most recent inbound message
|
||||||
// same thread the conversation is in. For non-threaded adapters the router
|
// that came from this same channel+platform. In agent-shared sessions,
|
||||||
// strips thread_id at ingest, so this will already be null.
|
// 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({
|
writeMessageOut({
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
in_reply_to: routing.inReplyTo,
|
in_reply_to: destRouting?.inReplyTo ?? routing.inReplyTo,
|
||||||
kind: 'chat',
|
kind: 'chat',
|
||||||
platform_id: platformId,
|
platform_id: platformId,
|
||||||
channel_type: channelType,
|
channel_type: channelType,
|
||||||
thread_id: routing.threadId,
|
thread_id: destRouting?.threadId ?? null,
|
||||||
content: JSON.stringify({ text: body }),
|
content: JSON.stringify({ text: body }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the thread_id and message id from the most recent inbound message
|
||||||
|
* matching the given channel+platform. Returns null if no match found.
|
||||||
|
*/
|
||||||
|
function resolveDestinationThread(
|
||||||
|
channelType: string,
|
||||||
|
platformId: string,
|
||||||
|
): { threadId: string | null; inReplyTo: string | null } | null {
|
||||||
|
try {
|
||||||
|
const db = getInboundDb();
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT thread_id, id FROM messages_in
|
||||||
|
WHERE channel_type = ? AND platform_id = ?
|
||||||
|
ORDER BY seq DESC LIMIT 1`,
|
||||||
|
)
|
||||||
|
.get(channelType, platformId) as { thread_id: string | null; id: string } | undefined;
|
||||||
|
if (row) return { threadId: row.thread_id, inReplyTo: row.id };
|
||||||
|
} catch (err) {
|
||||||
|
log(`resolveDestinationThread error: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,11 +257,15 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
private mcpServers: Record<string, McpServerConfig>;
|
private mcpServers: Record<string, McpServerConfig>;
|
||||||
private env: Record<string, string | undefined>;
|
private env: Record<string, string | undefined>;
|
||||||
private additionalDirectories?: string[];
|
private additionalDirectories?: string[];
|
||||||
|
private model?: string;
|
||||||
|
private effort?: string;
|
||||||
|
|
||||||
constructor(options: ProviderOptions = {}) {
|
constructor(options: ProviderOptions = {}) {
|
||||||
this.assistantName = options.assistantName;
|
this.assistantName = options.assistantName;
|
||||||
this.mcpServers = options.mcpServers ?? {};
|
this.mcpServers = options.mcpServers ?? {};
|
||||||
this.additionalDirectories = options.additionalDirectories;
|
this.additionalDirectories = options.additionalDirectories;
|
||||||
|
this.model = options.model;
|
||||||
|
this.effort = options.effort;
|
||||||
this.env = {
|
this.env = {
|
||||||
...(options.env ?? {}),
|
...(options.env ?? {}),
|
||||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
||||||
@@ -293,6 +297,9 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
],
|
],
|
||||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||||
env: this.env,
|
env: this.env,
|
||||||
|
model: this.model,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
effort: this.effort as any,
|
||||||
permissionMode: 'bypassPermissions',
|
permissionMode: 'bypassPermissions',
|
||||||
allowDangerouslySkipPermissions: true,
|
allowDangerouslySkipPermissions: true,
|
||||||
settingSources: ['project', 'user'],
|
settingSources: ['project', 'user'],
|
||||||
@@ -329,7 +336,7 @@ export class ClaudeProvider implements AgentProvider {
|
|||||||
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
|
||||||
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
|
||||||
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
|
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') {
|
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
|
||||||
const tn = message as { summary?: string };
|
const tn = message as { summary?: string };
|
||||||
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
yield { type: 'progress', message: tn.summary || 'Task notification' };
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ export interface ProviderOptions {
|
|||||||
mcpServers?: Record<string, McpServerConfig>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
additionalDirectories?: string[];
|
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 {
|
export interface QueryInput {
|
||||||
@@ -79,4 +89,12 @@ export type ProviderEvent =
|
|||||||
* event (tool call, thinking, partial message, anything) so the
|
* event (tool call, thinking, partial message, anything) so the
|
||||||
* poll-loop's idle timer stays honest during long tool runs.
|
* poll-loop's idle timer stays honest during long tool runs.
|
||||||
*/
|
*/
|
||||||
| { type: 'activity' };
|
| { type: 'activity' }
|
||||||
|
/**
|
||||||
|
* The provider's underlying SDK auto-compacted the conversation context.
|
||||||
|
* The poll-loop reacts by injecting a destination reminder back into
|
||||||
|
* the live query so the agent doesn't drop `<message to="…">` wrapping
|
||||||
|
* after compaction. Distinct from `result` so it doesn't mark the turn
|
||||||
|
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
|
||||||
|
*/
|
||||||
|
| { type: 'compacted'; text: string };
|
||||||
|
|||||||
85
container/skills/onecli-gateway/SKILL.md
Normal file
85
container/skills/onecli-gateway/SKILL.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
name: onecli-gateway
|
||||||
|
description: >-
|
||||||
|
OneCLI Gateway: transparent HTTPS proxy that injects stored credentials
|
||||||
|
into outbound calls. You MUST use this skill when the user asks you to
|
||||||
|
read emails, check calendar, access GitHub repos, create issues, check
|
||||||
|
Stripe payments, or interact with ANY external service or API. Do NOT
|
||||||
|
use browser extensions or OAuth CLI tools. Make HTTP requests directly;
|
||||||
|
the gateway injects credentials automatically.
|
||||||
|
compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`)
|
||||||
|
metadata:
|
||||||
|
author: onecli
|
||||||
|
version: "0.5.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# OneCLI Gateway
|
||||||
|
|
||||||
|
Your outbound HTTPS traffic is transparently proxied through the OneCLI
|
||||||
|
gateway, which injects stored credentials at the proxy boundary. You never
|
||||||
|
see or handle credential values directly.
|
||||||
|
|
||||||
|
## How to Access External Services
|
||||||
|
|
||||||
|
You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub,
|
||||||
|
Google Calendar, Google Drive, etc.) and API key services are all available
|
||||||
|
through the gateway. Just make the request directly; the gateway injects
|
||||||
|
credentials if the app is connected. If not, it returns an error with a
|
||||||
|
connect URL you can present to the user.
|
||||||
|
|
||||||
|
## Making Requests
|
||||||
|
|
||||||
|
Call the real API URL. The gateway intercepts the request and injects
|
||||||
|
credentials automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5"
|
||||||
|
curl -s "https://api.github.com/user/repos?per_page=10"
|
||||||
|
curl -s "https://api.stripe.com/v1/charges?limit=5"
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all
|
||||||
|
honor the `HTTPS_PROXY` environment variable automatically. You do not need
|
||||||
|
to set any auth headers.
|
||||||
|
|
||||||
|
## Credential Stubs for MCP Servers
|
||||||
|
|
||||||
|
Some MCP servers need local credential files to start. Stubs for connected
|
||||||
|
apps are pre-written automatically. Files containing `"onecli-managed"`
|
||||||
|
values are managed by OneCLI — do NOT modify or delete them.
|
||||||
|
|
||||||
|
If an MCP server won't start due to missing credentials, create stubs
|
||||||
|
**before** starting it. Use `"onecli-managed"` as the placeholder for all
|
||||||
|
secret values, with file permissions `0600`. See the guide at:
|
||||||
|
https://www.onecli.sh/docs/guides/credential-stubs/general-app
|
||||||
|
|
||||||
|
## When a Request Fails
|
||||||
|
|
||||||
|
If you get a 401, 403, or a gateway error (e.g., `app_not_connected`):
|
||||||
|
|
||||||
|
**Step 1 — Show the user a connect link.** Use the `connect_url` from the
|
||||||
|
error response:
|
||||||
|
|
||||||
|
> To connect [service], open this link:
|
||||||
|
> [connect_url from the error response]
|
||||||
|
|
||||||
|
If there is no `connect_url` in the error, tell the user to open the
|
||||||
|
OneCLI dashboard and connect the service there.
|
||||||
|
|
||||||
|
**Step 2 — Retry after the user connects.** Let the user know you will
|
||||||
|
retry once they have connected. When they confirm, retry the original
|
||||||
|
request. If the retry still fails, ask if they need help with the setup.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Never** say "I don't have access to X" without first making the HTTP
|
||||||
|
request through the proxy.
|
||||||
|
- **Never** use browser extensions, gcloud, or manual auth flows. The
|
||||||
|
gateway handles credentials for you.
|
||||||
|
- **Never** ask the user for API keys or tokens directly. Direct them to
|
||||||
|
connect the service in the OneCLI dashboard.
|
||||||
|
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
|
||||||
|
when they ask you to read or interact with those services. You have API
|
||||||
|
access. Use it.
|
||||||
|
- If the gateway returns a policy error (403 with a JSON body), respect
|
||||||
|
the block. Do not retry or circumvent it.
|
||||||
7
container/skills/onecli-gateway/instructions.md
Normal file
7
container/skills/onecli-gateway/instructions.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Credentials & External Services
|
||||||
|
|
||||||
|
Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service.
|
||||||
|
|
||||||
|
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
|
||||||
|
|
||||||
|
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
|
||||||
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
|
|||||||
|
|
||||||
### 1.1 `agent_groups`
|
### 1.1 `agent_groups`
|
||||||
|
|
||||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
|
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE agent_groups (
|
CREATE TABLE agent_groups (
|
||||||
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
|
|||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 1.15 `container_configs`
|
||||||
|
|
||||||
|
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE container_configs (
|
||||||
|
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT,
|
||||||
|
model TEXT,
|
||||||
|
effort TEXT,
|
||||||
|
image_tag TEXT,
|
||||||
|
assistant_name TEXT,
|
||||||
|
max_messages_per_prompt INTEGER,
|
||||||
|
skills TEXT NOT NULL DEFAULT '"all"',
|
||||||
|
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||||
|
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||||
|
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||||
|
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||||
|
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Migration system
|
## 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) |
|
| 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` |
|
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
| 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.
|
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ CREATE TABLE messages_in (
|
|||||||
recurrence TEXT, -- cron expr for recurring
|
recurrence TEXT, -- cron expr for recurring
|
||||||
series_id TEXT, -- groups occurrences of a recurring task
|
series_id TEXT, -- groups occurrences of a recurring task
|
||||||
tries INTEGER DEFAULT 0,
|
tries INTEGER DEFAULT 0,
|
||||||
|
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
|
||||||
platform_id TEXT,
|
platform_id TEXT,
|
||||||
channel_type TEXT,
|
channel_type TEXT,
|
||||||
thread_id TEXT,
|
thread_id TEXT,
|
||||||
content TEXT NOT NULL -- JSON; shape depends on kind
|
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);
|
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -242,8 +242,12 @@ fi
|
|||||||
|
|
||||||
V1_DB="$V1_PATH/store/messages.db"
|
V1_DB="$V1_PATH/store/messages.db"
|
||||||
|
|
||||||
# Quick schema check — make sure the tables we need exist
|
# Quick schema check — make sure the tables we need exist.
|
||||||
TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true)
|
# 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
|
if echo "$TABLES" | grep -q "registered_groups"; then
|
||||||
step_ok "v1 database has registered_groups"
|
step_ok "v1 database has registered_groups"
|
||||||
@@ -253,8 +257,8 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Show what we found
|
# Show what we found
|
||||||
GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 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=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 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
|
ENV_KEYS=0
|
||||||
if [ -f "$V1_PATH/.env" ]; then
|
if [ -f "$V1_PATH/.env" ]; then
|
||||||
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
|
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "nanoclaw",
|
"name": "nanoclaw",
|
||||||
"version": "2.0.33",
|
"version": "2.0.54",
|
||||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.0",
|
"packageManager": "pnpm@10.33.0",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"ncl": "bin/ncl"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
@@ -16,6 +19,7 @@
|
|||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"setup": "tsx setup/index.ts",
|
"setup": "tsx setup/index.ts",
|
||||||
"setup:auto": "tsx setup/auto.ts",
|
"setup:auto": "tsx setup/auto.ts",
|
||||||
|
"ncl": "tsx src/cli/client.ts",
|
||||||
"chat": "tsx scripts/chat.ts",
|
"chat": "tsx scripts/chat.ts",
|
||||||
"auth": "tsx src/whatsapp-auth.ts",
|
"auth": "tsx src/whatsapp-auth.ts",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/core": "^1.2.0",
|
"@clack/core": "^1.2.0",
|
||||||
"@clack/prompts": "^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",
|
"better-sqlite3": "11.10.0",
|
||||||
"chat": "^4.24.0",
|
"chat": "^4.24.0",
|
||||||
"cron-parser": "5.5.0",
|
"cron-parser": "5.5.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -15,8 +15,8 @@ importers:
|
|||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
'@onecli-sh/sdk':
|
'@onecli-sh/sdk':
|
||||||
specifier: ^0.3.1
|
specifier: ^0.5.0
|
||||||
version: 0.3.1
|
version: 0.5.0
|
||||||
better-sqlite3:
|
better-sqlite3:
|
||||||
specifier: 11.10.0
|
specifier: 11.10.0
|
||||||
version: 11.10.0
|
version: 11.10.0
|
||||||
@@ -303,8 +303,8 @@ packages:
|
|||||||
'@emnapi/core': ^1.7.1
|
'@emnapi/core': ^1.7.1
|
||||||
'@emnapi/runtime': ^1.7.1
|
'@emnapi/runtime': ^1.7.1
|
||||||
|
|
||||||
'@onecli-sh/sdk@0.3.1':
|
'@onecli-sh/sdk@0.5.0':
|
||||||
resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==}
|
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
'@oxc-project/types@0.124.0':
|
'@oxc-project/types@0.124.0':
|
||||||
@@ -1665,7 +1665,7 @@ snapshots:
|
|||||||
'@tybys/wasm-util': 0.10.1
|
'@tybys/wasm-util': 0.10.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@onecli-sh/sdk@0.3.1': {}
|
'@onecli-sh/sdk@0.5.0': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.124.0': {}
|
'@oxc-project/types@0.124.0': {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="141k tokens, 71% of context window">
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
|
||||||
<title>141k tokens, 71% of context window</title>
|
<title>174k tokens, 87% of context window</title>
|
||||||
<linearGradient id="s" x2="0" y2="100%">
|
<linearGradient id="s" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
|
||||||
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
|
||||||
<text x="26" y="14">tokens</text>
|
<text x="26" y="14">tokens</text>
|
||||||
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">141k</text>
|
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
|
||||||
<text x="71" y="14">141k</text>
|
<text x="71" y="14">174k</text>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* nc — chat with your NanoClaw agent from the terminal.
|
* ncl — chat with your NanoClaw agent from the terminal.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* pnpm run chat <message...>
|
* pnpm run chat <message...>
|
||||||
@@ -36,7 +36,7 @@ function main(): void {
|
|||||||
const e = err as NodeJS.ErrnoException;
|
const e = err as NodeJS.ErrnoException;
|
||||||
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
|
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
|
||||||
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
|
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 {
|
} else {
|
||||||
console.error('CLI socket error:', err);
|
console.error('CLI socket error:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||||
import { upsertUser } from '../src/modules/permissions/db/users.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 { initGroupFilesystem } from '../src/group-init.js';
|
||||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||||
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
|
|||||||
granted_at: now,
|
granted_at: now,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Owner's agent group gets global CLI access
|
||||||
|
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
|
||||||
} else if (args.role === 'admin') {
|
} else if (args.role === 'admin') {
|
||||||
const alreadyAdmin = existingRoles.some(
|
const alreadyAdmin = existingRoles.some(
|
||||||
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
||||||
|
|||||||
106
scripts/q.test.ts
Normal file
106
scripts/q.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Smoke tests for the q.ts sqlite-CLI replacement wrapper.
|
||||||
|
*
|
||||||
|
* Verifies the two modes (SELECT prints rows in sqlite3 default "list"
|
||||||
|
* format; mutation runs via db.exec) and a few edge cases that real
|
||||||
|
* skill invocations rely on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const Q = path.resolve(__dirname, 'q.ts');
|
||||||
|
|
||||||
|
describe('scripts/q.ts', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let dbPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'q-test-'));
|
||||||
|
dbPath = path.join(tempDir, 'test.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE t (id INTEGER, name TEXT, note TEXT);
|
||||||
|
INSERT INTO t (id, name, note) VALUES (1, 'alice', 'hi'), (2, 'bob', NULL);
|
||||||
|
`);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function run(sql: string): { stdout: string; stderr: string; status: number } {
|
||||||
|
const r = spawnSync('pnpm', ['exec', 'tsx', Q, dbPath, sql], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
});
|
||||||
|
return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('SELECT prints pipe-separated rows in default order', () => {
|
||||||
|
const r = run('SELECT id, name FROM t ORDER BY id');
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout.trim()).toBe('1|alice\n2|bob');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SELECT renders NULL as empty string (matches sqlite3 default mode)', () => {
|
||||||
|
const r = run('SELECT id, note FROM t ORDER BY id');
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout.trim()).toBe('1|hi\n2|');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SELECT with no rows prints nothing', () => {
|
||||||
|
const r = run("SELECT id FROM t WHERE name = 'nobody'");
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('INSERT runs via db.exec and persists', () => {
|
||||||
|
const r = run("INSERT INTO t (id, name) VALUES (3, 'carol')");
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
const row = db.prepare('SELECT name FROM t WHERE id = 3').get() as { name: string };
|
||||||
|
db.close();
|
||||||
|
expect(row.name).toBe('carol');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compound mutation statements execute together', () => {
|
||||||
|
const r = run("DELETE FROM t WHERE id = 1; INSERT INTO t (id, name) VALUES (9, 'zed');");
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
const ids = (db.prepare('SELECT id FROM t ORDER BY id').all() as { id: number }[]).map(
|
||||||
|
(r) => r.id,
|
||||||
|
);
|
||||||
|
db.close();
|
||||||
|
expect(ids).toEqual([2, 9]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WITH...DELETE is treated as a mutation, not a query', () => {
|
||||||
|
const r = run("WITH stale AS (SELECT id FROM t WHERE name = 'alice') DELETE FROM t WHERE id IN (SELECT id FROM stale)");
|
||||||
|
expect(r.status).toBe(0);
|
||||||
|
expect(r.stdout).toBe('');
|
||||||
|
|
||||||
|
const db = new Database(dbPath, { readonly: true });
|
||||||
|
const rows = db.prepare('SELECT name FROM t').all() as { name: string }[];
|
||||||
|
db.close();
|
||||||
|
expect(rows).toEqual([{ name: 'bob' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exits 2 with usage when args are missing', () => {
|
||||||
|
const r = spawnSync('pnpm', ['exec', 'tsx', Q], {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
cwd: path.resolve(__dirname, '..'),
|
||||||
|
});
|
||||||
|
expect(r.status).toBe(2);
|
||||||
|
expect(r.stderr).toMatch(/Usage/);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
scripts/q.ts
Normal file
58
scripts/q.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* scripts/q.ts — sqlite3 CLI replacement for skill SQL invocations.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm exec tsx scripts/q.ts <db-path> "<sql>"
|
||||||
|
*
|
||||||
|
* Uses better-sqlite3's stmt.reader property to distinguish queries
|
||||||
|
* (SELECT / WITH...SELECT) from mutations. Queries print rows in
|
||||||
|
* sqlite3 CLI default ("list") format — pipe-separated, no header —
|
||||||
|
* so existing skill text reads identically. Mutations run via
|
||||||
|
* stmt.run() (single statement) or db.exec() (compound).
|
||||||
|
*
|
||||||
|
* Why this exists: setup/verify.ts:5 codifies that NanoClaw avoids
|
||||||
|
* depending on the sqlite3 CLI binary; setup never installs or probes
|
||||||
|
* for it. Skills that shell out to `sqlite3` therefore fail on hosts
|
||||||
|
* where it isn't preinstalled (common on fresh Ubuntu — see #2191).
|
||||||
|
* This wrapper preserves the skill-text shape (path then SQL string)
|
||||||
|
* while routing through the better-sqlite3 dep that setup already
|
||||||
|
* installs and verifies.
|
||||||
|
*/
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
const [, , dbPath, sql] = process.argv;
|
||||||
|
|
||||||
|
if (!dbPath || sql === undefined) {
|
||||||
|
console.error('Usage: pnpm exec tsx scripts/q.ts <db-path> "<sql>"');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
if (stmt.reader) {
|
||||||
|
const rows = stmt.all() as Record<string, unknown>[];
|
||||||
|
for (const row of rows) {
|
||||||
|
console.log(
|
||||||
|
Object.values(row)
|
||||||
|
.map((v) => (v === null ? '' : String(v)))
|
||||||
|
.join('|'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stmt.run();
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
// better-sqlite3 throws on compound statements ("contains more than
|
||||||
|
// one statement"). Compound SQL in skills is always mutations
|
||||||
|
// (e.g. "DELETE ...; INSERT ...;"), so fall back to db.exec().
|
||||||
|
if (e instanceof Error && /more than one statement/i.test(e.message)) {
|
||||||
|
db.exec(sql);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
|
|||||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||||
import { brightSelect } from './lib/bright-select.js';
|
import { brightSelect } from './lib/bright-select.js';
|
||||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||||
import {
|
import {
|
||||||
applyToEnv,
|
applyToEnv,
|
||||||
parseFlags,
|
parseFlags,
|
||||||
@@ -416,7 +416,7 @@ async function main(): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
phEmit('first_chat_failed', { reason: ping });
|
phEmit('first_chat_failed', { reason: ping });
|
||||||
renderPingFailureNote(ping);
|
renderPingFailureNote(ping);
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName: 'cli-agent',
|
stepName: 'cli-agent',
|
||||||
msg:
|
msg:
|
||||||
ping === 'socket_error'
|
ping === 'socket_error'
|
||||||
@@ -468,7 +468,7 @@ async function main(): Promise<void> {
|
|||||||
} else if (channelChoice === 'imessage') {
|
} else if (channelChoice === 'imessage') {
|
||||||
result = await runIMessageChannel(displayName!);
|
result = await runIMessageChannel(displayName!);
|
||||||
} else if (channelChoice === 'other') {
|
} else if (channelChoice === 'other') {
|
||||||
await askOtherChannelName();
|
result = await askOtherChannelName();
|
||||||
} else {
|
} else {
|
||||||
p.log.info(
|
p.log.info(
|
||||||
brandBody(
|
brandBody(
|
||||||
@@ -528,7 +528,7 @@ async function main(): Promise<void> {
|
|||||||
service_running: res.terminal?.fields.SERVICE === 'running',
|
service_running: res.terminal?.fields.SERVICE === 'running',
|
||||||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||||||
});
|
});
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName: 'verify',
|
stepName: 'verify',
|
||||||
msg: summary || 'Verification completed with unresolved issues.',
|
msg: summary || 'Verification completed with unresolved issues.',
|
||||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||||
@@ -740,12 +740,38 @@ async function runAuthStep(): Promise<void> {
|
|||||||
label: 'Paste an Anthropic API key',
|
label: 'Paste an Anthropic API key',
|
||||||
hint: 'pay-per-use via console.anthropic.com',
|
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);
|
setupLog.userInput('auth_method', method);
|
||||||
phEmit('auth_method_chosen', { 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') {
|
if (method === 'subscription') {
|
||||||
await runSubscriptionAuth();
|
await runSubscriptionAuth();
|
||||||
} else {
|
} else {
|
||||||
@@ -1099,10 +1125,26 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
|||||||
return choice;
|
return choice;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askOtherChannelName(): Promise<void> {
|
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
|
||||||
|
const action = ensureAnswer(
|
||||||
|
await brightSelect<'type' | 'back'>({
|
||||||
|
message: 'Which channel would you like to install?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'type',
|
||||||
|
label: 'Type the channel name',
|
||||||
|
hint: 'e.g. matrix, github, linear, webex',
|
||||||
|
},
|
||||||
|
{ value: 'back', label: '← Back to channel selection' },
|
||||||
|
],
|
||||||
|
initialValue: 'type',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (action === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
|
||||||
const answer = ensureAnswer(
|
const answer = ensureAnswer(
|
||||||
await p.text({
|
await p.text({
|
||||||
message: 'Which channel would you like to install?',
|
message: 'Channel name',
|
||||||
placeholder: 'e.g. matrix, github, linear, webex',
|
placeholder: 'e.g. matrix, github, linear, webex',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -290,7 +290,8 @@ async function askOperatorHandle(): Promise<string> {
|
|||||||
"What phone number or email do you iMessage with?",
|
"What phone number or email do you iMessage with?",
|
||||||
"That's where your assistant will send its welcome message.",
|
"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, …)'),
|
k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'),
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Your iMessage handle',
|
'Your iMessage handle',
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import k from 'kleur';
|
|||||||
import * as setupLog from '../logs.js';
|
import * as setupLog from '../logs.js';
|
||||||
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js';
|
||||||
import { brightSelect } from '../lib/bright-select.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 { isHeadless } from '../platform.js';
|
||||||
import { askOperatorRole } from '../lib/role-prompt.js';
|
import { askOperatorRole } from '../lib/role-prompt.js';
|
||||||
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js';
|
||||||
@@ -126,22 +126,31 @@ export async function runSlackChannel(displayName: string): Promise<ChannelFlowR
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
|
||||||
|
// Bright-white ANSI overrides the surrounding brand-cyan from `note()`'s
|
||||||
|
// per-line formatter so the URL stands out against the rest of the body.
|
||||||
|
const linkBlock = isHeadless()
|
||||||
|
? [`\x1b[97mGet started: ${SLACK_APPS_URL}\x1b[39m`, '']
|
||||||
|
: [];
|
||||||
|
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
"You'll create a Slack app that the assistant talks through.",
|
"You'll create a Slack app that the assistant talks through.",
|
||||||
"Free and stays inside the workspaces you pick.",
|
"Free and stays inside the workspaces you pick.",
|
||||||
'',
|
'',
|
||||||
|
...linkBlock,
|
||||||
' 1. Create a new app "From scratch", name it, pick a workspace',
|
' 1. Create a new app "From scratch", name it, pick a workspace',
|
||||||
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
' 2. OAuth & Permissions → add Bot Token Scopes:',
|
||||||
' chat:write, im:write, channels:history, groups:history,',
|
' • im:write, im:history',
|
||||||
' im:history, channels:read, groups:read, users:read,',
|
' • channels:read, channels:history',
|
||||||
' reactions:write',
|
' • groups:read, groups:history',
|
||||||
|
' • chat:write',
|
||||||
|
' • users:read',
|
||||||
|
' • reactions:write',
|
||||||
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
' 3. App Home → enable "Messages Tab" and "Allow users to send',
|
||||||
' slash commands and messages from the messages tab"',
|
' slash commands and messages from the messages tab"',
|
||||||
' 4. Basic Information → copy the "Signing Secret"',
|
' 4. Basic Information → copy the "Signing Secret"',
|
||||||
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
|
||||||
formatNoteLink(SLACK_APPS_URL),
|
].join('\n'),
|
||||||
].filter((line): line is string => line !== null).join('\n'),
|
|
||||||
'Create a Slack app',
|
'Create a Slack app',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,9 +317,9 @@ async function collectSlackUserId(): Promise<string> {
|
|||||||
[
|
[
|
||||||
"To get your Slack member ID:",
|
"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"',
|
' 2. Click "Profile"',
|
||||||
' 3. Click the three dots (⋯) → "Copy member ID"',
|
' 3. Click the three dots (⋮) → "Copy member ID"',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Find your Slack user ID',
|
'Find your Slack user ID',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,12 +95,25 @@ export async function runTeamsChannel(_displayName: string): Promise<ChannelFlow
|
|||||||
const prereqsResult = await confirmPrereqs({ collected, completed });
|
const prereqsResult = await confirmPrereqs({ collected, completed });
|
||||||
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||||
await stepPublicUrl({ collected, completed });
|
await stepPublicUrl({ collected, completed });
|
||||||
await stepAppRegistration({ collected, completed });
|
if (await stepAppRegistration({ collected, completed }) === 'back') {
|
||||||
await stepClientSecret({ collected, completed });
|
return BACK_TO_CHANNEL_SELECTION;
|
||||||
await stepAzureBot({ collected, completed });
|
}
|
||||||
await stepEnableTeamsChannel({ collected, completed });
|
if (await stepClientSecret({ collected, completed }) === 'back') {
|
||||||
|
return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
}
|
||||||
|
if (await stepAzureBot({ collected, completed }) === 'back') {
|
||||||
|
return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
}
|
||||||
|
if (await stepEnableTeamsChannel({ collected, completed }) === 'back') {
|
||||||
|
return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
}
|
||||||
const manifestResult = await stepGenerateManifest({ collected, completed });
|
const manifestResult = await stepGenerateManifest({ collected, completed });
|
||||||
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath });
|
if (
|
||||||
|
await stepSideload({ collected, completed, zipPath: manifestResult.zipPath })
|
||||||
|
=== 'back'
|
||||||
|
) {
|
||||||
|
return BACK_TO_CHANNEL_SELECTION;
|
||||||
|
}
|
||||||
|
|
||||||
await installAdapter(collected);
|
await installAdapter(collected);
|
||||||
completed.push('Adapter installed and service restarted.');
|
completed.push('Adapter installed and service restarted.');
|
||||||
@@ -229,7 +242,7 @@ async function stepPublicUrl(args: { collected: Collected; completed: string[] }
|
|||||||
async function stepAppRegistration(args: {
|
async function stepAppRegistration(args: {
|
||||||
collected: Collected;
|
collected: Collected;
|
||||||
completed: string[];
|
completed: string[];
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
`1. In ${AZURE_PORTAL_URL}, search "App registrations" → "New registration"`,
|
`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',
|
stepName: 'teams-app-registration',
|
||||||
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
stepDescription: 'registering an app in Azure and collecting App ID + tenant type',
|
||||||
reshow: () => stepAppRegistration(args),
|
reshow: () => stepAppRegistration(args),
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
if (gate === 'back') return 'back';
|
||||||
args.completed.push(
|
args.completed.push(
|
||||||
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
`App registered: ${args.collected.appId} (${args.collected.appType})`,
|
||||||
);
|
);
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function askAppType(args: {
|
async function askAppType(args: {
|
||||||
@@ -313,7 +328,7 @@ async function askAppType(args: {
|
|||||||
async function stepClientSecret(args: {
|
async function stepClientSecret(args: {
|
||||||
collected: Collected;
|
collected: Collected;
|
||||||
completed: string[];
|
completed: string[];
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
`1. In your app registration, open "Certificates & secrets"`,
|
`1. In your app registration, open "Certificates & secrets"`,
|
||||||
@@ -356,13 +371,15 @@ async function stepClientSecret(args: {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await stepGate({
|
const gate = await stepGate({
|
||||||
stepName: 'teams-client-secret',
|
stepName: 'teams-client-secret',
|
||||||
stepDescription: 'creating and copying the client secret',
|
stepDescription: 'creating and copying the client secret',
|
||||||
reshow: () => stepClientSecret(args),
|
reshow: () => stepClientSecret(args),
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
if (gate === 'back') return 'back';
|
||||||
args.completed.push('Client secret captured.');
|
args.completed.push('Client secret captured.');
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
// ─── step: Azure Bot resource ──────────────────────────────────────────
|
||||||
@@ -370,7 +387,7 @@ async function stepClientSecret(args: {
|
|||||||
async function stepAzureBot(args: {
|
async function stepAzureBot(args: {
|
||||||
collected: Collected;
|
collected: Collected;
|
||||||
completed: string[];
|
completed: string[];
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
const endpoint = `${args.collected.publicUrl}/api/webhooks/teams`;
|
||||||
const tenantFlag =
|
const tenantFlag =
|
||||||
args.collected.appType === 'SingleTenant'
|
args.collected.appType === 'SingleTenant'
|
||||||
@@ -405,14 +422,16 @@ async function stepAzureBot(args: {
|
|||||||
'Step 3 of 6 — Create Azure Bot resource',
|
'Step 3 of 6 — Create Azure Bot resource',
|
||||||
);
|
);
|
||||||
|
|
||||||
await stepGate({
|
const gate = await stepGate({
|
||||||
stepName: 'teams-azure-bot',
|
stepName: 'teams-azure-bot',
|
||||||
stepDescription:
|
stepDescription:
|
||||||
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
'creating an Azure Bot resource linked to the app registration and setting the messaging endpoint',
|
||||||
reshow: () => stepAzureBot(args),
|
reshow: () => stepAzureBot(args),
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
if (gate === 'back') return 'back';
|
||||||
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
args.completed.push('Azure Bot created; messaging endpoint configured.');
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── step: enable Teams channel ────────────────────────────────────────
|
// ─── step: enable Teams channel ────────────────────────────────────────
|
||||||
@@ -420,7 +439,7 @@ async function stepAzureBot(args: {
|
|||||||
async function stepEnableTeamsChannel(args: {
|
async function stepEnableTeamsChannel(args: {
|
||||||
collected: Collected;
|
collected: Collected;
|
||||||
completed: string[];
|
completed: string[];
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
'1. Open your Azure Bot resource → Channels',
|
'1. Open your Azure Bot resource → Channels',
|
||||||
@@ -431,13 +450,15 @@ async function stepEnableTeamsChannel(args: {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Step 4 of 6 — Enable Teams channel on the bot',
|
'Step 4 of 6 — Enable Teams channel on the bot',
|
||||||
);
|
);
|
||||||
await stepGate({
|
const gate = await stepGate({
|
||||||
stepName: 'teams-enable-channel',
|
stepName: 'teams-enable-channel',
|
||||||
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
stepDescription: 'enabling the Microsoft Teams channel on the Azure Bot resource',
|
||||||
reshow: () => stepEnableTeamsChannel(args),
|
reshow: () => stepEnableTeamsChannel(args),
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
if (gate === 'back') return 'back';
|
||||||
args.completed.push('Teams channel enabled on the bot.');
|
args.completed.push('Teams channel enabled on the bot.');
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── step: manifest zip ────────────────────────────────────────────────
|
// ─── step: manifest zip ────────────────────────────────────────────────
|
||||||
@@ -490,7 +511,7 @@ async function stepSideload(args: {
|
|||||||
collected: Collected;
|
collected: Collected;
|
||||||
completed: string[];
|
completed: string[];
|
||||||
zipPath: string;
|
zipPath: string;
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
note(
|
note(
|
||||||
[
|
[
|
||||||
'1. Open Microsoft Teams',
|
'1. Open Microsoft Teams',
|
||||||
@@ -505,13 +526,15 @@ async function stepSideload(args: {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
'Step 5 of 6 — Sideload the app into Teams',
|
'Step 5 of 6 — Sideload the app into Teams',
|
||||||
);
|
);
|
||||||
await stepGate({
|
const gate = await stepGate({
|
||||||
stepName: 'teams-sideload',
|
stepName: 'teams-sideload',
|
||||||
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
stepDescription: 'uploading the generated zip into Teams as a custom app',
|
||||||
reshow: () => stepSideload(args),
|
reshow: () => stepSideload({ ...args, zipPath: args.zipPath }),
|
||||||
args,
|
args,
|
||||||
});
|
});
|
||||||
|
if (gate === 'back') return 'back';
|
||||||
args.completed.push('App sideloaded into Teams.');
|
args.completed.push('App sideloaded into Teams.');
|
||||||
|
return 'continue';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── step: install adapter ─────────────────────────────────────────────
|
// ─── step: install adapter ─────────────────────────────────────────────
|
||||||
@@ -623,9 +646,9 @@ async function finishWithHandoff(
|
|||||||
async function stepGate(args: {
|
async function stepGate(args: {
|
||||||
stepName: string;
|
stepName: string;
|
||||||
stepDescription: string;
|
stepDescription: string;
|
||||||
reshow: () => Promise<void> | Promise<unknown>;
|
reshow: () => Promise<'continue' | 'back'>;
|
||||||
args: { collected: Collected; completed: string[] };
|
args: { collected: Collected; completed: string[] };
|
||||||
}): Promise<void> {
|
}): Promise<'continue' | 'back'> {
|
||||||
while (true) {
|
while (true) {
|
||||||
const choice = ensureAnswer(
|
const choice = ensureAnswer(
|
||||||
await brightSelect({
|
await brightSelect({
|
||||||
@@ -634,10 +657,12 @@ async function stepGate(args: {
|
|||||||
{ value: 'done', label: "Done — let's continue" },
|
{ value: 'done', label: "Done — let's continue" },
|
||||||
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
{ value: 'help', label: 'Stuck — hand me off to Claude' },
|
||||||
{ value: 'reshow', label: 'Show me the steps again' },
|
{ 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') {
|
if (choice === 'help') {
|
||||||
await offerHandoff({
|
await offerHandoff({
|
||||||
step: args.stepName,
|
step: args.stepName,
|
||||||
@@ -647,8 +672,7 @@ async function stepGate(args: {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (choice === 'reshow') {
|
if (choice === 'reshow') {
|
||||||
await args.reshow();
|
return args.reshow();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export interface AssistContext {
|
|||||||
* rather than us stuffing contents into the prompt. Keys are step names as
|
* rather than us stuffing contents into the prompt. Keys are step names as
|
||||||
* they appear in fail() calls; values are repo-relative paths.
|
* they appear in fail() calls; values are repo-relative paths.
|
||||||
*/
|
*/
|
||||||
const STEP_FILES: Record<string, string[]> = {
|
export const STEP_FILES: Record<string, string[]> = {
|
||||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||||
environment: ['setup/environment.ts'],
|
environment: ['setup/environment.ts'],
|
||||||
container: [
|
container: [
|
||||||
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
||||||
@@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||||
if (!isClaudeInstalled()) {
|
if (!isClaudeInstalled()) {
|
||||||
const install = ensureAnswer(
|
const install = ensureAnswer(
|
||||||
await p.confirm({
|
await p.confirm({
|
||||||
|
|||||||
@@ -23,10 +23,19 @@
|
|||||||
* attempting to parse it as a real answer.
|
* attempting to parse it as a real answer.
|
||||||
*/
|
*/
|
||||||
import { execSync, spawn } from 'child_process';
|
import { execSync, spawn } from 'child_process';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
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';
|
import { brandBody, note } from './theme.js';
|
||||||
|
|
||||||
export interface HandoffContext {
|
export interface HandoffContext {
|
||||||
@@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string {
|
|||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either
|
||||||
|
* the interactive failure handoff (default) or the non-interactive assist.
|
||||||
|
*
|
||||||
|
* Drop-in replacement for `offerClaudeAssist` at failure call sites.
|
||||||
|
*/
|
||||||
|
export async function offerClaudeOnFailure(
|
||||||
|
ctx: AssistContext,
|
||||||
|
projectRoot: string = process.cwd(),
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') {
|
||||||
|
return offerClaudeAssist(ctx, projectRoot);
|
||||||
|
}
|
||||||
|
return offerFailureHandoff(ctx, projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive Claude handoff for setup failures. Same role as
|
||||||
|
* `offerClaudeAssist` but spawns an interactive session instead of
|
||||||
|
* parsing a structured REASON/COMMAND response.
|
||||||
|
*
|
||||||
|
* Returns `true` if Claude was launched (the user may have fixed
|
||||||
|
* things during the session), `false` if skipped/declined/unavailable.
|
||||||
|
*/
|
||||||
|
async function offerFailureHandoff(
|
||||||
|
ctx: AssistContext,
|
||||||
|
projectRoot: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||||
|
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||||
|
|
||||||
|
const want = ensureAnswer(
|
||||||
|
await p.confirm({
|
||||||
|
message: 'Want to debug this with Claude?',
|
||||||
|
initialValue: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!want) return false;
|
||||||
|
|
||||||
|
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
|
||||||
|
|
||||||
|
note(
|
||||||
|
[
|
||||||
|
"Launching Claude to help debug this failure.",
|
||||||
|
"It has the context of what went wrong.",
|
||||||
|
"",
|
||||||
|
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
|
||||||
|
].join('\n'),
|
||||||
|
'Handing off to Claude',
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
const child = spawn(
|
||||||
|
'claude',
|
||||||
|
[
|
||||||
|
'--append-system-prompt',
|
||||||
|
systemPrompt,
|
||||||
|
'--permission-mode',
|
||||||
|
'acceptEdits',
|
||||||
|
],
|
||||||
|
{ stdio: 'inherit' },
|
||||||
|
);
|
||||||
|
child.on('close', () => {
|
||||||
|
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
child.on('error', () => {
|
||||||
|
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||||
|
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||||
|
const references = [
|
||||||
|
...BIG_PICTURE_FILES,
|
||||||
|
...stepRefs,
|
||||||
|
'logs/setup.log',
|
||||||
|
ctx.rawLogPath
|
||||||
|
? path.relative(projectRoot, ctx.rawLogPath)
|
||||||
|
: 'logs/setup-steps/',
|
||||||
|
].filter((v, i, a) => a.indexOf(v) === i);
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||||
|
'',
|
||||||
|
`Failed step: ${ctx.stepName}`,
|
||||||
|
`Error: ${ctx.msg}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||||
|
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||||
|
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||||
|
"When they're ready to resume setup, tell them to type /exit.",
|
||||||
|
'',
|
||||||
|
'Relevant files (read as needed with the Read tool):',
|
||||||
|
);
|
||||||
|
for (const f of references) lines.push(` - ${f}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import * as p from '@clack/prompts';
|
|||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import * as setupLog from '../logs.js';
|
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 { emit as phEmit } from './diagnostics.js';
|
||||||
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ export async function fail(
|
|||||||
if (hint) p.log.message(k.dim(hint));
|
if (hint) p.log.message(k.dim(hint));
|
||||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
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
|
// 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
|
// at the step that failed instead of aborting. We re-exec via spawnSync
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [
|
|||||||
surface: 'flag',
|
surface: 'flag',
|
||||||
type: 'string',
|
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 ───────────────────────────────────────────────────
|
// ─── name derivation ───────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import * as p from '@clack/prompts';
|
import * as p from '@clack/prompts';
|
||||||
import k from 'kleur';
|
import k from 'kleur';
|
||||||
|
|
||||||
import { offerClaudeAssist } from './claude-assist.js';
|
import { offerClaudeOnFailure } from './claude-handoff.js';
|
||||||
import { emit as phEmit } from './diagnostics.js';
|
import { emit as phEmit } from './diagnostics.js';
|
||||||
import type { StepResult, SpinnerLabels } from './runner.js';
|
import type { StepResult, SpinnerLabels } from './runner.js';
|
||||||
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } 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.
|
// offerClaudeAssist runs its own spinner and may propose a fix command.
|
||||||
// We don't attempt to restart the stalled build from here — if Claude
|
// We don't attempt to restart the stalled build from here — if Claude
|
||||||
// proposes a command the user accepts, they can retry setup afterwards.
|
// proposes a command the user accepts, they can retry setup afterwards.
|
||||||
await offerClaudeAssist({
|
await offerClaudeOnFailure({
|
||||||
stepName,
|
stepName,
|
||||||
msg: `The ${stepName} step has produced no output for 60 seconds.`,
|
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.',
|
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
|
||||||
|
|||||||
@@ -82,6 +82,41 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
});
|
});
|
||||||
process.exit(1);
|
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(
|
function setupLaunchd(
|
||||||
|
|||||||
78
src/backfill-container-configs.ts
Normal file
78
src/backfill-container-configs.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* One-time backfill: seed `container_configs` rows from existing
|
||||||
|
* `groups/<folder>/container.json` files and `agent_groups.agent_provider`.
|
||||||
|
*
|
||||||
|
* Runs after migrations, before channel adapters start. Idempotent — skips
|
||||||
|
* groups that already have a config row.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { GROUPS_DIR } from './config.js';
|
||||||
|
import type { McpServerConfig, AdditionalMountConfig } from './container-config.js';
|
||||||
|
import { getAllAgentGroups } from './db/agent-groups.js';
|
||||||
|
import { getContainerConfig, createContainerConfig } from './db/container-configs.js';
|
||||||
|
import { log } from './log.js';
|
||||||
|
import type { ContainerConfigRow } from './types.js';
|
||||||
|
|
||||||
|
interface LegacyContainerJson {
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
packages?: { apt?: string[]; npm?: string[] };
|
||||||
|
imageTag?: string;
|
||||||
|
additionalMounts?: AdditionalMountConfig[];
|
||||||
|
skills?: string[] | 'all';
|
||||||
|
provider?: string;
|
||||||
|
assistantName?: string;
|
||||||
|
maxMessagesPerPrompt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backfillContainerConfigs(): void {
|
||||||
|
const groups = getAllAgentGroups();
|
||||||
|
let backfilled = 0;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
// Skip if already has a config row
|
||||||
|
if (getContainerConfig(group.id)) continue;
|
||||||
|
|
||||||
|
// Read legacy container.json from disk
|
||||||
|
const filePath = path.join(GROUPS_DIR, group.folder, 'container.json');
|
||||||
|
let legacy: LegacyContainerJson = {};
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
legacy = JSON.parse(fs.readFileSync(filePath, 'utf8')) as LegacyContainerJson;
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Backfill: failed to parse container.json, using defaults', {
|
||||||
|
folder: group.folder,
|
||||||
|
err: String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB agent_provider wins over file provider (matches old cascade)
|
||||||
|
const provider = group.agent_provider || legacy.provider || null;
|
||||||
|
|
||||||
|
const row: ContainerConfigRow = {
|
||||||
|
agent_group_id: group.id,
|
||||||
|
provider,
|
||||||
|
model: null,
|
||||||
|
effort: null,
|
||||||
|
image_tag: legacy.imageTag ?? null,
|
||||||
|
assistant_name: legacy.assistantName ?? null,
|
||||||
|
max_messages_per_prompt: legacy.maxMessagesPerPrompt ?? null,
|
||||||
|
skills: JSON.stringify(legacy.skills ?? 'all'),
|
||||||
|
mcp_servers: JSON.stringify(legacy.mcpServers ?? {}),
|
||||||
|
packages_apt: JSON.stringify(legacy.packages?.apt ?? []),
|
||||||
|
packages_npm: JSON.stringify(legacy.packages?.npm ?? []),
|
||||||
|
additional_mounts: JSON.stringify(legacy.additionalMounts ?? []),
|
||||||
|
cli_scope: 'group',
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
createContainerConfig(row);
|
||||||
|
backfilled++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backfilled > 0) {
|
||||||
|
log.info('Backfilled container_configs from disk', { count: backfilled });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,8 +307,14 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
// Start local HTTP server to receive forwarded Gateway events (including interactions)
|
// Start local HTTP server to receive forwarded Gateway events (including interactions)
|
||||||
const webhookUrl = await startLocalWebhookServer(gatewayAdapter, setupConfig, config.botToken);
|
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 = () => {
|
const startGateway = () => {
|
||||||
if (gatewayAbort?.signal.aborted) return;
|
if (gatewayAbort?.signal.aborted) return;
|
||||||
|
const startedAt = Date.now();
|
||||||
// Capture the long-running listener promise via waitUntil
|
// Capture the long-running listener promise via waitUntil
|
||||||
let listenerPromise: Promise<unknown> | undefined;
|
let listenerPromise: Promise<unknown> | undefined;
|
||||||
gatewayAdapter.startGatewayListener!(
|
gatewayAdapter.startGatewayListener!(
|
||||||
@@ -323,21 +329,30 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter
|
|||||||
).then(() => {
|
).then(() => {
|
||||||
// startGatewayListener resolves immediately with a Response;
|
// startGatewayListener resolves immediately with a Response;
|
||||||
// the actual work is in the listenerPromise passed to waitUntil
|
// the actual work is in the listenerPromise passed to waitUntil
|
||||||
if (listenerPromise) {
|
if (!listenerPromise) return;
|
||||||
listenerPromise
|
const reschedule = (err?: unknown) => {
|
||||||
.then(() => {
|
if (gatewayAbort?.signal.aborted) return;
|
||||||
if (!gatewayAbort?.signal.aborted) {
|
const ranForMs = Date.now() - startedAt;
|
||||||
log.info('Gateway listener expired, restarting', { adapter: adapter.name });
|
if (ranForMs > 5 * 60 * 1000) consecutiveFailures = 0;
|
||||||
startGateway();
|
else consecutiveFailures++;
|
||||||
}
|
const delayMs = Math.min(60 * 60 * 1000, 2 ** consecutiveFailures * 1000);
|
||||||
})
|
if (err) {
|
||||||
.catch((err) => {
|
log.error('Gateway listener error, retrying', {
|
||||||
if (!gatewayAbort?.signal.aborted) {
|
adapter: adapter.name,
|
||||||
log.error('Gateway listener error, restarting in 5s', { adapter: adapter.name, err });
|
err,
|
||||||
setTimeout(startGateway, 5000);
|
consecutiveFailures,
|
||||||
}
|
delayMs,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
log.info('Gateway listener expired, restarting', {
|
||||||
|
adapter: adapter.name,
|
||||||
|
consecutiveFailures,
|
||||||
|
delayMs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
setTimeout(startGateway, delayMs);
|
||||||
|
};
|
||||||
|
listenerPromise.then(() => reschedule()).catch(reschedule);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
startGateway();
|
startGateway();
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { GROUPS_DIR } from './config.js';
|
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 { log } from './log.js';
|
||||||
import type { AgentGroup } from './types.js';
|
import type { AgentGroup } from './types.js';
|
||||||
|
|
||||||
@@ -54,7 +55,10 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Desired fragment set.
|
// Desired fragment set.
|
||||||
const config = readContainerConfig(group.folder);
|
const configRow = getContainerConfig(group.id);
|
||||||
|
const mcpServers: Record<string, McpServerConfig> = configRow
|
||||||
|
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
|
||||||
|
: {};
|
||||||
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
|
||||||
|
|
||||||
// Skill fragments — every skill that ships an `instructions.md`.
|
// 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
|
// Built-in module fragments — every MCP tool source file that ships a
|
||||||
// sibling `<name>.instructions.md`. These describe how the agent should
|
// sibling `<name>.instructions.md`. These describe how the agent should
|
||||||
// use that module's MCP tools (schedule_task, install_packages, etc.).
|
// 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);
|
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
|
||||||
if (fs.existsSync(mcpToolsHostDir)) {
|
if (fs.existsSync(mcpToolsHostDir)) {
|
||||||
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
|
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
|
||||||
const match = entry.match(/^(.+)\.instructions\.md$/);
|
const match = entry.match(/^(.+)\.instructions\.md$/);
|
||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const moduleName = match[1];
|
const moduleName = match[1];
|
||||||
|
if (moduleName === 'cli' && cliDisabled) continue;
|
||||||
desired.set(`module-${moduleName}.md`, {
|
desired.set(`module-${moduleName}.md`, {
|
||||||
type: 'symlink',
|
type: 'symlink',
|
||||||
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
|
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
|
// MCP server fragments — inline instructions from container.json for
|
||||||
// user-added external MCP servers.
|
// 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) {
|
if (mcp.instructions) {
|
||||||
desired.set(`mcp-${name}.md`, {
|
desired.set(`mcp-${name}.md`, {
|
||||||
type: 'inline',
|
type: 'inline',
|
||||||
|
|||||||
126
src/cli/client.ts
Normal file
126
src/cli/client.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* `ncl` binary entry point.
|
||||||
|
*
|
||||||
|
* Parses argv, builds a request frame, sends it via the picked transport,
|
||||||
|
* formats the response, exits non-zero on error.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ncl <resource> <verb> [target] [--key value ...] [--json]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* ncl groups list
|
||||||
|
* ncl groups get abc123
|
||||||
|
* ncl groups create --name foo --folder bar
|
||||||
|
* ncl groups update abc123 --name baz
|
||||||
|
* ncl help
|
||||||
|
* ncl groups help
|
||||||
|
*/
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
import { formatResponse } from './format.js';
|
||||||
|
import type { RequestFrame } from './frame.js';
|
||||||
|
import { SocketTransport } from './socket-client.js';
|
||||||
|
import type { Transport } from './transport.js';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||||
|
printUsage();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { command, args, json } = parseArgv(argv);
|
||||||
|
const req: RequestFrame = { id: randomUUID(), command, args };
|
||||||
|
const transport: Transport = pickTransport();
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await transport.sendFrame(req);
|
||||||
|
} catch (e) {
|
||||||
|
process.stderr.write(formatTransportError(e));
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(formatResponse(res, json ? 'json' : 'human'));
|
||||||
|
process.exit(res.ok ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickTransport(): Transport {
|
||||||
|
return new SocketTransport();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgv(argv: string[]): {
|
||||||
|
command: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
json: boolean;
|
||||||
|
} {
|
||||||
|
const positional: string[] = [];
|
||||||
|
const args: Record<string, unknown> = {};
|
||||||
|
let json = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i];
|
||||||
|
if (a === '--json') {
|
||||||
|
json = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (a.startsWith('--')) {
|
||||||
|
const key = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (next === undefined || next.startsWith('--')) {
|
||||||
|
args[key] = true;
|
||||||
|
} else {
|
||||||
|
args[key] = next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
positional.push(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (positional.length === 0) {
|
||||||
|
process.stderr.write('ncl: missing command\n');
|
||||||
|
printUsage();
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Join all positionals with dashes to form the command name.
|
||||||
|
// If the full name isn't a command, the dispatcher will try trimming
|
||||||
|
// the last segment and using it as the target ID (e.g. `groups get abc`
|
||||||
|
// → command "groups-get", id "abc").
|
||||||
|
const command = positional.join('-');
|
||||||
|
|
||||||
|
return { command, args, json };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printUsage(): void {
|
||||||
|
process.stdout.write(
|
||||||
|
[
|
||||||
|
'Usage: ncl <resource> <verb> [target] [--key value ...] [--json]',
|
||||||
|
'',
|
||||||
|
'Run `ncl help` to list available resources and commands.',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTransportError(e: unknown): string {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
|
||||||
|
return [
|
||||||
|
`ncl: cannot reach NanoClaw host (${msg}).`,
|
||||||
|
`Is the host running? Start it with: pnpm run dev`,
|
||||||
|
`Or, if installed as a service:`,
|
||||||
|
` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
|
||||||
|
` Linux: systemctl --user restart nanoclaw`,
|
||||||
|
``,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
return `ncl: transport error: ${msg}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||||
|
process.exit(2);
|
||||||
|
});
|
||||||
137
src/cli/commands/help.ts
Normal file
137
src/cli/commands/help.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* Built-in help command. Introspects the resource and command registries.
|
||||||
|
*
|
||||||
|
* ncl help — list all resources and commands
|
||||||
|
* ncl groups help — show group resource details (verbs, columns, enums)
|
||||||
|
*/
|
||||||
|
import { getContainerConfig } from '../../db/container-configs.js';
|
||||||
|
import { getResource, getResources } from '../crud.js';
|
||||||
|
import type { CallerContext } from '../frame.js';
|
||||||
|
import { listCommands, register } from '../registry.js';
|
||||||
|
|
||||||
|
const GROUP_SCOPE_RESOURCES = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||||
|
|
||||||
|
function getCliScope(ctx: CallerContext): string | undefined {
|
||||||
|
if (ctx.caller !== 'agent') return undefined;
|
||||||
|
return getContainerConfig(ctx.agentGroupId)?.cli_scope ?? 'group';
|
||||||
|
}
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'help',
|
||||||
|
description: 'List available resources and commands.',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: () => ({}),
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const cliScope = getCliScope(ctx);
|
||||||
|
let resources = getResources();
|
||||||
|
if (cliScope === 'group') {
|
||||||
|
resources = resources.filter((r) => GROUP_SCOPE_RESOURCES.has(r.plural));
|
||||||
|
}
|
||||||
|
const commands = listCommands().filter((c) => c.access !== 'hidden' && !c.resource);
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
if (cliScope === 'group') {
|
||||||
|
lines.push('CLI scope: group (--id and group args are auto-filled to your agent group)');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.length > 0) {
|
||||||
|
lines.push('Resources:');
|
||||||
|
for (const r of resources) {
|
||||||
|
const ops: string[] = [];
|
||||||
|
if (r.operations.list) ops.push('list');
|
||||||
|
if (r.operations.get) ops.push('get');
|
||||||
|
if (r.operations.create) ops.push('create');
|
||||||
|
if (r.operations.update) ops.push('update');
|
||||||
|
if (r.operations.delete) ops.push('delete');
|
||||||
|
if (r.customOperations) ops.push(...Object.keys(r.customOperations));
|
||||||
|
lines.push(` ${r.plural.padEnd(20)} ${r.description}`);
|
||||||
|
lines.push(` ${''.padEnd(20)} verbs: ${ops.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commands.length > 0) {
|
||||||
|
if (lines.length > 0) lines.push('');
|
||||||
|
lines.push('Commands:');
|
||||||
|
for (const c of commands) {
|
||||||
|
lines.push(` ${c.name.padEnd(20)} ${c.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Run `ncl <resource> help` for detailed field information.');
|
||||||
|
return lines.join('\n');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register per-resource help commands. These are registered dynamically
|
||||||
|
// after the resources barrel has been imported.
|
||||||
|
// We use a lazy approach: register a catch-all pattern isn't possible with
|
||||||
|
// the flat registry, so we register `<plural>-help` for each resource
|
||||||
|
// in a post-import hook.
|
||||||
|
export function registerResourceHelpCommands(): void {
|
||||||
|
for (const res of getResources()) {
|
||||||
|
// Skip if already registered (e.g. from a previous call)
|
||||||
|
try {
|
||||||
|
register({
|
||||||
|
name: `${res.plural}-help`,
|
||||||
|
description: `Show ${res.name} resource details.`,
|
||||||
|
access: 'open',
|
||||||
|
resource: res.plural,
|
||||||
|
parseArgs: () => ({}),
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
const cliScope = getCliScope(ctx);
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`${res.plural}: ${res.description}`);
|
||||||
|
|
||||||
|
if (cliScope === 'group' && GROUP_SCOPE_RESOURCES.has(res.plural)) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Note: --id and group args are auto-filled to your agent group. You do not need to pass them.');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Verbs
|
||||||
|
const idAutoFilled = cliScope === 'group' && (res.plural === 'groups' || res.plural === 'destinations');
|
||||||
|
const idHint = idAutoFilled ? '' : ' <id>';
|
||||||
|
const verbs: string[] = [];
|
||||||
|
if (res.operations.list) verbs.push(`list [open]`);
|
||||||
|
if (res.operations.get) verbs.push(`get${idHint} [open]`);
|
||||||
|
if (res.operations.create) verbs.push(`create [approval]`);
|
||||||
|
if (res.operations.update) verbs.push(`update${idHint} [approval]`);
|
||||||
|
if (res.operations.delete) verbs.push(`delete${idHint} [approval]`);
|
||||||
|
if (res.customOperations) {
|
||||||
|
for (const [verb, op] of Object.entries(res.customOperations)) {
|
||||||
|
verbs.push(`${verb} [${op.access}] — ${op.description}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('Verbs:');
|
||||||
|
for (const v of verbs) lines.push(` ${v}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Columns
|
||||||
|
const autoFilledFields =
|
||||||
|
cliScope === 'group' ? new Set(['id', 'agent_group_id', 'group']) : new Set<string>();
|
||||||
|
lines.push('Fields:');
|
||||||
|
for (const col of res.columns) {
|
||||||
|
const tags: string[] = [];
|
||||||
|
if (autoFilledFields.has(col.name)) tags.push('auto-filled');
|
||||||
|
if (col.generated) tags.push('auto');
|
||||||
|
if (col.required) tags.push('required');
|
||||||
|
if (col.updatable) tags.push('updatable');
|
||||||
|
if (col.default !== undefined && col.default !== null) tags.push(`default: ${col.default}`);
|
||||||
|
if (col.enum) tags.push(`values: ${col.enum.join(' | ')}`);
|
||||||
|
|
||||||
|
const flag = `--${col.name.replace(/_/g, '-')}`;
|
||||||
|
const tagStr = tags.length > 0 ? ` (${tags.join(', ')})` : '';
|
||||||
|
lines.push(` ${flag.padEnd(28)} ${col.description}${tagStr}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Already registered — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/cli/commands/index.ts
Normal file
10
src/cli/commands/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Command barrel — populates the registry before the CLI server starts.
|
||||||
|
*
|
||||||
|
* Resource definitions register their CRUD commands on import.
|
||||||
|
* Help commands are registered after resources are loaded.
|
||||||
|
*/
|
||||||
|
import '../resources/index.js';
|
||||||
|
import { registerResourceHelpCommands } from './help.js';
|
||||||
|
|
||||||
|
registerResourceHelpCommands();
|
||||||
291
src/cli/crud.ts
Normal file
291
src/cli/crud.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* CRUD registration helper.
|
||||||
|
*
|
||||||
|
* Takes a declarative resource definition (table, columns, access levels)
|
||||||
|
* and auto-registers list/get/create/update/delete commands in the CLI
|
||||||
|
* registry. Column metadata doubles as documentation — `ncl <resource> help`
|
||||||
|
* is generated from the same definitions.
|
||||||
|
*/
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
import { getDb } from '../db/connection.js';
|
||||||
|
import { register } from './registry.js';
|
||||||
|
import type { CallerContext } from './frame.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Access = 'open' | 'approval' | 'hidden';
|
||||||
|
|
||||||
|
export interface ColumnDef {
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'json';
|
||||||
|
description: string;
|
||||||
|
/** Auto-set on create — not user-provided. */
|
||||||
|
generated?: boolean;
|
||||||
|
/** Must be provided on create (ignored if generated). */
|
||||||
|
required?: boolean;
|
||||||
|
/** Can be changed via update. */
|
||||||
|
updatable?: boolean;
|
||||||
|
/** Default value on create when not provided. */
|
||||||
|
default?: unknown;
|
||||||
|
/** Allowed values (shown in help). */
|
||||||
|
enum?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomOperation {
|
||||||
|
access: Access;
|
||||||
|
description: string;
|
||||||
|
args?: ColumnDef[];
|
||||||
|
handler: (args: Record<string, unknown>, ctx: CallerContext) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceDef {
|
||||||
|
/** Singular name: 'group'. */
|
||||||
|
name: string;
|
||||||
|
/** Plural name: 'groups'. Used in command names. */
|
||||||
|
plural: string;
|
||||||
|
/** DB table name. */
|
||||||
|
table: string;
|
||||||
|
/** One-line description shown in help. */
|
||||||
|
description: string;
|
||||||
|
/** Primary key column name. */
|
||||||
|
idColumn: string;
|
||||||
|
columns: ColumnDef[];
|
||||||
|
/** Which standard CRUD operations are enabled. */
|
||||||
|
operations: {
|
||||||
|
list?: Access;
|
||||||
|
get?: Access;
|
||||||
|
create?: Access;
|
||||||
|
update?: Access;
|
||||||
|
delete?: Access;
|
||||||
|
};
|
||||||
|
/** Non-standard verbs (grant, revoke, add, remove, restart, etc.). */
|
||||||
|
customOperations?: Record<string, CustomOperation>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Resource registry (for help introspection)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const resources = new Map<string, ResourceDef>();
|
||||||
|
|
||||||
|
export function getResources(): ResourceDef[] {
|
||||||
|
return [...resources.values()].sort((a, b) => a.plural.localeCompare(b.plural));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResource(plural: string): ResourceDef | undefined {
|
||||||
|
return resources.get(plural);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic SQL handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function visibleColumns(def: ResourceDef): string[] {
|
||||||
|
return def.columns.map((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericList(def: ResourceDef) {
|
||||||
|
const cols = visibleColumns(def).join(', ');
|
||||||
|
const filterableNames = new Set(def.columns.filter((c) => !c.generated).map((c) => c.name));
|
||||||
|
return async (args: Record<string, unknown>) => {
|
||||||
|
const limit = args.limit !== undefined ? Math.max(1, Number(args.limit)) : 200;
|
||||||
|
const filters: string[] = [];
|
||||||
|
const params: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(args)) {
|
||||||
|
if (k === 'id' || k === 'limit') continue;
|
||||||
|
if (filterableNames.has(k)) {
|
||||||
|
filters.push(`${k} = ?`);
|
||||||
|
params.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const where = filters.length > 0 ? ` WHERE ${filters.join(' AND ')}` : '';
|
||||||
|
params.push(limit);
|
||||||
|
return getDb()
|
||||||
|
.prepare(`SELECT ${cols} FROM ${def.table}${where} LIMIT ?`)
|
||||||
|
.all(...params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericGet(def: ResourceDef) {
|
||||||
|
const cols = visibleColumns(def).join(', ');
|
||||||
|
return async (args: Record<string, unknown>) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error(`${def.name} id is required`);
|
||||||
|
const row = getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
|
||||||
|
if (!row) throw new Error(`${def.name} not found: ${id}`);
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericCreate(def: ResourceDef) {
|
||||||
|
return async (args: Record<string, unknown>) => {
|
||||||
|
const values: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const col of def.columns) {
|
||||||
|
if (col.generated) {
|
||||||
|
if (col.name === def.idColumn) {
|
||||||
|
values[col.name] = randomUUID();
|
||||||
|
} else if (col.name.endsWith('_at')) {
|
||||||
|
values[col.name] = new Date().toISOString();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = args[col.name];
|
||||||
|
if (v !== undefined) {
|
||||||
|
if (col.enum && !col.enum.includes(String(v))) {
|
||||||
|
throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`);
|
||||||
|
}
|
||||||
|
values[col.name] = col.type === 'number' ? Number(v) : v;
|
||||||
|
} else if (col.required) {
|
||||||
|
throw new Error(`--${col.name.replace(/_/g, '-')} is required`);
|
||||||
|
} else if (col.default !== undefined) {
|
||||||
|
values[col.name] = col.default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colNames = Object.keys(values);
|
||||||
|
const placeholders = colNames.map((c) => `@${c}`);
|
||||||
|
getDb()
|
||||||
|
.prepare(`INSERT INTO ${def.table} (${colNames.join(', ')}) VALUES (${placeholders.join(', ')})`)
|
||||||
|
.run(values);
|
||||||
|
return values;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericUpdate(def: ResourceDef) {
|
||||||
|
const updatableCols = def.columns.filter((c) => c.updatable);
|
||||||
|
return async (args: Record<string, unknown>) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error(`${def.name} id is required`);
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
for (const col of updatableCols) {
|
||||||
|
const v = args[col.name];
|
||||||
|
if (v !== undefined) {
|
||||||
|
if (col.enum && !col.enum.includes(String(v))) {
|
||||||
|
throw new Error(`${col.name} must be one of: ${col.enum.join(', ')}`);
|
||||||
|
}
|
||||||
|
updates[col.name] = col.type === 'number' ? Number(v) : v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`nothing to update — provide at least one of: ${updatableCols.map((c) => '--' + c.name.replace(/_/g, '-')).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setClause = Object.keys(updates)
|
||||||
|
.map((k) => `${k} = @${k}`)
|
||||||
|
.join(', ');
|
||||||
|
const result = getDb()
|
||||||
|
.prepare(`UPDATE ${def.table} SET ${setClause} WHERE ${def.idColumn} = @_id`)
|
||||||
|
.run({ ...updates, _id: id });
|
||||||
|
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
|
||||||
|
|
||||||
|
const cols = visibleColumns(def).join(', ');
|
||||||
|
return getDb().prepare(`SELECT ${cols} FROM ${def.table} WHERE ${def.idColumn} = ?`).get(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function genericDelete(def: ResourceDef) {
|
||||||
|
return async (args: Record<string, unknown>) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error(`${def.name} id is required`);
|
||||||
|
const result = getDb().prepare(`DELETE FROM ${def.table} WHERE ${def.idColumn} = ?`).run(id);
|
||||||
|
if (result.changes === 0) throw new Error(`${def.name} not found: ${id}`);
|
||||||
|
return { deleted: id };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// parseArgs helper: normalizes --hyphen-keys to underscore_keys
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function normalizeArgs(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(raw)) {
|
||||||
|
out[k.replace(/-/g, '_')] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// registerResource
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function registerResource(def: ResourceDef): void {
|
||||||
|
resources.set(def.plural, def);
|
||||||
|
|
||||||
|
if (def.operations.list) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-list`,
|
||||||
|
description: `List all ${def.plural}.`,
|
||||||
|
access: def.operations.list,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: genericList(def),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.operations.get) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-get`,
|
||||||
|
description: `Get a ${def.name} by ID.`,
|
||||||
|
access: def.operations.get,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: genericGet(def),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.operations.create) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-create`,
|
||||||
|
description: `Create a new ${def.name}.`,
|
||||||
|
access: def.operations.create,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: genericCreate(def),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.operations.update) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-update`,
|
||||||
|
description: `Update a ${def.name}.`,
|
||||||
|
access: def.operations.update,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: genericUpdate(def),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (def.operations.delete) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-delete`,
|
||||||
|
description: `Delete a ${def.name}.`,
|
||||||
|
access: def.operations.delete,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: genericDelete(def),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom operations
|
||||||
|
if (def.customOperations) {
|
||||||
|
for (const [verb, op] of Object.entries(def.customOperations)) {
|
||||||
|
register({
|
||||||
|
name: `${def.plural}-${verb.replace(/ /g, '-')}`,
|
||||||
|
description: op.description,
|
||||||
|
access: op.access,
|
||||||
|
resource: def.plural,
|
||||||
|
parseArgs: (raw) => normalizeArgs(raw),
|
||||||
|
handler: async (args, ctx) => op.handler(args as Record<string, unknown>, ctx),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/cli/delivery-action.ts
Normal file
59
src/cli/delivery-action.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Delivery action handler for CLI requests from container agents.
|
||||||
|
*
|
||||||
|
* When an agent writes a `cli_request` system message to outbound.db,
|
||||||
|
* the delivery poll picks it up and calls this handler. We dispatch
|
||||||
|
* the command and write the response back to inbound.db.
|
||||||
|
*/
|
||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
import { registerDeliveryAction } from '../delivery.js';
|
||||||
|
import { insertMessage } from '../db/session-db.js';
|
||||||
|
import { log } from '../log.js';
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
|
import type { RequestFrame } from './frame.js';
|
||||||
|
import type { Session } from '../types.js';
|
||||||
|
|
||||||
|
registerDeliveryAction('cli_request', async (content, session, inDb) => {
|
||||||
|
const requestId = content.requestId as string;
|
||||||
|
const command = content.command as string;
|
||||||
|
const args = (content.args as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
if (!requestId || !command) {
|
||||||
|
log.warn('cli_request missing requestId or command', { sessionId: session.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req: RequestFrame = { id: requestId, command, args };
|
||||||
|
const ctx = {
|
||||||
|
caller: 'agent' as const,
|
||||||
|
sessionId: session.id,
|
||||||
|
agentGroupId: session.agent_group_id,
|
||||||
|
messagingGroupId: session.messaging_group_id ?? '',
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info('CLI request from agent', { requestId, command, sessionId: session.id });
|
||||||
|
|
||||||
|
const response = await dispatch(req, ctx);
|
||||||
|
|
||||||
|
// Write response to inbound.db so the container can read it.
|
||||||
|
// trigger=0: don't wake the agent — this is an inline response to a tool call.
|
||||||
|
insertMessage(inDb, {
|
||||||
|
id: `cli-resp-${requestId}`,
|
||||||
|
kind: 'system',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: null,
|
||||||
|
channelType: null,
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({
|
||||||
|
type: 'cli_response',
|
||||||
|
requestId,
|
||||||
|
frame: response,
|
||||||
|
}),
|
||||||
|
processAfter: null,
|
||||||
|
recurrence: null,
|
||||||
|
trigger: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.info('CLI response written', { requestId, ok: response.ok, sessionId: session.id });
|
||||||
|
});
|
||||||
405
src/cli/dispatch.test.ts
Normal file
405
src/cli/dispatch.test.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
vi.mock('../log.js', () => ({
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetContainerConfig = vi.fn();
|
||||||
|
vi.mock('../db/container-configs.js', () => ({
|
||||||
|
getContainerConfig: (...args: unknown[]) => mockGetContainerConfig(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetAgentGroup = vi.fn();
|
||||||
|
vi.mock('../db/agent-groups.js', () => ({
|
||||||
|
getAgentGroup: (...args: unknown[]) => mockGetAgentGroup(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetSession = vi.fn();
|
||||||
|
vi.mock('../db/sessions.js', () => ({
|
||||||
|
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../modules/approvals/index.js', () => ({
|
||||||
|
registerApprovalHandler: vi.fn(),
|
||||||
|
requestApproval: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Register a test command so dispatch has something to find
|
||||||
|
import { register } from './registry.js';
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'test-cmd',
|
||||||
|
description: 'test command (non-group resource)',
|
||||||
|
resource: 'test',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'groups-test',
|
||||||
|
description: 'test command (groups resource)',
|
||||||
|
resource: 'groups',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'general-cmd',
|
||||||
|
description: 'test command (no resource, like help)',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'sessions-list',
|
||||||
|
description: 'test command (sessions resource)',
|
||||||
|
resource: 'sessions',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'destinations-list',
|
||||||
|
description: 'test command (destinations resource)',
|
||||||
|
resource: 'destinations',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'members-add',
|
||||||
|
description: 'test command (members resource)',
|
||||||
|
resource: 'members',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'wirings-list',
|
||||||
|
description: 'test command (wirings resource — not allowed)',
|
||||||
|
resource: 'wirings',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({ echo: args }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Commands that return data shaped like real resources (for post-handler filtering tests)
|
||||||
|
register({
|
||||||
|
name: 'groups-list-data',
|
||||||
|
description: 'returns mock group rows',
|
||||||
|
resource: 'groups',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async () => [
|
||||||
|
{ id: 'g1', name: 'my-group' },
|
||||||
|
{ id: 'g2', name: 'other-group' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
register({
|
||||||
|
name: 'sessions-get-data',
|
||||||
|
description: 'returns a mock session row',
|
||||||
|
resource: 'sessions',
|
||||||
|
access: 'open',
|
||||||
|
parseArgs: (raw) => raw,
|
||||||
|
handler: async (args) => ({
|
||||||
|
id: args.id,
|
||||||
|
agent_group_id: (args as Record<string, unknown>).belongs_to ?? 'g1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
|
import type { CallerContext } from './frame.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function agentCtx(overrides?: Partial<Extract<CallerContext, { caller: 'agent' }>>): CallerContext {
|
||||||
|
return {
|
||||||
|
caller: 'agent',
|
||||||
|
sessionId: 's1',
|
||||||
|
agentGroupId: 'g1',
|
||||||
|
messagingGroupId: 'mg1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('CLI scope enforcement', () => {
|
||||||
|
it('disabled: rejects all CLI requests from agent', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'disabled' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('disabled');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: auto-fills --id with caller agent group', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { foo: 'bar' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { echo: Record<string, unknown> };
|
||||||
|
expect(data.echo.id).toBe('g1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: rejects cross-group access', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'other-group' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('scoped');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: allows same-group id', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { id: 'g1' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: blocks cli_scope escalation', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { cli_scope: 'global' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('cli_scope');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: blocks cli-scope escalation (hyphenated)', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-test', args: { 'cli-scope': 'global' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: blocks non-group resources', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('test');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: allows general commands with no resource (e.g. help)', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'general-cmd', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: allows sessions, auto-fills --agent_group_id', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'sessions-list', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { echo: Record<string, unknown> };
|
||||||
|
expect(data.echo.agent_group_id).toBe('g1');
|
||||||
|
// --id should NOT be auto-filled for sessions (it's session UUID, not group)
|
||||||
|
expect(data.echo.id).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: allows destinations, auto-fills --id', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'destinations-list', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { echo: Record<string, unknown> };
|
||||||
|
expect(data.echo.id).toBe('g1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: allows members, auto-fills --group', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'members-add', args: { user: 'u1' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { echo: Record<string, unknown> };
|
||||||
|
expect(data.echo.group).toBe('g1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: blocks non-whitelisted resources (wirings)', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'wirings-list', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('wirings');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: rejects cross-group --agent_group_id', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch(
|
||||||
|
{ id: '1', command: 'sessions-list', args: { agent_group_id: 'other-group' } },
|
||||||
|
agentCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: rejects cross-group --group', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch(
|
||||||
|
{ id: '1', command: 'members-add', args: { user: 'u1', group: 'other-group' } },
|
||||||
|
agentCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global: allows cross-group access', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'other-group' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global: allows non-group resources', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global: does not auto-fill --id', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { foo: 'bar' } }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as { echo: Record<string, unknown> };
|
||||||
|
expect(data.echo.id).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to group when cli_scope is missing', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({});
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('host caller bypasses CLI scope enforcement', async () => {
|
||||||
|
// No config check should happen for host callers
|
||||||
|
const resp = await dispatch({ id: '1', command: 'test-cmd', args: { id: 'any-group' } }, { caller: 'host' });
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
expect(mockGetContainerConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Post-handler filtering ---
|
||||||
|
|
||||||
|
it('group: groups list filters out other groups', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as Array<{ id: string }>;
|
||||||
|
expect(data).toHaveLength(1);
|
||||||
|
expect(data[0].id).toBe('g1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: sessions get rejects cross-group session', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch(
|
||||||
|
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'other-group' } },
|
||||||
|
agentCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(false);
|
||||||
|
if (!resp.ok) {
|
||||||
|
expect(resp.error.code).toBe('forbidden');
|
||||||
|
expect(resp.error.message).toContain('different agent group');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('group: sessions get allows own-group session', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
|
||||||
|
|
||||||
|
const resp = await dispatch(
|
||||||
|
{ id: '1', command: 'sessions-get-data', args: { id: 's-123', belongs_to: 'g1' } },
|
||||||
|
agentCtx(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('global: no post-handler filtering', async () => {
|
||||||
|
mockGetContainerConfig.mockReturnValue({ cli_scope: 'global' });
|
||||||
|
|
||||||
|
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
|
||||||
|
|
||||||
|
expect(resp.ok).toBe(true);
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = resp.data as Array<{ id: string }>;
|
||||||
|
expect(data).toHaveLength(2); // both groups returned
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
174
src/cli/dispatch.ts
Normal file
174
src/cli/dispatch.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Transport-agnostic dispatcher. Both the socket server (host caller) and
|
||||||
|
* the per-session DB poller (container caller) call dispatch() with the
|
||||||
|
* same frame and a transport-supplied CallerContext.
|
||||||
|
*
|
||||||
|
* Approval gating for risky calls from the container is the only branch
|
||||||
|
* that differs by caller. Host callers and `open` commands run inline.
|
||||||
|
*/
|
||||||
|
import { getContainerConfig } from '../db/container-configs.js';
|
||||||
|
import { getAgentGroup } from '../db/agent-groups.js';
|
||||||
|
import { getSession } from '../db/sessions.js';
|
||||||
|
import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js';
|
||||||
|
import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js';
|
||||||
|
import { lookup } from './registry.js';
|
||||||
|
|
||||||
|
export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<ResponseFrame> {
|
||||||
|
let cmd = lookup(req.command);
|
||||||
|
|
||||||
|
// Fallback: if the full command isn't registered, trim the last
|
||||||
|
// dash-segment and treat it as the target ID. This lets clients join
|
||||||
|
// all positional args with dashes (e.g. `ncl groups get abc123`
|
||||||
|
// → command "groups-get-abc123" → trim → "groups-get" + id "abc123").
|
||||||
|
if (!cmd) {
|
||||||
|
const idx = req.command.lastIndexOf('-');
|
||||||
|
if (idx > 0) {
|
||||||
|
const shortened = req.command.slice(0, idx);
|
||||||
|
const tail = req.command.slice(idx + 1);
|
||||||
|
const fallback = lookup(shortened);
|
||||||
|
if (fallback) {
|
||||||
|
cmd = fallback;
|
||||||
|
req = { ...req, command: shortened, args: { ...req.args, id: req.args.id ?? tail } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cmd) {
|
||||||
|
return err(req.id, 'unknown-command', `no command "${req.command}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI scope enforcement for agent callers
|
||||||
|
if (ctx.caller === 'agent') {
|
||||||
|
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||||
|
const cliScope = configRow?.cli_scope ?? 'group';
|
||||||
|
|
||||||
|
if (cliScope === 'disabled') {
|
||||||
|
return err(req.id, 'forbidden', 'CLI access is disabled for this agent group.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cliScope === 'group') {
|
||||||
|
const allowed = new Set(['groups', 'sessions', 'destinations', 'members']);
|
||||||
|
// Only allow whitelisted resources and general commands (no resource, like help)
|
||||||
|
if (cmd.resource && !allowed.has(cmd.resource)) {
|
||||||
|
return err(req.id, 'forbidden', `CLI access is scoped to this agent group. Cannot access "${cmd.resource}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce group scope on all agent-group-related args.
|
||||||
|
// Different resources use different arg names for the agent group ID.
|
||||||
|
// Only check --id for resources where it IS the agent group ID.
|
||||||
|
const groupArgs = ['agent_group_id', 'group'] as const;
|
||||||
|
for (const key of groupArgs) {
|
||||||
|
if (req.args[key] && req.args[key] !== ctx.agentGroupId) {
|
||||||
|
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(cmd.resource === 'groups' || cmd.resource === 'destinations') &&
|
||||||
|
req.args.id &&
|
||||||
|
req.args.id !== ctx.agentGroupId
|
||||||
|
) {
|
||||||
|
return err(req.id, 'forbidden', 'CLI access is scoped to this agent group.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block cli_scope changes from group-scoped agents (privilege escalation)
|
||||||
|
if (req.args.cli_scope !== undefined || req.args['cli-scope'] !== undefined) {
|
||||||
|
return err(req.id, 'forbidden', 'Cannot change cli_scope from a group-scoped agent.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill agent-group-related args so the agent doesn't need
|
||||||
|
// to pass its own group ID explicitly.
|
||||||
|
const fill: Record<string, unknown> = {
|
||||||
|
agent_group_id: req.args.agent_group_id ?? ctx.agentGroupId,
|
||||||
|
group: req.args.group ?? ctx.agentGroupId,
|
||||||
|
};
|
||||||
|
// Only auto-fill --id for resources where it IS the agent group ID
|
||||||
|
// (groups, destinations). For sessions/members --id is a different key.
|
||||||
|
if (cmd.resource === 'groups' || cmd.resource === 'destinations') {
|
||||||
|
fill.id = req.args.id ?? ctx.agentGroupId;
|
||||||
|
}
|
||||||
|
req = { ...req, args: { ...req.args, ...fill } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.caller !== 'host' && cmd.access === 'approval') {
|
||||||
|
const session = getSession(ctx.sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return err(req.id, 'handler-error', 'Session not found.');
|
||||||
|
}
|
||||||
|
const agentGroup = getAgentGroup(ctx.agentGroupId);
|
||||||
|
const agentName = agentGroup?.name ?? ctx.agentGroupId;
|
||||||
|
|
||||||
|
const argSummary = Object.entries(req.args)
|
||||||
|
.map(([k, v]) => `--${k} ${v}`)
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
await requestApproval({
|
||||||
|
session,
|
||||||
|
agentName,
|
||||||
|
action: 'cli_command',
|
||||||
|
payload: { frame: { id: req.id, command: req.command, args: req.args } },
|
||||||
|
title: `CLI: ${req.command}`,
|
||||||
|
question: `Agent "${agentName}" wants to run:\n\`ncl ${req.command}${argSummary ? ' ' + argSummary : ''}\``,
|
||||||
|
});
|
||||||
|
|
||||||
|
return err(req.id, 'approval-pending', 'Approval request sent to admin. You will be notified of the result.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = cmd.parseArgs(req.args);
|
||||||
|
} catch (e) {
|
||||||
|
return err(req.id, 'invalid-args', errMsg(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let data = await cmd.handler(parsed, ctx);
|
||||||
|
|
||||||
|
// Post-handler group scope enforcement: filter/verify results belong
|
||||||
|
// to the caller's agent group. Catches leaks that pre-handler auto-fill
|
||||||
|
// can't prevent (e.g. `groups list` where the id arg is skipped by the
|
||||||
|
// generic list handler, or `sessions get` by UUID).
|
||||||
|
if (ctx.caller === 'agent' && cmd.resource) {
|
||||||
|
const configRow = getContainerConfig(ctx.agentGroupId);
|
||||||
|
if ((configRow?.cli_scope ?? 'group') === 'group') {
|
||||||
|
const groupField = cmd.resource === 'groups' ? 'id' : 'agent_group_id';
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
data = data.filter(
|
||||||
|
(row) =>
|
||||||
|
typeof row === 'object' &&
|
||||||
|
row !== null &&
|
||||||
|
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
|
||||||
|
);
|
||||||
|
} else if (data && typeof data === 'object' && groupField in (data as Record<string, unknown>)) {
|
||||||
|
if ((data as Record<string, unknown>)[groupField] !== ctx.agentGroupId) {
|
||||||
|
return err(req.id, 'forbidden', 'Resource belongs to a different agent group.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: req.id, ok: true, data };
|
||||||
|
} catch (e) {
|
||||||
|
return err(req.id, 'handler-error', errMsg(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerApprovalHandler('cli_command', async ({ session, payload, userId, notify }) => {
|
||||||
|
const frame = payload.frame as RequestFrame;
|
||||||
|
const response = await dispatch(frame, { caller: 'host' });
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2);
|
||||||
|
notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`);
|
||||||
|
} else {
|
||||||
|
notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function err(id: string, code: ErrorCode, message: string): ResponseFrame {
|
||||||
|
return { id, ok: false, error: { code, message } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMsg(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
52
src/cli/format.ts
Normal file
52
src/cli/format.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Output formatting for the `ncl` binary. Two modes:
|
||||||
|
* - human (default): a small auto-table for arrays of flat records,
|
||||||
|
* JSON.stringify for everything else, plain "error: ..." line for !ok.
|
||||||
|
* - json: the response frame, pretty-printed.
|
||||||
|
*
|
||||||
|
* The MCP / agent side will always pass --json so it parses the frame
|
||||||
|
* itself. The DB transport (when it lands) skips this layer entirely —
|
||||||
|
* the agent sees frames directly.
|
||||||
|
*/
|
||||||
|
import type { ResponseFrame } from './frame.js';
|
||||||
|
|
||||||
|
export type FormatMode = 'human' | 'json';
|
||||||
|
|
||||||
|
export function formatResponse(res: ResponseFrame, mode: FormatMode): string {
|
||||||
|
if (mode === 'json') return JSON.stringify(res, null, 2) + '\n';
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return `error (${res.error.code}): ${res.error.message}\n`;
|
||||||
|
}
|
||||||
|
return formatHuman(res.data) + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHuman(data: unknown): string {
|
||||||
|
if (data === null || data === undefined) return '';
|
||||||
|
if (typeof data === 'string') return data;
|
||||||
|
if (Array.isArray(data) && data.every(isFlatRecord)) {
|
||||||
|
return renderTable(data as Record<string, unknown>[]);
|
||||||
|
}
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFlatRecord(x: unknown): x is Record<string, unknown> {
|
||||||
|
if (!x || typeof x !== 'object') return false;
|
||||||
|
for (const v of Object.values(x as Record<string, unknown>)) {
|
||||||
|
if (v !== null && typeof v === 'object') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows: Record<string, unknown>[]): string {
|
||||||
|
if (rows.length === 0) return '(no rows)';
|
||||||
|
const cols = Object.keys(rows[0]);
|
||||||
|
const widths = cols.map((c) => Math.max(c.length, ...rows.map((r) => String(r[c] ?? '').length)));
|
||||||
|
const fmtRow = (vals: string[]): string => vals.map((v, i) => v.padEnd(widths[i])).join(' ');
|
||||||
|
const lines = [
|
||||||
|
fmtRow(cols),
|
||||||
|
fmtRow(widths.map((w) => '─'.repeat(w))),
|
||||||
|
...rows.map((r) => fmtRow(cols.map((c) => String(r[c] ?? '')))),
|
||||||
|
];
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
45
src/cli/frame.ts
Normal file
45
src/cli/frame.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Wire format shared between the socket transport (host caller) and — when
|
||||||
|
* it lands — the DB transport (container agent caller).
|
||||||
|
*
|
||||||
|
* Same JSON whether it goes over a socket as a line or sits in a
|
||||||
|
* `frame_json TEXT` column on a session DB. Caller identity is NOT carried
|
||||||
|
* in the frame — it's filled in by whichever server-side adapter received
|
||||||
|
* the bytes (see CallerContext).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type RequestFrame = {
|
||||||
|
/** Correlation key set by the client. */
|
||||||
|
id: string;
|
||||||
|
/** Registry name, e.g. "list-groups". */
|
||||||
|
command: string;
|
||||||
|
/** Command-specific. Each command's parseArgs validates. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResponseFrame =
|
||||||
|
| { id: string; ok: true; data: unknown }
|
||||||
|
| { id: string; ok: false; error: { code: ErrorCode; message: string } };
|
||||||
|
|
||||||
|
export type ErrorCode =
|
||||||
|
| 'unknown-command'
|
||||||
|
| 'invalid-args'
|
||||||
|
| 'permission-denied'
|
||||||
|
| 'forbidden'
|
||||||
|
| 'approval-pending'
|
||||||
|
| 'not-found'
|
||||||
|
| 'handler-error'
|
||||||
|
| 'transport-error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled in by the transport adapter on the server side. Handlers read
|
||||||
|
* caller identity from here, never from the frame.
|
||||||
|
*/
|
||||||
|
export type CallerContext =
|
||||||
|
| { caller: 'host' }
|
||||||
|
| {
|
||||||
|
caller: 'agent';
|
||||||
|
sessionId: string;
|
||||||
|
agentGroupId: string;
|
||||||
|
messagingGroupId: string;
|
||||||
|
};
|
||||||
38
src/cli/registry.ts
Normal file
38
src/cli/registry.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* Command registry — single source of truth for what `ncl` can do.
|
||||||
|
*
|
||||||
|
* Each command file under `commands/` calls `register()` at top level,
|
||||||
|
* and `commands/index.ts` imports them all for side effects so the
|
||||||
|
* registry is populated before the host's CLI server accepts connections.
|
||||||
|
*/
|
||||||
|
import type { CallerContext } from './frame.js';
|
||||||
|
|
||||||
|
export type Access = 'open' | 'approval' | 'hidden';
|
||||||
|
|
||||||
|
export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
access: Access;
|
||||||
|
/** Resource this command belongs to (for help grouping). */
|
||||||
|
resource?: string;
|
||||||
|
/** Validates `frame.args` and produces the typed handler input. Throws on invalid. */
|
||||||
|
parseArgs: (raw: Record<string, unknown>) => TArgs;
|
||||||
|
handler: (args: TArgs, ctx: CallerContext) => Promise<TData>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const registry = new Map<string, CommandDef>();
|
||||||
|
|
||||||
|
export function register<TArgs, TData>(def: CommandDef<TArgs, TData>): void {
|
||||||
|
if (registry.has(def.name)) {
|
||||||
|
throw new Error(`CLI command "${def.name}" already registered`);
|
||||||
|
}
|
||||||
|
registry.set(def.name, def as CommandDef);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lookup(name: string): CommandDef | undefined {
|
||||||
|
return registry.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCommands(): CommandDef[] {
|
||||||
|
return [...registry.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
53
src/cli/resources/approvals.ts
Normal file
53
src/cli/resources/approvals.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'approval',
|
||||||
|
plural: 'approvals',
|
||||||
|
table: 'pending_approvals',
|
||||||
|
description:
|
||||||
|
'Pending approval — in-flight approval cards waiting for an admin response. Created by requestApproval() (self-mod install_packages/add_mcp_server) and OneCLI credential approval flow. Rows are deleted after the admin approves/rejects or the request expires.',
|
||||||
|
idColumn: 'approval_id',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'approval_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Unique approval identifier (also used as the card questionId).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'session_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Session that requested the approval. Null for OneCLI credential approvals.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'request_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Original request identifier (OneCLI request UUID or same as approval_id).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'action',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Action type — matches the registered approval handler (e.g. install_packages, add_mcp_server, onecli_credential).',
|
||||||
|
},
|
||||||
|
{ name: 'payload', type: 'json', description: 'JSON payload carried through to the approval handler.' },
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||||
|
{ name: 'agent_group_id', type: 'string', description: 'Originating agent group.' },
|
||||||
|
{ name: 'channel_type', type: 'string', description: 'Channel the approval card was delivered on.' },
|
||||||
|
{ name: 'platform_id', type: 'string', description: 'Platform chat ID the card was delivered to.' },
|
||||||
|
{
|
||||||
|
name: 'platform_message_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Platform message ID of the delivered card (for editing on expiry).',
|
||||||
|
},
|
||||||
|
{ name: 'expires_at', type: 'string', description: 'When this approval expires (OneCLI gateway TTL).' },
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Current status.',
|
||||||
|
enum: ['pending', 'approved', 'rejected', 'expired'],
|
||||||
|
},
|
||||||
|
{ name: 'title', type: 'string', description: 'Card title shown to the admin.' },
|
||||||
|
{ name: 'options_json', type: 'json', description: 'Card button options as JSON array.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open' },
|
||||||
|
});
|
||||||
77
src/cli/resources/destinations.ts
Normal file
77
src/cli/resources/destinations.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { getDb } from '../../db/connection.js';
|
||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'destination',
|
||||||
|
plural: 'destinations',
|
||||||
|
table: 'agent_destinations',
|
||||||
|
description:
|
||||||
|
'Agent destination — per-agent routing entry and ACL. Each row authorizes an agent to send messages to a target (channel or another agent) and assigns a local name the agent uses to address it. Names are scoped to the source agent — two agents can have different local names for the same target. Created automatically when wiring channels or when agents create child agents.',
|
||||||
|
idColumn: 'agent_group_id',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'agent_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The agent that owns this destination. References agent_groups.id.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'local_name',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Name the agent uses to address this target (e.g. <message to="local_name">). Unique per agent. Lowercase, dash-separated.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'target_type',
|
||||||
|
type: 'string',
|
||||||
|
description: '"channel" for messaging group targets, "agent" for agent-to-agent targets.',
|
||||||
|
enum: ['channel', 'agent'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'target_id',
|
||||||
|
type: 'string',
|
||||||
|
description: "The target's ID — messaging_groups.id for channels, agent_groups.id for agents.",
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open' },
|
||||||
|
customOperations: {
|
||||||
|
add: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Add a destination for an agent. Use --agent-group-id, --local-name, --target-type, --target-id.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const agentGroupId = args.agent_group_id as string;
|
||||||
|
const localName = args.local_name as string;
|
||||||
|
const targetType = args.target_type as string;
|
||||||
|
const targetId = args.target_id as string;
|
||||||
|
if (!agentGroupId) throw new Error('--agent-group-id is required');
|
||||||
|
if (!localName) throw new Error('--local-name is required');
|
||||||
|
if (!targetType || !['channel', 'agent'].includes(targetType)) {
|
||||||
|
throw new Error('--target-type must be channel or agent');
|
||||||
|
}
|
||||||
|
if (!targetId) throw new Error('--target-id is required');
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||||
|
)
|
||||||
|
.run(agentGroupId, localName, targetType, targetId);
|
||||||
|
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Remove a destination from an agent. Use --agent-group-id and --local-name.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const agentGroupId = args.agent_group_id as string;
|
||||||
|
const localName = args.local_name as string;
|
||||||
|
if (!agentGroupId) throw new Error('--agent-group-id is required');
|
||||||
|
if (!localName) throw new Error('--local-name is required');
|
||||||
|
const result = getDb()
|
||||||
|
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ? AND local_name = ?')
|
||||||
|
.run(agentGroupId, localName);
|
||||||
|
if (result.changes === 0) throw new Error('destination not found');
|
||||||
|
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
28
src/cli/resources/dropped-messages.ts
Normal file
28
src/cli/resources/dropped-messages.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'dropped-message',
|
||||||
|
plural: 'dropped-messages',
|
||||||
|
table: 'unregistered_senders',
|
||||||
|
description:
|
||||||
|
"Dropped message log — tracks messages that were dropped by the router or access gate. Aggregates by (channel_type, platform_id) with a running count. Reasons include: no_agent_wired (no wiring exists), no_agent_engaged (wiring exists but engage rules didn't fire), unknown_sender_strict (sender not recognized, strict policy), unknown_sender_request_approval (sender not recognized, approval requested).",
|
||||||
|
idColumn: 'channel_type',
|
||||||
|
columns: [
|
||||||
|
{ name: 'channel_type', type: 'string', description: 'Channel adapter type of the dropped message.' },
|
||||||
|
{ name: 'platform_id', type: 'string', description: 'Platform chat ID where the message was dropped.' },
|
||||||
|
{ name: 'user_id', type: 'string', description: 'Sender user ID if resolved, null otherwise.' },
|
||||||
|
{ name: 'sender_name', type: 'string', description: 'Sender display name if available.' },
|
||||||
|
{
|
||||||
|
name: 'reason',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Why the message was dropped.',
|
||||||
|
enum: ['no_agent_wired', 'no_agent_engaged', 'unknown_sender_strict', 'unknown_sender_request_approval'],
|
||||||
|
},
|
||||||
|
{ name: 'messaging_group_id', type: 'string', description: 'Messaging group ID if resolved.' },
|
||||||
|
{ name: 'agent_group_id', type: 'string', description: 'Target agent group ID if resolved.' },
|
||||||
|
{ name: 'message_count', type: 'number', description: 'Number of dropped messages from this sender on this chat.' },
|
||||||
|
{ name: 'first_seen', type: 'string', description: 'First drop timestamp.' },
|
||||||
|
{ name: 'last_seen', type: 'string', description: 'Most recent drop timestamp.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open' },
|
||||||
|
});
|
||||||
282
src/cli/resources/groups.ts
Normal file
282
src/cli/resources/groups.ts
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import type { McpServerConfig } from '../../container-config.js';
|
||||||
|
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||||
|
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||||
|
import { getSession } from '../../db/sessions.js';
|
||||||
|
import { writeSessionMessage } from '../../session-manager.js';
|
||||||
|
import {
|
||||||
|
getContainerConfig,
|
||||||
|
updateContainerConfigScalars,
|
||||||
|
updateContainerConfigJson,
|
||||||
|
} from '../../db/container-configs.js';
|
||||||
|
import type { ContainerConfigRow } from '../../types.js';
|
||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
/** Deserialize JSON columns for display. */
|
||||||
|
function presentConfig(row: ContainerConfigRow): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
agent_group_id: row.agent_group_id,
|
||||||
|
provider: row.provider,
|
||||||
|
model: row.model,
|
||||||
|
effort: row.effort,
|
||||||
|
image_tag: row.image_tag,
|
||||||
|
assistant_name: row.assistant_name,
|
||||||
|
max_messages_per_prompt: row.max_messages_per_prompt,
|
||||||
|
skills: JSON.parse(row.skills),
|
||||||
|
mcp_servers: JSON.parse(row.mcp_servers),
|
||||||
|
packages_apt: JSON.parse(row.packages_apt),
|
||||||
|
packages_npm: JSON.parse(row.packages_npm),
|
||||||
|
additional_mounts: JSON.parse(row.additional_mounts),
|
||||||
|
cli_scope: row.cli_scope,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'group',
|
||||||
|
plural: 'groups',
|
||||||
|
table: 'agent_groups',
|
||||||
|
description:
|
||||||
|
'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.',
|
||||||
|
idColumn: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.',
|
||||||
|
required: true,
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'folder',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||||
|
customOperations: {
|
||||||
|
restart: {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Restart containers for a group. Use --id <group-id> [--rebuild] [--message <text>]. ' +
|
||||||
|
'From inside a container, --id is auto-filled and only the calling session is restarted. ' +
|
||||||
|
'--rebuild rebuilds the container image first (required for package changes). ' +
|
||||||
|
'--message sets an on-wake instruction for the fresh container to act on when it starts — ' +
|
||||||
|
'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' +
|
||||||
|
'Without --message, the container stops and only starts again on the next user message.',
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined);
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
if (args.rebuild) {
|
||||||
|
await buildAgentGroupImage(id);
|
||||||
|
}
|
||||||
|
const message = args.message as string | undefined;
|
||||||
|
|
||||||
|
// From an agent: scope to the calling session only
|
||||||
|
if (ctx.caller === 'agent') {
|
||||||
|
if (message) {
|
||||||
|
writeSessionMessage(id, ctx.sessionId, {
|
||||||
|
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'chat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: id,
|
||||||
|
channelType: 'agent',
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }),
|
||||||
|
onWake: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
killContainer(
|
||||||
|
ctx.sessionId,
|
||||||
|
'restarted via ncl',
|
||||||
|
message
|
||||||
|
? () => {
|
||||||
|
const s = getSession(ctx.sessionId);
|
||||||
|
if (s) wakeContainer(s);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
return { restarted: 1, rebuilt: !!args.rebuild };
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the host: restart all running containers in the group
|
||||||
|
const count = restartAgentGroupContainers(id, 'restarted via ncl', message);
|
||||||
|
return { restarted: count, rebuilt: !!args.rebuild };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config get': {
|
||||||
|
access: 'open',
|
||||||
|
description: 'Show the container config for a group. Use --id <group-id>.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
return presentConfig(row);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config update': {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Update container config scalar fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' +
|
||||||
|
'Use --id <group-id> and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
|
||||||
|
const updates: Partial<
|
||||||
|
Pick<
|
||||||
|
ContainerConfigRow,
|
||||||
|
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||||
|
>
|
||||||
|
> = {};
|
||||||
|
if (args.provider !== undefined) updates.provider = args.provider as string;
|
||||||
|
if (args.model !== undefined) updates.model = args.model as string;
|
||||||
|
if (args.effort !== undefined) updates.effort = args.effort as string;
|
||||||
|
if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string;
|
||||||
|
if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string;
|
||||||
|
if (args.max_messages_per_prompt !== undefined)
|
||||||
|
updates.max_messages_per_prompt = Number(args.max_messages_per_prompt);
|
||||||
|
if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) {
|
||||||
|
const scope = (args['cli-scope'] ?? args.cli_scope) as string;
|
||||||
|
if (!['disabled', 'group', 'global'].includes(scope)) {
|
||||||
|
throw new Error('--cli-scope must be one of: disabled, group, global');
|
||||||
|
}
|
||||||
|
updates.cli_scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(updates).length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContainerConfigScalars(id, updates);
|
||||||
|
|
||||||
|
const updated = getContainerConfig(id)!;
|
||||||
|
return presentConfig(updated);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config add-mcp-server': {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' +
|
||||||
|
'Use --id <group-id> --name <server-name> --command <cmd> [--args <json-array>] [--env <json-object>].',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
const name = args.name as string;
|
||||||
|
if (!name) throw new Error('--name is required');
|
||||||
|
const command = args.command as string;
|
||||||
|
if (!command) throw new Error('--command is required');
|
||||||
|
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
|
||||||
|
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
|
||||||
|
servers[name] = {
|
||||||
|
command,
|
||||||
|
args: args.args ? (JSON.parse(args.args as string) as string[]) : [],
|
||||||
|
env: args.env ? (JSON.parse(args.env as string) as Record<string, string>) : {},
|
||||||
|
};
|
||||||
|
updateContainerConfigJson(id, 'mcp_servers', servers);
|
||||||
|
|
||||||
|
return { added: name, servers };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config remove-mcp-server': {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id <group-id> --name <server-name>.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
const name = args.name as string;
|
||||||
|
if (!name) throw new Error('--name is required');
|
||||||
|
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
|
||||||
|
const servers = JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>;
|
||||||
|
if (!servers[name]) throw new Error(`MCP server "${name}" not found`);
|
||||||
|
delete servers[name];
|
||||||
|
updateContainerConfigJson(id, 'mcp_servers', servers);
|
||||||
|
|
||||||
|
return { removed: name };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config add-package': {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
|
||||||
|
const apt = args.apt as string | undefined;
|
||||||
|
const npm = args.npm as string | undefined;
|
||||||
|
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
|
||||||
|
|
||||||
|
if (apt) {
|
||||||
|
const existing = JSON.parse(row.packages_apt) as string[];
|
||||||
|
if (!existing.includes(apt)) {
|
||||||
|
existing.push(apt);
|
||||||
|
updateContainerConfigJson(id, 'packages_apt', existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (npm) {
|
||||||
|
const existing = JSON.parse(row.packages_npm) as string[];
|
||||||
|
if (!existing.includes(npm)) {
|
||||||
|
existing.push(npm);
|
||||||
|
updateContainerConfigJson(id, 'packages_npm', existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
added: { apt: apt || null, npm: npm || null },
|
||||||
|
note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'config remove-package': {
|
||||||
|
access: 'approval',
|
||||||
|
description:
|
||||||
|
'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id <group-id> and --apt <pkg> or --npm <pkg>.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const id = args.id as string;
|
||||||
|
if (!id) throw new Error('--id is required');
|
||||||
|
|
||||||
|
const row = getContainerConfig(id);
|
||||||
|
if (!row) throw new Error(`No container config for group: ${id}`);
|
||||||
|
|
||||||
|
const apt = args.apt as string | undefined;
|
||||||
|
const npm = args.npm as string | undefined;
|
||||||
|
if (!apt && !npm) throw new Error('Provide --apt <pkg> or --npm <pkg>');
|
||||||
|
|
||||||
|
if (apt) {
|
||||||
|
const existing = JSON.parse(row.packages_apt) as string[];
|
||||||
|
const filtered = existing.filter((p) => p !== apt);
|
||||||
|
updateContainerConfigJson(id, 'packages_apt', filtered);
|
||||||
|
}
|
||||||
|
if (npm) {
|
||||||
|
const existing = JSON.parse(row.packages_npm) as string[];
|
||||||
|
const filtered = existing.filter((p) => p !== npm);
|
||||||
|
updateContainerConfigJson(id, 'packages_npm', filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
removed: { apt: apt || null, npm: npm || null },
|
||||||
|
note: 'Image rebuild required for package changes to take effect.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
15
src/cli/resources/index.ts
Normal file
15
src/cli/resources/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* Resource barrel — imports each resource module for its side-effect
|
||||||
|
* `registerResource(...)` call.
|
||||||
|
*/
|
||||||
|
import './groups.js';
|
||||||
|
import './messaging-groups.js';
|
||||||
|
import './wirings.js';
|
||||||
|
import './users.js';
|
||||||
|
import './roles.js';
|
||||||
|
import './members.js';
|
||||||
|
import './destinations.js';
|
||||||
|
import './user-dms.js';
|
||||||
|
import './dropped-messages.js';
|
||||||
|
import './approvals.js';
|
||||||
|
import './sessions.js';
|
||||||
65
src/cli/resources/members.ts
Normal file
65
src/cli/resources/members.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { getDb } from '../../db/connection.js';
|
||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'member',
|
||||||
|
plural: 'members',
|
||||||
|
table: 'agent_group_members',
|
||||||
|
description:
|
||||||
|
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
|
||||||
|
idColumn: 'user_id',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'user_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The user to grant membership. Must reference an existing user (users.id).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The agent group to grant access to. Must reference an existing agent group (agent_groups.id).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'added_by',
|
||||||
|
type: 'string',
|
||||||
|
description: 'User ID of whoever added this member. Informational — not enforced.',
|
||||||
|
},
|
||||||
|
{ name: 'added_at', type: 'string', description: 'ISO 8601 timestamp of when the membership was granted.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open' },
|
||||||
|
customOperations: {
|
||||||
|
add: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Add a user as a member of an agent group. Use --user and --group.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const userId = args.user as string;
|
||||||
|
const groupId = args.group as string;
|
||||||
|
const addedBy = (args.added_by as string) ?? null;
|
||||||
|
if (!userId) throw new Error('--user is required');
|
||||||
|
if (!groupId) throw new Error('--group is required');
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at)
|
||||||
|
VALUES (?, ?, ?, datetime('now'))`,
|
||||||
|
)
|
||||||
|
.run(userId, groupId, addedBy);
|
||||||
|
return { user_id: userId, agent_group_id: groupId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
remove: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Remove a user from an agent group. Use --user and --group.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const userId = args.user as string;
|
||||||
|
const groupId = args.group as string;
|
||||||
|
if (!userId) throw new Error('--user is required');
|
||||||
|
if (!groupId) throw new Error('--group is required');
|
||||||
|
const result = getDb()
|
||||||
|
.prepare('DELETE FROM agent_group_members WHERE user_id = ? AND agent_group_id = ?')
|
||||||
|
.run(userId, groupId);
|
||||||
|
if (result.changes === 0) throw new Error('member not found');
|
||||||
|
return { removed: { user_id: userId, agent_group_id: groupId } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
58
src/cli/resources/messaging-groups.ts
Normal file
58
src/cli/resources/messaging-groups.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'messaging-group',
|
||||||
|
plural: 'messaging-groups',
|
||||||
|
table: 'messaging_groups',
|
||||||
|
description:
|
||||||
|
'Messaging group — one chat or channel on one platform (a Telegram DM, a Discord channel, a Slack thread root, an email address). Identity is the (channel_type, platform_id) pair, which must be unique.',
|
||||||
|
idColumn: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||||
|
{
|
||||||
|
name: 'channel_type',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Channel adapter type — matches the adapter registered by /add-<channel> (e.g. telegram, discord, slack, whatsapp).',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'platform_id',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Platform-specific chat ID. Format varies: Telegram chat ID, Discord channel snowflake, Slack channel ID, phone number, email address.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Display name. Often auto-populated by the channel adapter.',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'is_group',
|
||||||
|
type: 'number',
|
||||||
|
description: 'Multi-user group chat (1) or direct message (0). Affects session scoping.',
|
||||||
|
default: 0,
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unknown_sender_policy',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'What happens when an unrecognized sender posts. "strict" drops silently. "request_approval" sends an approval card to an admin. "public" allows anyone.',
|
||||||
|
enum: ['strict', 'request_approval', 'public'],
|
||||||
|
default: 'strict',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'denied_at',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Set when the owner explicitly denies registering this channel. While set, the router drops all messages silently without re-escalating. Cleared by any explicit wiring mutation.',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||||
|
});
|
||||||
67
src/cli/resources/roles.ts
Normal file
67
src/cli/resources/roles.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { getDb } from '../../db/connection.js';
|
||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'role',
|
||||||
|
plural: 'roles',
|
||||||
|
table: 'user_roles',
|
||||||
|
description:
|
||||||
|
'User role — privilege grant. "owner" is always global and has full control. "admin" can be global (agent_group_id null) or scoped to a specific agent group. Admin at a group implies membership. Approval routing prefers admins/owners reachable on the same messaging platform as the request origin (e.g. a Telegram request routes the approval card to an admin on Telegram when possible).',
|
||||||
|
idColumn: 'user_id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'user_id', type: 'string', description: 'User receiving the role. Must exist in users table.' },
|
||||||
|
{
|
||||||
|
name: 'role',
|
||||||
|
type: 'string',
|
||||||
|
description: '"owner" has full control, always global. "admin" can manage groups and approve actions.',
|
||||||
|
enum: ['owner', 'admin'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Null = global (all groups). A specific ID limits the role to that group. Owner must always be null.',
|
||||||
|
},
|
||||||
|
{ name: 'granted_by', type: 'string', description: 'Who granted this role. Informational.' },
|
||||||
|
{ name: 'granted_at', type: 'string', description: 'Auto-set.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open' },
|
||||||
|
customOperations: {
|
||||||
|
grant: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Grant a role. Use --user, --role, and optionally --group for scoped admin.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const userId = args.user as string;
|
||||||
|
const role = args.role as string;
|
||||||
|
const groupId = (args.group as string) ?? null;
|
||||||
|
const grantedBy = (args.granted_by as string) ?? null;
|
||||||
|
if (!userId) throw new Error('--user is required');
|
||||||
|
if (!role || !['owner', 'admin'].includes(role)) throw new Error('--role must be owner or admin');
|
||||||
|
if (role === 'owner' && groupId) throw new Error('owner role is always global (do not pass --group)');
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at)
|
||||||
|
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||||
|
)
|
||||||
|
.run(userId, role, groupId, grantedBy);
|
||||||
|
return { user_id: userId, role, agent_group_id: groupId };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
revoke: {
|
||||||
|
access: 'approval',
|
||||||
|
description: 'Revoke a role. Use --user, --role, and --group if scoped.',
|
||||||
|
handler: async (args) => {
|
||||||
|
const userId = args.user as string;
|
||||||
|
const role = args.role as string;
|
||||||
|
const groupId = (args.group as string) ?? null;
|
||||||
|
if (!userId) throw new Error('--user is required');
|
||||||
|
if (!role) throw new Error('--role is required');
|
||||||
|
const result = getDb()
|
||||||
|
.prepare('DELETE FROM user_roles WHERE user_id = ? AND role = ? AND agent_group_id IS ?')
|
||||||
|
.run(userId, role, groupId);
|
||||||
|
if (result.changes === 0) throw new Error('role not found');
|
||||||
|
return { revoked: { user_id: userId, role, agent_group_id: groupId } };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
45
src/cli/resources/sessions.ts
Normal file
45
src/cli/resources/sessions.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'session',
|
||||||
|
plural: 'sessions',
|
||||||
|
table: 'sessions',
|
||||||
|
description:
|
||||||
|
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
|
||||||
|
idColumn: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||||
|
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
|
||||||
|
{
|
||||||
|
name: 'messaging_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Messaging group this session serves. Null for agent-shared sessions.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'thread_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Thread ID. Only set for per-thread session mode.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent_provider',
|
||||||
|
type: 'string',
|
||||||
|
description: 'Provider override. Null means inherit from agent group.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'string',
|
||||||
|
description: '"active" receives messages. "closed" is archived.',
|
||||||
|
enum: ['active', 'closed'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'container_status',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'"running" — container alive and polling. "stopped" — container exited; the sweep will restart it automatically when due messages arrive. "idle" — reserved, currently unused.',
|
||||||
|
enum: ['running', 'idle', 'stopped'],
|
||||||
|
},
|
||||||
|
{ name: 'last_active', type: 'string', description: 'Last message or heartbeat. Used for stale detection.' },
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open' },
|
||||||
|
});
|
||||||
21
src/cli/resources/user-dms.ts
Normal file
21
src/cli/resources/user-dms.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'user-dm',
|
||||||
|
plural: 'user-dms',
|
||||||
|
table: 'user_dms',
|
||||||
|
description:
|
||||||
|
"User DM cache — maps (user, channel_type) to the messaging group used for DM delivery. Populated lazily by ensureUserDm() when the host needs to cold-DM a user (approvals, pairing). For direct-addressable channels (Telegram, WhatsApp) the handle IS the DM chat ID. For resolution-required channels (Discord, Slack) the adapter's openDM resolves it.",
|
||||||
|
idColumn: 'user_id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'user_id', type: 'string', description: 'User this DM route is for.' },
|
||||||
|
{ name: 'channel_type', type: 'string', description: 'Channel adapter type.' },
|
||||||
|
{
|
||||||
|
name: 'messaging_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The messaging group used to deliver DMs to this user on this channel.',
|
||||||
|
},
|
||||||
|
{ name: 'resolved_at', type: 'string', description: 'When this DM route was last resolved.' },
|
||||||
|
],
|
||||||
|
operations: { list: 'open' },
|
||||||
|
});
|
||||||
35
src/cli/resources/users.ts
Normal file
35
src/cli/resources/users.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'user',
|
||||||
|
plural: 'users',
|
||||||
|
table: 'users',
|
||||||
|
description:
|
||||||
|
'User — a messaging-platform identity. Each row is one sender on one channel. A single human may have multiple user rows across channels (no cross-channel linking yet).',
|
||||||
|
idColumn: 'id',
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'id',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Namespaced "channel_type:handle" — e.g. "tg:6037840640", "discord:123456789", "email:user@example.com". Must be provided on create.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'kind',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Channel type identifier (e.g. "telegram", "discord"). Used as a fallback for DM resolution when the id prefix doesn\'t match a registered adapter.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'display_name',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Human-readable name. Shown in approval cards and logs. Often auto-populated from the channel adapter.',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||||
|
});
|
||||||
70
src/cli/resources/wirings.ts
Normal file
70
src/cli/resources/wirings.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { registerResource } from '../crud.js';
|
||||||
|
|
||||||
|
registerResource({
|
||||||
|
name: 'wiring',
|
||||||
|
plural: 'wirings',
|
||||||
|
table: 'messaging_group_agents',
|
||||||
|
description:
|
||||||
|
'Wiring — connects a messaging group to an agent group. Determines which agent handles messages from which chat. The same messaging group can be wired to multiple agents; the same agent can be wired to multiple messaging groups.',
|
||||||
|
idColumn: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||||
|
{
|
||||||
|
name: 'messaging_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The chat/channel to route from. References messaging_groups.id.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'agent_group_id',
|
||||||
|
type: 'string',
|
||||||
|
description: 'The agent that handles messages. References agent_groups.id.',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'engage_mode',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'When the agent engages. "mention" — only when @mentioned or in DMs. "mention-sticky" — once mentioned in a thread, the agent subscribes and responds to all subsequent messages in that thread without needing further mentions. "pattern" — matches every message against engage_pattern regex.',
|
||||||
|
enum: ['pattern', 'mention', 'mention-sticky'],
|
||||||
|
default: 'mention',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'engage_pattern',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'Regex for engage_mode=pattern. Required when mode is pattern. Use "." to match every message (always-on). Ignored for mention modes.',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'sender_scope',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'"all" — any sender (subject to unknown_sender_policy). "known" — only users with a role or membership in this agent group.',
|
||||||
|
enum: ['all', 'known'],
|
||||||
|
default: 'all',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ignored_message_policy',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'What happens to messages that don\'t trigger engagement. "drop" — agent never sees them. "accumulate" — stored as background context (trigger=0) so the agent has prior context when eventually triggered.',
|
||||||
|
enum: ['drop', 'accumulate'],
|
||||||
|
default: 'drop',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'session_mode',
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'"shared" — one session per (agent, messaging group). "per-thread" — separate session per thread/topic. "agent-shared" — one session across all messaging groups wired to this agent. Note: threaded adapters in group chats force per-thread regardless of this setting.',
|
||||||
|
enum: ['shared', 'per-thread', 'agent-shared'],
|
||||||
|
default: 'shared',
|
||||||
|
updatable: true,
|
||||||
|
},
|
||||||
|
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||||
|
],
|
||||||
|
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||||
|
});
|
||||||
63
src/cli/socket-client.ts
Normal file
63
src/cli/socket-client.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* SocketTransport — client side. Used by the `ncl` binary when running on
|
||||||
|
* the host (i.e. invoked from a shell or by Claude in the project).
|
||||||
|
*
|
||||||
|
* Wire format: line-delimited JSON. One request per connection; the server
|
||||||
|
* writes one response and closes.
|
||||||
|
*/
|
||||||
|
import net from 'net';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { DATA_DIR } from '../config.js';
|
||||||
|
import type { RequestFrame, ResponseFrame } from './frame.js';
|
||||||
|
import type { Transport } from './transport.js';
|
||||||
|
|
||||||
|
export const DEFAULT_SOCKET_PATH = path.join(DATA_DIR, 'ncl.sock');
|
||||||
|
|
||||||
|
export class SocketTransport implements Transport {
|
||||||
|
constructor(private readonly socketPath: string = DEFAULT_SOCKET_PATH) {}
|
||||||
|
|
||||||
|
async sendFrame(req: RequestFrame): Promise<ResponseFrame> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const client = net.createConnection(this.socketPath);
|
||||||
|
let buffer = '';
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const settle = (action: 'resolve' | 'reject', valueOrErr: ResponseFrame | Error): void => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
try {
|
||||||
|
client.end();
|
||||||
|
} catch (_e) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
if (action === 'resolve') resolve(valueOrErr as ResponseFrame);
|
||||||
|
else reject(valueOrErr as Error);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
client.write(JSON.stringify(req) + '\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
const idx = buffer.indexOf('\n');
|
||||||
|
if (idx < 0) return;
|
||||||
|
const line = buffer.slice(0, idx);
|
||||||
|
try {
|
||||||
|
const frame = JSON.parse(line) as ResponseFrame;
|
||||||
|
settle('resolve', frame);
|
||||||
|
} catch (e) {
|
||||||
|
settle('reject', new Error(`malformed response from host: ${e instanceof Error ? e.message : String(e)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('error', (err) => settle('reject', err));
|
||||||
|
client.on('close', () => {
|
||||||
|
if (!settled) {
|
||||||
|
settle('reject', new Error('host closed connection before sending response'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/cli/socket-server.ts
Normal file
111
src/cli/socket-server.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Host-side socket listener. Started from src/index.ts, accepts one frame
|
||||||
|
* per connection, calls dispatch() with caller='host', writes the response
|
||||||
|
* frame, closes.
|
||||||
|
*
|
||||||
|
* Lives at data/ncl.sock (separate from data/cli.sock, which the existing
|
||||||
|
* chat-style CLI channel adapter owns). Socket file is chmod 0600 — only
|
||||||
|
* the user that started the host can connect.
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import net from 'net';
|
||||||
|
|
||||||
|
import { log } from '../log.js';
|
||||||
|
import { dispatch } from './dispatch.js';
|
||||||
|
import type { CallerContext, RequestFrame, ResponseFrame } from './frame.js';
|
||||||
|
import { DEFAULT_SOCKET_PATH } from './socket-client.js';
|
||||||
|
|
||||||
|
let server: net.Server | null = null;
|
||||||
|
|
||||||
|
export async function startCliServer(socketPath: string = DEFAULT_SOCKET_PATH): Promise<void> {
|
||||||
|
// Stale-socket cleanup — a previous run that crashed may have left the
|
||||||
|
// file behind, and net.createServer refuses to bind to an existing path.
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(socketPath);
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as NodeJS.ErrnoException;
|
||||||
|
if (e.code !== 'ENOENT') {
|
||||||
|
log.warn('Failed to unlink stale ncl socket (will try to bind anyway)', { socketPath, err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = net.createServer((conn) => handleConnection(conn));
|
||||||
|
server = s;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
s.once('error', reject);
|
||||||
|
s.listen(socketPath, () => {
|
||||||
|
try {
|
||||||
|
fs.chmodSync(socketPath, 0o600);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to chmod ncl socket (continuing)', { socketPath, err });
|
||||||
|
}
|
||||||
|
log.info('ncl CLI server listening', { socketPath });
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopCliServer(): Promise<void> {
|
||||||
|
if (!server) return;
|
||||||
|
const s = server;
|
||||||
|
server = null;
|
||||||
|
await new Promise<void>((resolve) => s.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnection(conn: net.Socket): void {
|
||||||
|
let buffer = '';
|
||||||
|
conn.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString('utf8');
|
||||||
|
let idx: number;
|
||||||
|
while ((idx = buffer.indexOf('\n')) >= 0) {
|
||||||
|
const line = buffer.slice(0, idx).trim();
|
||||||
|
buffer = buffer.slice(idx + 1);
|
||||||
|
if (!line) continue;
|
||||||
|
void handleFrame(conn, line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
conn.on('error', (err) => {
|
||||||
|
log.warn('ncl CLI server connection error', { err });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFrame(conn: net.Socket, line: string): Promise<void> {
|
||||||
|
let req: RequestFrame;
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(line);
|
||||||
|
if (!isRequestFrame(parsed)) throw new Error('bad request shape');
|
||||||
|
req = parsed;
|
||||||
|
} catch (e) {
|
||||||
|
write(conn, {
|
||||||
|
id: 'unknown',
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
code: 'transport-error',
|
||||||
|
message: `bad frame: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host caller — connecting to data/ncl.sock requires file-system access
|
||||||
|
// to a 0600 socket owned by the host user, so we treat the socket path
|
||||||
|
// itself as the auth boundary.
|
||||||
|
const ctx: CallerContext = { caller: 'host' };
|
||||||
|
const res = await dispatch(req, ctx);
|
||||||
|
write(conn, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(conn: net.Socket, frame: ResponseFrame): void {
|
||||||
|
try {
|
||||||
|
conn.write(JSON.stringify(frame) + '\n');
|
||||||
|
conn.end();
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to write ncl CLI response', { err });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRequestFrame(x: unknown): x is RequestFrame {
|
||||||
|
if (!x || typeof x !== 'object') return false;
|
||||||
|
const o = x as Record<string, unknown>;
|
||||||
|
return typeof o.id === 'string' && typeof o.command === 'string' && typeof o.args === 'object' && o.args !== null;
|
||||||
|
}
|
||||||
10
src/cli/transport.ts
Normal file
10
src/cli/transport.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Client-side transport interface. The `ncl` binary picks one of these and
|
||||||
|
* calls sendFrame; the caller doesn't know whether bytes traveled over a
|
||||||
|
* Unix socket (host) or through outbound.db / inbound.db rows (container).
|
||||||
|
*/
|
||||||
|
import type { RequestFrame, ResponseFrame } from './frame.js';
|
||||||
|
|
||||||
|
export interface Transport {
|
||||||
|
sendFrame(req: RequestFrame): Promise<ResponseFrame>;
|
||||||
|
}
|
||||||
@@ -1,26 +1,25 @@
|
|||||||
/**
|
/**
|
||||||
* Per-group container config, stored as a plain JSON file at
|
* Container config types and materialization.
|
||||||
* `groups/<folder>/container.json`. Mounted read-only inside the container
|
|
||||||
* at `/workspace/agent/container.json` — the runner reads it at startup but
|
|
||||||
* cannot modify it. Config changes go through the self-mod approval flow.
|
|
||||||
*
|
*
|
||||||
* All fields are optional — a missing file or a partial file both resolve
|
* Source of truth is the `container_configs` table in the central DB.
|
||||||
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
|
* This module provides:
|
||||||
* worth the ceremony here since there's only one writer in practice: the
|
* - Type definitions for the file shape (read by the container runner)
|
||||||
* host, from the delivery thread that processes approved system actions).
|
* - `materializeContainerJson()` — writes `groups/<folder>/container.json`
|
||||||
|
* from the DB at spawn time
|
||||||
|
* - `configFromDb()` — builds a `ContainerConfig` from a DB row + agent group
|
||||||
*/
|
*/
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { GROUPS_DIR } from './config.js';
|
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 {
|
export interface McpServerConfig {
|
||||||
command: string;
|
command: string;
|
||||||
args?: string[];
|
args?: string[];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
// Optional always-in-context guidance. When set, the host writes the
|
|
||||||
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
|
|
||||||
// into the composed CLAUDE.md.
|
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,101 +29,61 @@ export interface AdditionalMountConfig {
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape of the materialized `container.json` file read by the container runner. */
|
||||||
export interface ContainerConfig {
|
export interface ContainerConfig {
|
||||||
mcpServers: Record<string, McpServerConfig>;
|
mcpServers: Record<string, McpServerConfig>;
|
||||||
packages: { apt: string[]; npm: string[] };
|
packages: { apt: string[]; npm: string[] };
|
||||||
imageTag?: string;
|
imageTag?: string;
|
||||||
additionalMounts: AdditionalMountConfig[];
|
additionalMounts: AdditionalMountConfig[];
|
||||||
/** Which skills to enable — array of skill names or "all" (default). */
|
|
||||||
skills: string[] | 'all';
|
skills: string[] | 'all';
|
||||||
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
|
|
||||||
provider?: string;
|
provider?: string;
|
||||||
/** Agent group display name (used in transcript archiving). */
|
|
||||||
groupName?: string;
|
groupName?: string;
|
||||||
/** Assistant display name (used in system prompt / responses). */
|
|
||||||
assistantName?: string;
|
assistantName?: string;
|
||||||
/** Agent group ID — set by the host, read by the runner. */
|
|
||||||
agentGroupId?: string;
|
agentGroupId?: string;
|
||||||
/** Max messages per prompt. Falls back to code default if unset. */
|
|
||||||
maxMessagesPerPrompt?: number;
|
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 {
|
return {
|
||||||
mcpServers: {},
|
mcpServers: JSON.parse(row.mcp_servers) as Record<string, McpServerConfig>,
|
||||||
packages: { apt: [], npm: [] },
|
|
||||||
additionalMounts: [],
|
|
||||||
skills: 'all',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
*/
|
|
||||||
export function readContainerConfig(folder: string): ContainerConfig {
|
|
||||||
const p = configPath(folder);
|
|
||||||
if (!fs.existsSync(p)) return emptyConfig();
|
|
||||||
try {
|
|
||||||
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
|
|
||||||
return {
|
|
||||||
mcpServers: raw.mcpServers ?? {},
|
|
||||||
packages: {
|
packages: {
|
||||||
apt: raw.packages?.apt ?? [],
|
apt: JSON.parse(row.packages_apt) as string[],
|
||||||
npm: raw.packages?.npm ?? [],
|
npm: JSON.parse(row.packages_npm) as string[],
|
||||||
},
|
},
|
||||||
imageTag: raw.imageTag,
|
imageTag: row.image_tag ?? undefined,
|
||||||
additionalMounts: raw.additionalMounts ?? [],
|
additionalMounts: JSON.parse(row.additional_mounts) as AdditionalMountConfig[],
|
||||||
skills: raw.skills ?? 'all',
|
skills: JSON.parse(row.skills) as string[] | 'all',
|
||||||
provider: raw.provider,
|
provider: row.provider ?? undefined,
|
||||||
groupName: raw.groupName,
|
groupName: group.name,
|
||||||
assistantName: raw.assistantName,
|
assistantName: row.assistant_name ?? group.name,
|
||||||
agentGroupId: raw.agentGroupId,
|
agentGroupId: group.id,
|
||||||
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
|
maxMessagesPerPrompt: row.max_messages_per_prompt ?? undefined,
|
||||||
|
model: row.model ?? undefined,
|
||||||
|
effort: row.effort ?? undefined,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
|
|
||||||
return emptyConfig();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write the container config for a group, creating the groups/<folder>/
|
* Materialize `container.json` from the DB. Called at spawn time so the
|
||||||
* directory if necessary. Pretty-printed JSON so diffs in the activation
|
* container always sees fresh config. Returns the `ContainerConfig` for
|
||||||
* flow are reviewable.
|
* use by the caller (buildMounts, buildContainerArgs, etc.).
|
||||||
*/
|
*/
|
||||||
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
|
export function materializeContainerJson(agentGroupId: string): ContainerConfig {
|
||||||
const p = configPath(folder);
|
const group = getAgentGroup(agentGroupId);
|
||||||
|
if (!group) throw new Error(`Agent group not found: ${agentGroupId}`);
|
||||||
|
|
||||||
|
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);
|
const dir = path.dirname(p);
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
|
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;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
151
src/container-restart.test.ts
Normal file
151
src/container-restart.test.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// --- Mocks ---
|
||||||
|
|
||||||
|
vi.mock('./log.js', () => ({
|
||||||
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsContainerRunning = vi.fn<(id: string) => boolean>();
|
||||||
|
const mockKillContainer = vi.fn<(id: string, reason: string, onExit?: () => void) => void>();
|
||||||
|
const mockWakeContainer = vi.fn();
|
||||||
|
vi.mock('./container-runner.js', () => ({
|
||||||
|
isContainerRunning: (...args: unknown[]) => mockIsContainerRunning(args[0] as string),
|
||||||
|
killContainer: (...args: unknown[]) =>
|
||||||
|
mockKillContainer(args[0] as string, args[1] as string, args[2] as (() => void) | undefined),
|
||||||
|
wakeContainer: (...args: unknown[]) => mockWakeContainer(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetSessionsByAgentGroup = vi.fn();
|
||||||
|
const mockGetSession = vi.fn();
|
||||||
|
vi.mock('./db/sessions.js', () => ({
|
||||||
|
getSessionsByAgentGroup: (...args: unknown[]) => mockGetSessionsByAgentGroup(...args),
|
||||||
|
getSession: (...args: unknown[]) => mockGetSession(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockWriteSessionMessage = vi.fn();
|
||||||
|
vi.mock('./session-manager.js', () => ({
|
||||||
|
writeSessionMessage: (...args: unknown[]) => mockWriteSessionMessage(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { restartAgentGroupContainers } from './container-restart.js';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function makeSession(id: string, agentGroupId: string, status = 'active') {
|
||||||
|
return { id, agent_group_id: agentGroupId, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tests ---
|
||||||
|
|
||||||
|
describe('restartAgentGroupContainers', () => {
|
||||||
|
it('skips sessions without a running container', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(false);
|
||||||
|
|
||||||
|
const count = restartAgentGroupContainers('g1', 'test');
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(mockKillContainer).not.toHaveBeenCalled();
|
||||||
|
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips non-active sessions', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1', 'closed')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
|
||||||
|
const count = restartAgentGroupContainers('g1', 'test');
|
||||||
|
|
||||||
|
expect(count).toBe(0);
|
||||||
|
expect(mockKillContainer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kills running containers and returns count', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockImplementation((id) => id === 's1');
|
||||||
|
|
||||||
|
const count = restartAgentGroupContainers('g1', 'test');
|
||||||
|
|
||||||
|
expect(count).toBe(1);
|
||||||
|
expect(mockKillContainer).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not write wake message when wakeMessage is omitted', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
|
||||||
|
restartAgentGroupContainers('g1', 'test');
|
||||||
|
|
||||||
|
expect(mockWriteSessionMessage).not.toHaveBeenCalled();
|
||||||
|
expect(mockKillContainer).toHaveBeenCalledWith('s1', 'test', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes on_wake message and passes onExit callback when wakeMessage is provided', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
|
||||||
|
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||||
|
|
||||||
|
// Should write an on-wake message
|
||||||
|
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(1);
|
||||||
|
const [agentGroupId, sessionId, msg] = mockWriteSessionMessage.mock.calls[0];
|
||||||
|
expect(agentGroupId).toBe('g1');
|
||||||
|
expect(sessionId).toBe('s1');
|
||||||
|
expect(msg.onWake).toBe(1);
|
||||||
|
expect(JSON.parse(msg.content).text).toBe('Resuming.');
|
||||||
|
|
||||||
|
// Should pass an onExit callback to killContainer
|
||||||
|
expect(mockKillContainer).toHaveBeenCalledTimes(1);
|
||||||
|
const onExit = mockKillContainer.mock.calls[0][2];
|
||||||
|
expect(typeof onExit).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onExit callback calls wakeContainer with refreshed session', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
const freshSession = makeSession('s1', 'g1');
|
||||||
|
mockGetSession.mockReturnValue(freshSession);
|
||||||
|
|
||||||
|
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||||
|
|
||||||
|
// Simulate container exit by calling the onExit callback
|
||||||
|
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
|
||||||
|
onExit();
|
||||||
|
|
||||||
|
expect(mockGetSession).toHaveBeenCalledWith('s1');
|
||||||
|
expect(mockWakeContainer).toHaveBeenCalledWith(freshSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('onExit callback does not wake if session no longer exists', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
mockGetSession.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
restartAgentGroupContainers('g1', 'test', 'Resuming.');
|
||||||
|
|
||||||
|
const onExit = mockKillContainer.mock.calls[0][2] as () => void;
|
||||||
|
onExit();
|
||||||
|
|
||||||
|
expect(mockWakeContainer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles multiple running sessions with wake message', () => {
|
||||||
|
mockGetSessionsByAgentGroup.mockReturnValue([makeSession('s1', 'g1'), makeSession('s2', 'g1')]);
|
||||||
|
mockIsContainerRunning.mockReturnValue(true);
|
||||||
|
|
||||||
|
const count = restartAgentGroupContainers('g1', 'test', 'Config updated.');
|
||||||
|
|
||||||
|
expect(count).toBe(2);
|
||||||
|
expect(mockKillContainer).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockWriteSessionMessage).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// Each session gets its own on-wake message
|
||||||
|
expect(mockWriteSessionMessage.mock.calls[0][1]).toBe('s1');
|
||||||
|
expect(mockWriteSessionMessage.mock.calls[1][1]).toBe('s2');
|
||||||
|
});
|
||||||
|
});
|
||||||
59
src/container-restart.ts
Normal file
59
src/container-restart.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Helper to restart all running containers for an agent group.
|
||||||
|
*
|
||||||
|
* Writes an on_wake message to each session, kills the container, then
|
||||||
|
* wakes a fresh container via the onExit callback — race-free.
|
||||||
|
*/
|
||||||
|
import { isContainerRunning, killContainer, wakeContainer } from './container-runner.js';
|
||||||
|
import { getSession, getSessionsByAgentGroup } from './db/sessions.js';
|
||||||
|
import { log } from './log.js';
|
||||||
|
import { writeSessionMessage } from './session-manager.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill all running containers for an agent group and respawn them.
|
||||||
|
*
|
||||||
|
* Only targets sessions that actually have a running container.
|
||||||
|
* If `wakeMessage` is provided, each session gets an on_wake message
|
||||||
|
* (picked up only by the fresh container's first poll) and a
|
||||||
|
* wakeContainer call on exit. Without it, containers are killed and
|
||||||
|
* only come back on the next real user message.
|
||||||
|
*/
|
||||||
|
export function restartAgentGroupContainers(agentGroupId: string, reason: string, wakeMessage?: string): number {
|
||||||
|
const sessions = getSessionsByAgentGroup(agentGroupId).filter(
|
||||||
|
(s) => s.status === 'active' && isContainerRunning(s.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (wakeMessage) {
|
||||||
|
writeSessionMessage(agentGroupId, session.id, {
|
||||||
|
id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
kind: 'chat',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
platformId: agentGroupId,
|
||||||
|
channelType: 'agent',
|
||||||
|
threadId: null,
|
||||||
|
content: JSON.stringify({
|
||||||
|
text: wakeMessage,
|
||||||
|
sender: 'system',
|
||||||
|
senderId: 'system',
|
||||||
|
}),
|
||||||
|
onWake: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
killContainer(
|
||||||
|
session.id,
|
||||||
|
reason,
|
||||||
|
wakeMessage
|
||||||
|
? () => {
|
||||||
|
const s = getSession(session.id);
|
||||||
|
if (s) wakeContainer(s);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessions.length > 0) {
|
||||||
|
log.info('Restarting agent group containers', { agentGroupId, reason, count: sessions.length });
|
||||||
|
}
|
||||||
|
return sessions.length;
|
||||||
|
}
|
||||||
@@ -3,30 +3,25 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { resolveProviderName } from './container-runner.js';
|
import { resolveProviderName } from './container-runner.js';
|
||||||
|
|
||||||
describe('resolveProviderName', () => {
|
describe('resolveProviderName', () => {
|
||||||
it('prefers session over group and container.json', () => {
|
it('prefers session over container config', () => {
|
||||||
expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex');
|
expect(resolveProviderName('codex', 'claude')).toBe('codex');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to group when session is null', () => {
|
it('falls back to container config when session is null', () => {
|
||||||
expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex');
|
expect(resolveProviderName(null, 'opencode')).toBe('opencode');
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to container.json when session and group are null', () => {
|
|
||||||
expect(resolveProviderName(null, null, 'opencode')).toBe('opencode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults to claude when nothing is set', () => {
|
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', () => {
|
it('lowercases the resolved name', () => {
|
||||||
expect(resolveProviderName('CODEX', null, null)).toBe('codex');
|
expect(resolveProviderName('CODEX', null)).toBe('codex');
|
||||||
expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode');
|
expect(resolveProviderName(null, 'Claude')).toBe('claude');
|
||||||
expect(resolveProviderName(null, null, 'Claude')).toBe('claude');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats empty string as unset (falls through)', () => {
|
it('treats empty string as unset (falls through)', () => {
|
||||||
expect(resolveProviderName('', 'codex', null)).toBe('codex');
|
expect(resolveProviderName('', 'opencode')).toBe('opencode');
|
||||||
expect(resolveProviderName(null, '', 'opencode')).toBe('opencode');
|
expect(resolveProviderName(null, '')).toBe('claude');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
ONECLI_URL,
|
ONECLI_URL,
|
||||||
TIMEZONE,
|
TIMEZONE,
|
||||||
} from './config.js';
|
} 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 { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js';
|
||||||
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
import { composeGroupClaudeMd } from './claude-md-compose.js';
|
||||||
import { getAgentGroup } from './db/agent-groups.js';
|
import { getAgentGroup } from './db/agent-groups.js';
|
||||||
@@ -119,13 +121,10 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
}
|
}
|
||||||
writeSessionRouting(agentGroup.id, session.id);
|
writeSessionRouting(agentGroup.id, session.id);
|
||||||
|
|
||||||
// Read container config once — threaded through provider resolution,
|
// Materialize container.json from DB — writes fresh file and returns
|
||||||
// buildMounts, and buildContainerArgs so we don't re-read the file.
|
// the config object, threaded through provider resolution, buildMounts,
|
||||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
// and buildContainerArgs so we don't re-read.
|
||||||
|
const containerConfig = materializeContainerJson(agentGroup.id);
|
||||||
// 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);
|
|
||||||
|
|
||||||
// Resolve the effective provider + any host-side contribution it declares
|
// Resolve the effective provider + any host-side contribution it declares
|
||||||
// (extra mounts, env passthrough). Computed once and threaded through both
|
// (extra mounts, env passthrough). Computed once and threaded through both
|
||||||
@@ -191,10 +190,14 @@ async function spawnContainer(session: Session): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Kill a container for a session. */
|
/** 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);
|
const entry = activeContainers.get(sessionId);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (onExit) {
|
||||||
|
entry.process.once('close', onExit);
|
||||||
|
}
|
||||||
|
|
||||||
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
|
log.info('Killing container', { sessionId, reason, containerName: entry.containerName });
|
||||||
try {
|
try {
|
||||||
stopContainer(entry.containerName);
|
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
|
* Resolve the provider name for a session:
|
||||||
* the provider-install skills:
|
|
||||||
*
|
*
|
||||||
* sessions.agent_provider
|
* sessions.agent_provider
|
||||||
* → agent_groups.agent_provider
|
* → container_configs.provider
|
||||||
* → container.json `provider`
|
|
||||||
* → 'claude'
|
* → 'claude'
|
||||||
*
|
*
|
||||||
* Pure so the precedence can be unit-tested without a DB or filesystem.
|
* Pure so the precedence can be unit-tested without a DB or filesystem.
|
||||||
*/
|
*/
|
||||||
export function resolveProviderName(
|
export function resolveProviderName(
|
||||||
sessionProvider: string | null | undefined,
|
sessionProvider: string | null | undefined,
|
||||||
agentGroupProvider: string | null | undefined,
|
|
||||||
containerConfigProvider: string | null | undefined,
|
containerConfigProvider: string | null | undefined,
|
||||||
): string {
|
): string {
|
||||||
return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase();
|
return (sessionProvider || containerConfigProvider || 'claude').toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProviderContribution(
|
function resolveProviderContribution(
|
||||||
@@ -227,7 +227,7 @@ function resolveProviderContribution(
|
|||||||
agentGroup: AgentGroup,
|
agentGroup: AgentGroup,
|
||||||
containerConfig: import('./container-config.js').ContainerConfig,
|
containerConfig: import('./container-config.js').ContainerConfig,
|
||||||
): { provider: string; contribution: ProviderContainerContribution } {
|
): { 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 fn = getProviderContainerConfig(provider);
|
||||||
const contribution = fn
|
const contribution = fn
|
||||||
? 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(
|
async function buildContainerArgs(
|
||||||
mounts: VolumeMount[],
|
mounts: VolumeMount[],
|
||||||
containerName: string,
|
containerName: string,
|
||||||
@@ -497,10 +469,10 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
|||||||
const agentGroup = getAgentGroup(agentGroupId);
|
const agentGroup = getAgentGroup(agentGroupId);
|
||||||
if (!agentGroup) throw new Error('Agent group not found');
|
if (!agentGroup) throw new Error('Agent group not found');
|
||||||
|
|
||||||
const containerConfig = readContainerConfig(agentGroup.folder);
|
const configRow = getContainerConfig(agentGroup.id);
|
||||||
const aptPackages = containerConfig.packages.apt;
|
if (!configRow) throw new Error('Container config not found');
|
||||||
const npmPackages = containerConfig.packages.npm;
|
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) {
|
if (aptPackages.length === 0 && npmPackages.length === 0) {
|
||||||
throw new Error('No packages to install. Use install_packages first.');
|
throw new Error('No packages to install. Use install_packages first.');
|
||||||
}
|
}
|
||||||
@@ -530,15 +502,14 @@ export async function buildAgentGroupImage(agentGroupId: string): Promise<void>
|
|||||||
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
|
execSync(`${CONTAINER_RUNTIME_BIN} build -t ${imageTag} -f ${tmpDockerfile} .`, {
|
||||||
cwd: DATA_DIR,
|
cwd: DATA_DIR,
|
||||||
stdio: 'pipe',
|
stdio: 'pipe',
|
||||||
timeout: 300_000,
|
timeout: 900_000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
fs.unlinkSync(tmpDockerfile);
|
fs.unlinkSync(tmpDockerfile);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the image tag in groups/<folder>/container.json
|
// Store the image tag in the DB
|
||||||
containerConfig.imageTag = imageTag;
|
updateContainerConfigScalars(agentGroup.id, { image_tag: imageTag });
|
||||||
writeContainerConfig(agentGroup.folder, containerConfig);
|
|
||||||
|
|
||||||
log.info('Per-agent-group image built', { agentGroupId, imageTag });
|
log.info('Per-agent-group image built', { agentGroupId, imageTag });
|
||||||
}
|
}
|
||||||
|
|||||||
97
src/db/container-configs.ts
Normal file
97
src/db/container-configs.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ContainerConfigRow } from '../types.js';
|
||||||
|
import { getDb } from './connection.js';
|
||||||
|
|
||||||
|
const SCALAR_COLUMNS = new Set([
|
||||||
|
'provider',
|
||||||
|
'model',
|
||||||
|
'effort',
|
||||||
|
'image_tag',
|
||||||
|
'assistant_name',
|
||||||
|
'max_messages_per_prompt',
|
||||||
|
'cli_scope',
|
||||||
|
]);
|
||||||
|
const JSON_COLUMNS = new Set(['skills', 'mcp_servers', 'packages_apt', 'packages_npm', 'additional_mounts']);
|
||||||
|
|
||||||
|
export function getContainerConfig(agentGroupId: string): ContainerConfigRow | undefined {
|
||||||
|
return getDb().prepare('SELECT * FROM container_configs WHERE agent_group_id = ?').get(agentGroupId) as
|
||||||
|
| ContainerConfigRow
|
||||||
|
| undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllContainerConfigs(): ContainerConfigRow[] {
|
||||||
|
return getDb().prepare('SELECT * FROM container_configs').all() as ContainerConfigRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a new config row. Caller must supply all JSON fields (use defaults for empty). */
|
||||||
|
export function createContainerConfig(config: ContainerConfigRow): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO container_configs (
|
||||||
|
agent_group_id, provider, model, effort, image_tag, assistant_name,
|
||||||
|
max_messages_per_prompt, skills, mcp_servers, packages_apt, packages_npm,
|
||||||
|
additional_mounts, updated_at
|
||||||
|
) VALUES (
|
||||||
|
@agent_group_id, @provider, @model, @effort, @image_tag, @assistant_name,
|
||||||
|
@max_messages_per_prompt, @skills, @mcp_servers, @packages_apt, @packages_npm,
|
||||||
|
@additional_mounts, @updated_at
|
||||||
|
)`,
|
||||||
|
)
|
||||||
|
.run(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create an empty config row with sensible defaults. Idempotent — no-ops if row exists. */
|
||||||
|
export function ensureContainerConfig(agentGroupId: string): void {
|
||||||
|
getDb()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR IGNORE INTO container_configs (agent_group_id, updated_at)
|
||||||
|
VALUES (?, ?)`,
|
||||||
|
)
|
||||||
|
.run(agentGroupId, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update scalar fields on a config row. Only touches fields present in `updates`. */
|
||||||
|
export function updateContainerConfigScalars(
|
||||||
|
agentGroupId: string,
|
||||||
|
updates: Partial<
|
||||||
|
Pick<
|
||||||
|
ContainerConfigRow,
|
||||||
|
'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope'
|
||||||
|
>
|
||||||
|
>,
|
||||||
|
): void {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: Record<string, unknown> = { agent_group_id: agentGroupId };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
if (!SCALAR_COLUMNS.has(key)) throw new Error(`Invalid scalar column: ${key}`);
|
||||||
|
fields.push(`${key} = @${key}`);
|
||||||
|
values[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fields.length === 0) return;
|
||||||
|
|
||||||
|
fields.push('updated_at = @updated_at');
|
||||||
|
values.updated_at = new Date().toISOString();
|
||||||
|
|
||||||
|
getDb()
|
||||||
|
.prepare(`UPDATE container_configs SET ${fields.join(', ')} WHERE agent_group_id = @agent_group_id`)
|
||||||
|
.run(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Overwrite a JSON column wholesale. Used for skills, mcp_servers, packages_*, additional_mounts. */
|
||||||
|
export function updateContainerConfigJson(
|
||||||
|
agentGroupId: string,
|
||||||
|
column: 'skills' | 'mcp_servers' | 'packages_apt' | 'packages_npm' | 'additional_mounts',
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
if (!JSON_COLUMNS.has(column)) throw new Error(`Invalid JSON column: ${column}`);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
getDb()
|
||||||
|
.prepare(`UPDATE container_configs SET ${column} = ?, updated_at = ? WHERE agent_group_id = ?`)
|
||||||
|
.run(JSON.stringify(value), now, agentGroupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteContainerConfig(agentGroupId: string): void {
|
||||||
|
getDb().prepare('DELETE FROM container_configs WHERE agent_group_id = ?').run(agentGroupId);
|
||||||
|
}
|
||||||
@@ -42,3 +42,12 @@ export {
|
|||||||
deletePendingApproval,
|
deletePendingApproval,
|
||||||
getPendingApprovalsByAction,
|
getPendingApprovalsByAction,
|
||||||
} from './sessions.js';
|
} from './sessions.js';
|
||||||
|
export {
|
||||||
|
getContainerConfig,
|
||||||
|
getAllContainerConfigs,
|
||||||
|
createContainerConfig,
|
||||||
|
ensureContainerConfig,
|
||||||
|
updateContainerConfigScalars,
|
||||||
|
updateContainerConfigJson,
|
||||||
|
deleteContainerConfig,
|
||||||
|
} from './container-configs.js';
|
||||||
|
|||||||
26
src/db/migrations/014-container-configs.ts
Normal file
26
src/db/migrations/014-container-configs.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
import type { Migration } from './index.js';
|
||||||
|
|
||||||
|
export const migration014: Migration = {
|
||||||
|
version: 14,
|
||||||
|
name: 'container-configs',
|
||||||
|
up(db: Database.Database) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE container_configs (
|
||||||
|
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||||
|
provider TEXT,
|
||||||
|
model TEXT,
|
||||||
|
effort TEXT,
|
||||||
|
image_tag TEXT,
|
||||||
|
assistant_name TEXT,
|
||||||
|
max_messages_per_prompt INTEGER,
|
||||||
|
skills TEXT NOT NULL DEFAULT '"all"',
|
||||||
|
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||||
|
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||||
|
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||||
|
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user