Compare commits

..

4 Commits

Author SHA1 Message Date
Daniel Milliner 39700a87aa fix(test): use valid session status in stale-origin fallback test
Session.status only allows 'active' | 'closed' — 'archived' is not a
valid value and breaks the TypeScript build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:22:49 +03:00
Daniel Milliner 90f36dc997 Merge branch 'main' into fix/a2a-test-type-error 2026-05-08 13:22:13 +03:00
Koshkoshinsk 537d507e56 style: apply Prettier formatting from pre-commit hook
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 22:18:58 +00:00
Koshkoshinsk c8757a5e32 fix(test): add missing in_reply_to to A2A test objects
The RoutableAgentMessage interface gained a required in_reply_to field
in #2267 but the test objects weren't updated, breaking the build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-07 22:18:48 +00:00
133 changed files with 624 additions and 4722 deletions
+2 -5
View File
@@ -182,12 +182,9 @@ ATOMIC_CHAT_API_KEY=sk-...
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
+2 -5
View File
@@ -93,13 +93,10 @@ Generate the secret: `node -e "console.log('nc-' + require('crypto').randomBytes
### 6. Build and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart nanoclaw # Linux
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
### 7. Verify
+2 -5
View File
@@ -23,17 +23,14 @@ DC_SMTP_PORT
## 3. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
## 4. Remove account data (optional)
+3 -7
View File
@@ -98,16 +98,12 @@ The `/set-avatar` command (send an image with that caption) is the easiest way t
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# Linux
systemctl --user restart $(systemd_unit)
systemctl --user restart nanoclaw
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
On first start the adapter configures the email account (IMAP/SMTP credentials, calls `configure()`). Subsequent starts skip straight to `startIo()`. Account data is stored in `dc-account/` in the project root (or your `DC_ACCOUNT_DIR`).
@@ -236,7 +232,7 @@ Set `DC_SMTP_SECURITY=1` and `DC_SMTP_PORT=465` in `.env`, then restart.
```bash
rm -f dc-account/accounts.lock
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
systemctl --user restart nanoclaw
```
### Bot not responding after restart
+5 -11
View File
@@ -162,13 +162,10 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
## Restart NanoClaw
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
```
## Verify
@@ -243,7 +240,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
### No response from agent
1. NanoClaw running: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux)
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
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`
@@ -285,16 +282,13 @@ If an agent outputs org-mode directly, markers get double-converted and render i
## Removal
Run from your NanoClaw project root:
```bash
rm src/channels/emacs.ts src/channels/emacs.test.ts emacs/nanoclaw.el
# Remove the `import './emacs.js';` line from src/channels/index.ts
# Remove EMACS_* lines from .env
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# systemctl --user restart nanoclaw # Linux
# Remove the NanoClaw block from your Emacs config
# Optionally clean up the messaging group:
+33 -61
View File
@@ -92,6 +92,7 @@ onecli agents list
```bash
grep -q 'CALENDAR_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__calendar__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
@@ -120,7 +121,9 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
```
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `calendar` in Phase 3 automatically allows `mcp__calendar__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'` to `TOOL_ALLOWLIST` after `'mcp__nanoclaw__*'` (or after `'mcp__gmail__*'` if present).
### Rebuild the container image
@@ -130,59 +133,40 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
## Phase 3: Wire Per-Agent-Group
For each agent group, persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.calendar` entry and an `additionalMounts` entry for `.calendar-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
For each agent group, merge into `groups/<folder>/container.json`:
### Register the MCP server
For each chosen `<group-id>` (use `ncl groups list` to enumerate):
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name calendar \
--command google-calendar-mcp \
--args '[]' \
--env '{"GOOGLE_OAUTH_CREDENTIALS":"/workspace/extra/.calendar-mcp/gcp-oauth.keys.json","GOOGLE_CALENDAR_MCP_TOKEN_PATH":"/workspace/extra/.calendar-mcp/credentials.json"}'
```jsonc
{
"mcpServers": {
"calendar": {
"command": "google-calendar-mcp",
"args": [],
"env": {
"GOOGLE_OAUTH_CREDENTIALS": "/workspace/extra/.calendar-mcp/gcp-oauth.keys.json",
"GOOGLE_CALENDAR_MCP_TOKEN_PATH": "/workspace/extra/.calendar-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.calendar-mcp",
"containerPath": ".calendar-mcp",
"readonly": false
}
]
}
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
### Add the `.calendar-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.calendar-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
`containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, both coexist — `ncl groups config add-mcp-server` only updates the named entry, and `json_insert` appends to `additional_mounts` without disturbing existing entries.
**Same-group-as-gmail tip:** if this group already has the gmail MCP + `.gmail-mcp` mount, **merge, don't replace** — both entries coexist in `mcpServers` and `additionalMounts`.
## Phase 4: Build and Restart
```bash
pnpm run build
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
Kill any existing agent containers so they respawn with the new mcpServers config:
@@ -209,28 +193,16 @@ Common signals:
- `command not found: google-calendar-mcp` → image not rebuilt.
- `ENOENT ...credentials.json` → mount missing. Check the mount allowlist.
- `401 Unauthorized` from `*.googleapis.com` → OneCLI isn't injecting; verify agent's secret mode and that Google Calendar is connected.
- Agent says "I don't have calendar tools" → the `calendar` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (`./container/build.sh`, `--no-cache` if suspicious).
- Agent says "I don't have calendar tools" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
## Removal
1. For each group that had Calendar wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name calendar
```
2. Remove the `.calendar-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
1. Delete `"calendar"` from `mcpServers` and the `.calendar-mcp` mount from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__calendar__*'` from `TOOL_ALLOWLIST`.
3. Remove `CALENDAR_MCP_VERSION` ARG and the calendar package from the Dockerfile install block.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. Optional: `rm -rf ~/.calendar-mcp/` and `onecli apps disconnect --provider google-calendar`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## Credits & references
- **MCP server:** [`@cocal/google-calendar-mcp`](https://github.com/cocal-com/google-calendar-mcp) — MIT-licensed, actively maintained, multi-account and multi-calendar.
+1 -9
View File
@@ -136,15 +136,7 @@ Use `per-thread` session mode so each PR/issue gets its own agent session.
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
+35 -65
View File
@@ -98,6 +98,7 @@ onecli agents secrets --id <agent-id>
```bash
grep -q 'GMAIL_MCP_VERSION' container/Dockerfile && \
grep -q "mcp__gmail__\*" container/agent-runner/src/providers/claude.ts && \
echo "ALREADY APPLIED — skip to Phase 3"
```
@@ -131,7 +132,9 @@ Pinned version matters — `minimumReleaseAge` in `pnpm-workspace.yaml` gates tr
**Why the `zod-to-json-schema` pin:** `@gongrzhe/server-gmail-autoauth-mcp@1.1.11` has loose deps (`zod-to-json-schema: ^3.22.1`, `zod: ^3.22.4`). pnpm resolves `zod-to-json-schema` to the latest 3.25.x, which imports `zod/v3` — a subpath that only exists in `zod>=3.25`. But `zod` resolves to `3.24.x` (highest satisfying `^3.22.4` without breaking peer ranges). Result: `ERR_PACKAGE_PATH_NOT_EXPORTED` at import time. Pinning `zod-to-json-schema` to a pre-v3-subpath version avoids it. Re-check if you bump `GMAIL_MCP_VERSION`.
**No `TOOL_ALLOWLIST` edit needed.** `container/agent-runner/src/providers/claude.ts` derives the allow-pattern dynamically from each group's `mcpServers` map (`Object.keys(this.mcpServers).map(mcpAllowPattern)`), so registering `gmail` in Phase 3 automatically allows `mcp__gmail__*`. Earlier versions of this skill instructed a static `TOOL_ALLOWLIST` edit — that's now redundant.
### Add tools to allowlist
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
### Rebuild the container image
@@ -143,63 +146,42 @@ Must complete cleanly. The new `pnpm install -g` layer is ~60s first time (cache
## Phase 3: Wire Per-Agent-Group
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), persist two changes to the **central DB** (`data/v2.db`): the `mcpServers.gmail` entry and an `additionalMounts` entry for `.gmail-mcp`. Both flow through `materializeContainerJson` on every spawn, so editing `groups/<folder>/container.json` by hand does **not** stick — that file is regenerated from the DB.
For each agent group that should have Gmail (ask the user — typically their personal DM and CLI agents, sometimes shared household agents), edit `groups/<folder>/container.json` to add the mount and MCP server.
### List groups, pick which ones get Gmail
Merge these into the group's `container.json`:
```bash
ncl groups list
```jsonc
{
"mcpServers": {
"gmail": {
"command": "gmail-mcp",
"args": [],
"env": {
"GMAIL_OAUTH_PATH": "/workspace/extra/.gmail-mcp/gcp-oauth.keys.json",
"GMAIL_CREDENTIALS_PATH": "/workspace/extra/.gmail-mcp/credentials.json"
}
}
},
"additionalMounts": [
{
"hostPath": "/home/<user>/.gmail-mcp",
"containerPath": ".gmail-mcp",
"readonly": false
}
]
}
```
### Register the MCP server
For each chosen `<group-id>`:
```bash
ncl groups config add-mcp-server \
--id <group-id> \
--name gmail \
--command gmail-mcp \
--args '[]' \
--env '{"GMAIL_OAUTH_PATH":"/workspace/extra/.gmail-mcp/gcp-oauth.keys.json","GMAIL_CREDENTIALS_PATH":"/workspace/extra/.gmail-mcp/credentials.json"}'
```
Approval behaviour depends on where you run it: from inside an agent's container `ncl` write verbs are approval-gated (admin approves before it lands); from a host operator shell with full scope, it executes immediately. Either way, the response tells you which path it took.
### Add the `.gmail-mcp` mount
There is no `ncl groups config add-mount` verb yet (tracked in [#2395](https://github.com/nanocoai/nanoclaw/issues/2395)). Until that ships, edit the DB directly via the in-tree wrapper (`scripts/q.ts``setup/verify.ts:5` codifies that NanoClaw avoids depending on the `sqlite3` CLI binary, so don't shell out to it):
```bash
GROUP_ID='<group-id>'
HOST_PATH="$HOME/.gmail-mcp"
MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}')
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \
updated_at = datetime('now') \
WHERE agent_group_id = '$GROUP_ID';"
```
Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings.
**Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time.
Substitute `<user>` with the host user's home (use `echo $HOME`, don't assume `~` will expand — `container-runner.ts` does expand `~` via `expandPath`, but an explicit absolute path is clearer and matches what `/manage-mounts` writes).
**Why the container path is relative:** `mount-security` rejects absolute `containerPath` values. Additional mounts are prefixed with `/workspace/extra/`, so `containerPath: ".gmail-mcp"` lands at `/workspace/extra/.gmail-mcp`. The MCP server's `GMAIL_OAUTH_PATH` / `GMAIL_CREDENTIALS_PATH` env vars point at that absolute location inside the container.
**Why this can't be `groups/<folder>/container.json`:** post-migration `014-container-configs`, `materializeContainerJson` in `src/container-config.ts` rewrites that file from the DB on every spawn. Anything hand-edited there is silently overwritten on next restart.
## Phase 4: Build and Restart
```bash
pnpm run build
```
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
## Phase 5: Verify
@@ -224,29 +206,17 @@ Common signals:
- `command not found: gmail-mcp` → image wasn't rebuilt or PATH doesn't include `/pnpm` (should — `ENV PATH="$PNPM_HOME:$PATH"` in Dockerfile).
- `ENOENT: no such file or directory, open '/workspace/extra/.gmail-mcp/credentials.json'` → mount is missing. Check `~/.config/nanoclaw/mount-allowlist.json` includes a parent of `~/.gmail-mcp`.
- `401 Unauthorized` from `gmail.googleapis.com` → OneCLI isn't injecting. Check the agent's secret mode (`onecli agents secrets --id <agent-id>`) and that the Gmail app is connected (`onecli apps get --provider gmail`).
- Agent says "I don't have Gmail tools" → the `gmail` MCP server isn't registered in this group's `mcpServers` (re-run the `ncl groups config add-mcp-server` step in Phase 3 for that group and restart it), or the agent-runner image is stale (rebuild with `./container/build.sh`, with `--no-cache` if suspicious).
- Agent says "I don't have Gmail tools" → `mcp__gmail__*` wasn't added to `TOOL_ALLOWLIST`, or the agent-runner wasn't rebuilt (image cache — run `./container/build.sh` again with `--no-cache` if suspicious).
## Removal
1. For each group that had Gmail wired, remove the MCP server from the DB:
```bash
ncl groups config remove-mcp-server --id <group-id> --name gmail
```
2. Remove the `.gmail-mcp` mount from the DB (no `remove-mount` verb yet — same #2395 dependency):
```bash
pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \
SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \
WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \
updated_at = datetime('now') \
WHERE agent_group_id = '<group-id>';"
```
1. Delete the `"gmail"` entry from `mcpServers` and the `.gmail-mcp` entry from `additionalMounts` in each group's `container.json`.
2. Remove `'mcp__gmail__*'` from `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts`.
3. Remove the `GMAIL_MCP_VERSION` ARG and the `pnpm install -g @gongrzhe/server-gmail-autoauth-mcp` block from `container/Dockerfile`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
4. `pnpm run build && ./container/build.sh && systemctl --user restart nanoclaw`.
5. (Optional) `rm -rf ~/.gmail-mcp/` if no other host-side tool needs the stubs.
6. (Optional) Disconnect Gmail in OneCLI: `onecli apps disconnect --provider gmail`.
No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
## Notes
- **Stub format is OneCLI-prescribed.** The `access_token: "onecli-managed"` pattern with `expiry_date: 99999999999999` tells the Google auth client the token is valid; OneCLI intercepts the outgoing Gmail API call and rewrites `Authorization: Bearer onecli-managed` to the real token. `expiry_date: 0` (refresh-interception) is an alternative the OneCLI docs describe — both work but OneCLI's own `migrate` command writes the far-future variant, which is what this skill assumes.
@@ -258,5 +228,5 @@ No `TOOL_ALLOWLIST` removal step — Phase 2 no longer edits it.
- **MCP server:** [`@gongrzhe/server-gmail-autoauth-mcp`](https://github.com/GongRzhe/Gmail-MCP-Server) by GongRzhe — MIT-licensed.
- **OneCLI credential stubs:** pattern documented at `https://onecli.sh/docs/guides/credential-stubs/gmail.md`.
- **Skill pattern:** modeled on [`add-atomic-chat-tool`](../add-atomic-chat-tool/SKILL.md) and [`add-vercel`](../add-vercel/SKILL.md).
- **Addresses:** [issue #1500](https://github.com/nanocoai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/nanocoai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
- **Addresses:** [issue #1500](https://github.com/qwibitai/nanoclaw/issues/1500) (proxy Gmail/Calendar OAuth tokens through credential proxy) for the Gmail side.
- **Related PRs:** [#1810](https://github.com/qwibitai/nanoclaw/pull/1810) (pre-install Gmail/Notion MCP) overlaps on the "install the MCP server in the image" idea but bundles many unrelated changes; this skill is the focused OneCLI-native version.
@@ -75,12 +75,9 @@ If yes, ask the agent to schedule the lint task using the `schedule_task` MCP to
## Step 6: Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Tell the user to test by sending a source to the wiki group.
+1 -9
View File
@@ -156,15 +156,7 @@ The `platform_id` must be `linear:<TEAM_KEY>` matching the `LINEAR_TEAM_KEY` env
If you're in the middle of `/setup`, return to the setup flow now.
Otherwise, restart the service to pick up the new channel.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
Otherwise, restart the service (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
## Channel Info
+2 -5
View File
@@ -89,12 +89,9 @@ docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart nanoclaw # Linux
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
### Confirm mnemon hooks are registered
+3 -6
View File
@@ -130,15 +130,12 @@ file, not from env vars. This file is bind-mounted into the container as `~/.cla
## 5. Build and restart
Run from your NanoClaw project root:
```bash
export PATH="/opt/homebrew/bin:$PATH"
pnpm run build
source setup/lib/install-slug.sh
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
# Linux: systemctl --user restart $(systemd_unit)
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux: systemctl --user restart nanoclaw
```
## 6. Verify
+3 -6
View File
@@ -54,7 +54,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -122,12 +122,9 @@ OLLAMA_HOST=http://your-ollama-host:11434
### Restart the service
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
+6 -9
View File
@@ -229,22 +229,19 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe
### 7. Restart Service
Rebuild the main app and restart.
Run from your NanoClaw project root:
Rebuild the main app and restart:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
Wait 3 seconds for service to start, then verify:
```bash
sleep 3
launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)" # macOS
# Linux: systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"
launchctl list | grep nanoclaw # macOS
# Linux: systemctl --user status nanoclaw
```
### 8. Test Integration
@@ -290,4 +287,4 @@ To remove Parallel AI integration:
2. Revert changes to container-runner.ts and agent-runner/src/index.ts
3. Remove Web Research Tools section from groups/main/CLAUDE.md
4. Rebuild: `./container/build.sh && pnpm run build`
5. Restart: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)` (macOS) or `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (Linux)
5. Restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
+7 -15
View File
@@ -90,21 +90,17 @@ No output = success.
> ⚠ Stop NanoClaw before running signal-cli commands — the daemon holds an exclusive lock on its data directory while running.
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
# optionally: --avatar /path/to/avatar.jpg
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux
systemctl --user stop $(systemd_unit)
systemctl --user stop nanoclaw
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
systemctl --user start $(systemd_unit)
systemctl --user start nanoclaw
```
### Path B: Link as secondary device
@@ -189,16 +185,12 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
### Restart
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
# macOS
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
# Linux
systemctl --user restart $(systemd_unit)
systemctl --user restart nanoclaw
```
## Wiring
@@ -291,7 +283,7 @@ If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DA
1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1`
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)/"$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) / `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (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)
+1 -1
View File
@@ -60,7 +60,7 @@ pnpm run build
1. Go to [api.slack.com/apps](https://api.slack.com/apps) and click **Create New App** > **From scratch**
2. Name it (e.g., "NanoClaw") and select your workspace
3. Go to **OAuth & Permissions** and add Bot Token Scopes:
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
5. Go to **Basic Information** and copy the **Signing Secret**
+2 -5
View File
@@ -41,12 +41,9 @@ DELETE FROM messaging_groups WHERE channel_type = 'wechat';
### 6. Rebuild and restart
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
systemctl --user restart nanoclaw # Linux
# or
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
+3 -6
View File
@@ -82,15 +82,12 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
### 2. Start the service and scan the QR
Restart NanoClaw.
Run from your NanoClaw project root:
Restart NanoClaw:
```bash
source setup/lib/install-slug.sh
systemctl --user restart $(systemd_unit) # Linux
systemctl --user restart nanoclaw # Linux
# or
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
```
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
+4 -7
View File
@@ -244,15 +244,12 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
### "waiting for this message" on reactions
Signal sessions corrupted from rapid restarts. Clear sessions.
Run from your NanoClaw project root:
Signal sessions corrupted from rapid restarts. Clear sessions:
```bash
source setup/lib/install-slug.sh
systemctl --user stop $(systemd_unit)
systemctl --user stop nanoclaw
rm store/auth/session-*.json
systemctl --user start $(systemd_unit)
systemctl --user start nanoclaw
```
### Bot not responding
@@ -260,7 +257,7 @@ systemctl --user start $(systemd_unit)
1. Auth exists: `test -f store/auth/creds.json`
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
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 "$(. setup/lib/install-slug.sh && systemd_unit)"`
4. Service running: `systemctl --user status nanoclaw`
### "conflict" disconnection
@@ -58,7 +58,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -171,12 +171,9 @@ Expected: Both operations succeed.
### Full integration test
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
```
Send a message via WhatsApp and verify the agent responds.
+4 -8
View File
@@ -88,19 +88,15 @@ Implementation:
## After Changes
Always tell the user.
Run from your NanoClaw project root:
Always tell the user:
```bash
# Rebuild and restart
pnpm run build
source setup/lib/install-slug.sh
# macOS:
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user restart $(systemd_unit)
# systemctl --user restart nanoclaw
```
## Example Interaction
+1 -1
View File
@@ -9,7 +9,7 @@ Stand up the first NanoClaw agent for a channel and verify end-to-end delivery b
## Prerequisites
- **Service running.** Check: `launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)"` (macOS) or `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"` (Linux). If stopped, tell the user to run `/setup` first.
- **Service running.** Check: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
- **Target channel installed.** At least one `/add-<channel>` skill has run, credentials are in `.env`, and the adapter is uncommented in `src/channels/index.ts`.
- **Adapter connected.** Tail `logs/nanoclaw.log` — look for a recent `channel setup` / `adapter connected` line for the target channel.
+3 -6
View File
@@ -236,12 +236,9 @@ pnpm run build
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
Restart the service.
Run from your NanoClaw project root:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux (systemd): `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
+3 -8
View File
@@ -41,12 +41,7 @@ npx tsx setup/index.ts --step mounts --force -- --empty
## After Changes
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
Restart the service so containers pick up the new config:
Run from your NanoClaw project root:
```bash
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
systemctl --user restart $(systemd_unit) # Linux
```
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
+1 -1
View File
@@ -34,7 +34,7 @@ Two phases: **Extract** (build the migration guide) and **Upgrade** (use it). If
Run `git status --porcelain`. If non-empty, offer to stash or commit for them (AskUserQuestion: "Stash changes" / "Commit changes" / "I'll handle it"). If they want to commit, stage and commit with a descriptive message. If they want to stash, run `git stash push -m "pre-migration stash"`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/nanocoai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Check remotes with `git remote -v`. If `upstream` is missing, ask for the URL (default: `https://github.com/qwibitai/nanoclaw.git`), add it, then `git fetch upstream --prune`.
Detect upstream branch: check `git branch -r | grep upstream/` for `main` or `master`. Store as UPSTREAM_BRANCH.
+5 -5
View File
@@ -11,7 +11,7 @@ Run `/update-nanoclaw` in Claude Code.
## How it works
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/nanocoai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
**Backup**: creates a timestamped backup branch and tag (`backup/pre-update-<hash>-<timestamp>`, `pre-update-<hash>-<timestamp>`) before touching anything. Safe to run multiple times.
@@ -69,7 +69,7 @@ If output is non-empty:
Confirm remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- Add it: `git remote add upstream <user-provided-url>`
- Then: `git fetch upstream --prune`
@@ -270,9 +270,9 @@ Show:
Tell the user:
- To rollback: `git reset --hard <backup-tag-from-step-1>`
- Backup branch also exists: `backup/pre-update-<HASH>-<TIMESTAMP>`
- Restart the service to apply changes. The unit/label names are per-install — derive them with `setup/lib/install-slug.sh`. Run from your NanoClaw project root:
- **macOS (Darwin)**: `source setup/lib/install-slug.sh && launchctl kickstart -k gui/$(id -u)/$(launchd_label)`
- **Linux**: `source setup/lib/install-slug.sh && systemctl --user restart $(systemd_unit)` (or, if you want to confirm the unit name first: `systemctl --user list-units --type=service | grep "$(. setup/lib/install-slug.sh && systemd_unit)"`)
- Restart the service to apply changes. Detect platform with `uname -s`:
- **macOS (Darwin)**: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- **Linux**: detect the service name with `systemctl --user list-units --type=service | grep nanoclaw | awk '{print $1}'`, then `systemctl --user restart <detected-name>`
- **Manual** (no service found): restart `pnpm run dev`
+1 -1
View File
@@ -42,7 +42,7 @@ Check remotes:
- `git remote -v`
If `upstream` is missing:
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/nanoclaw.git`).
- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
- `git remote add upstream <url>`
Fetch:
@@ -40,7 +40,7 @@ git remote -v
If `upstream` is missing, add it:
```bash
git remote add upstream https://github.com/nanocoai/nanoclaw.git
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
@@ -128,12 +128,9 @@ echo 'ANTHROPIC_API_KEY=<key>' >> .env
pnpm run build
```
Then restart the service.
Run from your NanoClaw project root:
- macOS: `launchctl kickstart -k gui/$(id -u)/"$(. setup/lib/install-slug.sh && launchd_label)"`
- Linux: `systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`
Then restart the service:
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux: `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
2. Check logs for successful proxy startup:
+10 -23
View File
@@ -38,8 +38,6 @@ Before using this skill, ensure:
## Quick Start
Run from your NanoClaw project root:
```bash
# 1. Setup authentication (interactive)
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
@@ -51,10 +49,9 @@ pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/s
# 3. Rebuild host and restart service
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
# Verify: launchctl list | grep "$(launchd_label)" (macOS) or systemctl --user status $(systemd_unit) (Linux)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
# Verify: launchctl list | grep nanoclaw (macOS) or systemctl --user status nanoclaw (Linux)
```
## Configuration
@@ -273,23 +270,16 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
### 4. Restart Service
Run from your NanoClaw project root:
```bash
pnpm run build
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
**Verify success.**
Run from your NanoClaw project root:
**Verify success:**
```bash
source setup/lib/install-slug.sh
launchctl list | grep "$(launchd_label)" # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status $(systemd_unit)
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
# Linux: systemctl --user status nanoclaw
```
## Usage via WhatsApp
@@ -353,13 +343,10 @@ echo '{"content":"Test"}' | pnpm exec tsx .claude/skills/x-integration/scripts/p
### Authentication Expired
Run from your NanoClaw project root:
```bash
pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/setup.ts
source setup/lib/install-slug.sh
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
# Linux: systemctl --user restart $(systemd_unit)
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Browser Lock Files
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
+1 -1
View File
@@ -8,7 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'nanocoai/nanoclaw'
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
-4
View File
@@ -1,5 +1 @@
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
pnpm run format:fix
if [ -n "$staged" ]; then
echo "$staged" | xargs git add
fi
+3 -28
View File
@@ -2,37 +2,12 @@
All notable changes to NanoClaw will be documented in this file.
## [2.0.63] - 2026-05-15
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
Rollup release covering v2.0.55 through v2.0.63 — everything merged since the v2.0.54 tag. Starting with this release, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`; see [RELEASING.md](RELEASING.md).
## [Unreleased]
- [BREAKING] **Service names are now per-install.** On v2 installs the launchd label and systemd unit are slugged to your project root: `com.nanoclaw.<sha1(projectRoot)[:8]>` and `nanoclaw-<slug>.service`. The old `com.nanoclaw` / `nanoclaw.service` names no longer match a real service — update any copy-pasted restart or status commands. Find your install's names with `source setup/lib/install-slug.sh && launchd_label` (macOS) or `systemd_unit` (Linux). The `ncl` transport-error help text and 26 skill files now use the canonical helper-driven pattern; see [setup/lib/install-slug.sh](setup/lib/install-slug.sh).
- **Compaction destination reminder placement fixed.** The reminder injected after SDK auto-compaction now appears at the end of the compaction summary so it isn't stripped during truncation. Replaces the placement shipped in v2.0.54.
- **Stronger message-wrapping enforcement.** The poll loop nudges the agent when its output lacks `<message>` wrapping, and `CLAUDE.md` core instructions now require wrapping even for single-destination agents. The welcome flow no longer double-greets.
- **OneCLI credentials after MCP install.** MCP servers added through `add_mcp_server` now inherit OneCLI gateway routing — fixes the case where the agent kept asking for API keys after installing a new server.
- **CLI scope hardening.** `scopeField` now fails closed when scope is missing, and `sessions get` is guarded against cross-group oracle access from group-scoped agents.
- **gmail/gcal skills aligned with v2.** `/add-gmail-tool` and `/add-gcal-tool` now reflect the v2 container-config model — DB-backed mounts, no dead `TOOL_ALLOWLIST` edits, no `container.json` writes that get clobbered on next spawn. Manual sqlite3/JSON1 invocations corrected.
- **Repo-rename cleanup.** Remaining `qwibitai/nanoclaw` references swept to `nanocoai/nanoclaw` across code and docs; CI workflow guards updated so they no longer no-op after the rename.
- Slack scope checklist now includes `files:read` and `files:write` for skills that read or post attachments.
- The internal-tag description in destination instructions no longer mentions scratchpads (which confused agents into routing them incorrectly).
- Container startup is now graceful when the `on_wake` column is missing on older sessions DBs.
## [2.0.54] - 2026-05-10
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
## [2.0.48] - 2026-05-09
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
## [2.0.45] - 2026-05-08
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md``CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
## [2.0.0] - 2026-04-22
+2 -53
View File
@@ -72,10 +72,7 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
| `src/db/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/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
@@ -84,32 +81,6 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
## Admin CLI (`ncl`)
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
```
ncl <resource> <verb> [<id>] [--flags]
ncl <resource> help
ncl help
```
| Resource | Verbs | What it is |
|----------|-------|------------|
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
| members | list, add, remove | Unprivileged access gate for an agent group |
| destinations | list, add, remove | Where an agent group can send messages |
| sessions | list, get | Active sessions (read-only) |
| user-dms | list | Cold-DM cache (read-only) |
| dropped-messages | list | Messages from unregistered senders (read-only) |
| approvals | list, get | Pending approval requests (read-only) |
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
## Channels and Providers (skill-installed)
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
@@ -123,32 +94,10 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
One tier of agent self-modification today:
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config 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`.
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`.
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
## Container Config
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
| Value | Behavior |
|-------|----------|
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
## Container Restart
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
## Secrets / Credentials / OneCLI
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. 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`.
+3 -3
View File
@@ -4,8 +4,8 @@
1. **Check for existing work.** Search open PRs and issues before starting:
```bash
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
gh issue list --repo nanocoai/nanoclaw --search "<your feature>"
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
```
If a related PR or issue exists, build on it rather than duplicating effort.
@@ -43,7 +43,7 @@ Add capabilities to NanoClaw by merging a git branch. The SKILL.md contains setu
3. Claude walks through interactive setup (env vars, bot creation, etc.)
**Contributing a feature skill:**
1. Fork `nanocoai/nanoclaw` and branch from `main`
1. Fork `qwibitai/nanoclaw` and branch from `main`
2. Make the code changes (new files, modified source, updated `package.json`, etc.)
3. Add a SKILL.md in `.claude/skills/<name>/` with setup instructions — step 1 should be merging the branch
4. Open a PR. We'll create the `skill/<name>` branch from your work
+2 -2
View File
@@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
@@ -39,7 +39,7 @@ bash nanoclaw.sh
Run from a fresh v2 checkout next to your v1 install:
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash migrate-v2.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
## クイックスタート
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
+1 -1
View File
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
## 快速开始
```bash
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
cd nanoclaw-v2
bash nanoclaw.sh
```
-50
View File
@@ -1,50 +0,0 @@
# Releasing NanoClaw
Starting with v2.0.63, the goal is to publish a GitHub Release for every `package.json` version bump that lands on `main`. Releases are cut manually by a maintainer, so there can be lag between a bump merging and its release being published. The intent is *timeliness*, not strict 1:1 correlation with every bump.
Each release ships:
- A tagged commit on `main` (`vX.Y.Z`).
- A `CHANGELOG.md` entry under `## [<version>] - <YYYY-MM-DD>`.
- A GitHub Release whose body mirrors the CHANGELOG entry plus a contributors section.
## When to cut a release
A release is cut by a maintainer publishing it. The trigger is a `package.json` bump on `main`, but the publish step is manual — there is no fixed schedule, and bumps that land back-to-back may be rolled into a single release (as v2.0.55 through v2.0.63 were). Cutting more frequently is preferable to batching: smaller releases are easier to read, pin, and revert.
## What goes in a release
`CHANGELOG.md` is the canonical record of user-visible change. The release body on GitHub mirrors it. Aim for:
- **Bold lead-ins** per major feature or fix, then a sentence-case prose explanation.
- **`[BREAKING]` prefix** for any change that requires user action. Always include the workaround inline — never link to a separate doc for the fix.
- **Doc links** for major features (relative paths into the repo, e.g. `[setup/lib/install-slug.sh](setup/lib/install-slug.sh)`).
- **Inline commands** for actionable steps, in backticks.
- **Minor items** as single plain bullets at the bottom of the entry, no bold lead-in.
- **No PR numbers** in the user-facing prose. PR references can live in the GitHub Release's `## Contributors` section.
## Publishing the release
1. Bump `package.json` and add a `CHANGELOG.md` entry in the same commit (commit message: `chore: bump version to vX.Y.Z`).
2. Once the bump commit lands on `main`, open a draft GitHub Release:
- **Tag:** `vX.Y.Z`, target `main`.
- **Title:** `vX.Y.Z` (bare version — descriptive content lives in the body, matching the CHANGELOG header pattern).
- **Body:** copy the CHANGELOG entry verbatim. Append a `## Contributors` section listing every PR author who landed work in the release window. Append a `**Full Changelog**: https://github.com/nanocoai/nanoclaw/compare/<prev-tag>...vX.Y.Z` line at the bottom.
3. If anyone in the window opened their first NanoClaw PR, add a `## New Contributors` section above `## Contributors`, with each first-timer's first PR link and an invite to Discord.
4. Publish (not just save draft).
## Rollup releases
If multiple `package.json` bumps land between two GitHub Releases (as happened between v2.0.54 and v2.0.63), the next release is a rollup: its CHANGELOG entry covers everything merged since the last released tag, and the body opens with a one-line "Rollup release covering vX.Y.Z through vX.Y.W." note. After the catchup, return to one release per bump.
## Channels and stability
NanoClaw currently ships a single channel: every published release is a stable release.
- **Latest** — the most recent release on `main`, shown as "Latest release" on the GitHub Releases page. Consumers that want auto-bump follow GitHub's `/releases/latest` pointer.
- **Stable** — currently identical to latest. NanoClaw has no separate stable branch and no pre-release/RC channel.
- **Pinned** — any tagged release. Reproducible and the recommended choice for packagers and forks; published tags are not moved or retracted.
If a pre-release channel is introduced later (e.g. `vX.Y.Z-rc.N`), those releases will be marked "Pre-release" on GitHub so they do not become the `latest` pointer, and this section will be updated to describe the promotion path.
The tag is the source of truth — a GitHub Release's `target_commitish` always points to a tagged commit.
-27
View File
@@ -1,27 +0,0 @@
#!/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 "$@"
+1 -6
View File
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
# mean every rebuild silently picks up the latest and can break in lockstep
# across all users.
ARG CLAUDE_CODE_VERSION=2.1.128
ARG CLAUDE_CODE_VERSION=2.1.116
ARG AGENT_BROWSER_VERSION=latest
ARG VERCEL_VERSION=52.2.1
ARG BUN_VERSION=1.3.12
@@ -110,11 +110,6 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
RUN --mount=type=cache,target=/root/.cache/pnpm \
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
# ---- ncl CLI wrapper ----------------------------------------------------------
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
chmod +x /usr/local/bin/ncl
# ---- Entrypoint --------------------------------------------------------------
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
+10 -10
View File
@@ -5,7 +5,7 @@
"": {
"name": "nanoclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0",
@@ -18,23 +18,23 @@
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.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": ["@anthropic-ai/claude-agent-sdk@0.2.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.138", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aObxJ/GeJ5UxT9N8XypUHPYQKpwYsRT5THiJl5E2pKEUk/Xt42gT55N5GV0TOjtgxVAnDMWjxTAgGCGoDzjgpg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.138", "", { "os": "darwin", "cpu": "x64" }, "sha512-ou3i1/gAf2PEgVl2WYJb7ZdE+KGwoB1I46JRhWHSC3uD6lb9HMZam233T/rlKCVX9e5dzfkujUOnmCkmXjgVGQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-jp8lmAVe9uI9X5o+IYWFajLbN+Z80XogVX7NeyaenLHdpHkxg29Yf8pb6Os4OvHMjJOAdwDhPpXajf6RtBeEDA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.138", "", { "os": "linux", "cpu": "arm64" }, "sha512-uZaEFND1pl7KD9tdYqj2hd6ktjlYizVmkHRgU2Aj/P1CC6WMDsKG+rqPP7dsVXO77gMXhL4xjjwwqMjxx83HkA=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-SLuUmu/nH1Wh0wnoXj/Bwh0nbDfEn9PgXqMsZHEUk3x1zxeR+6aRqFLjKZ8TawBey7xod7nfYUIjPnQx6IWDzg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.138", "", { "os": "linux", "cpu": "x64" }, "sha512-T16F8Vkikb98E781ZM6Cx84yEBk+loSCqAObjaZ1hzQ1eKcpnxzSTF4rH2bz6N91dhFuCfIjFaBfNYg+oQA+yQ=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.138", "", { "os": "win32", "cpu": "arm64" }, "sha512-H/sD25fmMyEeJWamYmBKRS3E7jaIrg2S8KWxyR37P+xTZgkLe19sDTp7gYYywMXf1X9CJZJ8jJZ93qxINZoCeA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.138", "", { "os": "win32", "cpu": "x64" }, "sha512-cSOdTH1OfIamVdJit9laWZiXne81ewgdP8MGh5HzLLLci0NGHkME7YxCWd0lYkCNkfiOEcToKU9axaZ+84jGiw=="],
"@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/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=="],
+1 -1
View File
@@ -9,7 +9,7 @@
"test": "bun test"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
-254
View File
@@ -1,254 +0,0 @@
#!/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);
}
@@ -26,9 +26,9 @@ const instructions = [
'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. At the END of the compaction summary, include this verbatim reminder:',
' "You MUST wrap all responses in <message to="name">...</message> blocks.',
` Available destinations: ${names.length > 0 ? names.map((n) => `\`${n}\``).join(', ') : '(none)'}."`,
'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'));
-4
View File
@@ -16,8 +16,6 @@ export interface RunnerConfig {
agentGroupId: string;
maxMessagesPerPrompt: number;
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
model?: string;
effort?: string;
}
const DEFAULT_MAX_MESSAGES = 10;
@@ -45,8 +43,6 @@ export function loadConfig(): RunnerConfig {
agentGroupId: (raw.agentGroupId as string) || '',
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
model: (raw.model as string) || undefined,
effort: (raw.effort as string) || undefined,
};
return _config;
+1 -2
View File
@@ -196,8 +196,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL,
on_wake INTEGER NOT NULL DEFAULT 0
content TEXT NOT NULL
);
CREATE TABLE delivered (
message_out_id TEXT PRIMARY KEY,
+3 -18
View File
@@ -10,19 +10,6 @@
import { getConfig } from '../config.js';
import { openInboundDb, getOutboundDb } from './connection.js';
// Cache whether inbound.db has the on_wake column (added in v2.0.48).
// The container opens inbound.db read-only, so it can't ALTER —
// gracefully degrade when running against an older session DB.
let _hasOnWake: boolean | null = null;
function hasOnWakeColumn(db: ReturnType<typeof openInboundDb>): boolean {
if (_hasOnWake !== null) return _hasOnWake;
const cols = new Set(
(db.prepare("PRAGMA table_info('messages_in')").all() as Array<{ name: string }>).map((c) => c.name),
);
_hasOnWake = cols.has('on_wake');
return _hasOnWake;
}
export interface MessageInRow {
id: string;
seq: number | null;
@@ -62,22 +49,20 @@ function getMaxMessagesPerPrompt(): number {
* sees the prior context it missed. Host's countDueMessages gates waking on
* trigger=1 separately (see src/db/session-db.ts).
*/
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
export function getPendingMessages(): MessageInRow[] {
const inbound = openInboundDb();
const outbound = getOutboundDb();
try {
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
const pending = inbound
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
${onWakeFilter}
ORDER BY seq DESC
LIMIT ?2`,
LIMIT ?`,
)
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
if (pending.length === 0) return [];
@@ -27,18 +27,18 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('from="name"');
expect(prompt).toContain('`casa`');
expect(prompt).toContain('`whatsapp-mg-17780`');
});
it('describes message wrapping for a single destination', () => {
it('requires explicit wrapping even for a single destination', () => {
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('`casa`');
});
@@ -47,7 +47,7 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('no configured destinations');
expect(prompt).not.toContain('default to addressing');
expect(prompt).not.toContain('Default routing');
});
it('includes default-routing and wrapping instructions for single destination', () => {
@@ -55,9 +55,9 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
const prompt = buildSystemPromptAddendum('Casa');
expect(prompt).toContain('Wrap each delivered message');
expect(prompt).toContain('Every response must be wrapped');
expect(prompt).toContain('<message to="name">');
expect(prompt).toContain('default to addressing the destination it came `from`');
expect(prompt).toContain('Default routing');
expect(prompt).toContain('`casa`');
});
});
+7 -6
View File
@@ -115,16 +115,17 @@ function buildDestinationsSection(): string {
}
}
lines.push('');
lines.push('**Every response must be wrapped** in a `<message to="name">...</message>` block.');
lines.push('You can include multiple `<message>` blocks in one response to send to multiple destinations.');
lines.push('Text outside of `<message>` blocks is scratchpad — logged but not sent anywhere.');
lines.push('Use `<internal>...</internal>` to make scratchpad intent explicit.');
lines.push('');
lines.push(
'Wrap each delivered message in a `<message to="name">…</message>` block; include several blocks in one response to address several destinations. `<internal>…</internal>` marks thinking you don\'t want sent.',
'**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(
'When replying to an incoming message, default to addressing the destination it came `from` (every inbound `<message>` tag carries a `from="name"` attribute). Pick a different destination when the request asks for it (e.g., "tell Laura that…").',
);
lines.push('');
lines.push(
'The `send_message` MCP tool is the same delivery, available mid-turn — handy for a quick acknowledgment ("on it") before a slow tool call. Each `send_message` call and each final-response `<message>` block lands as its own message in the conversation, so they read as a sequence rather than as one combined reply.',
'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.',
);
return lines.join('\n');
}
-2
View File
@@ -91,8 +91,6 @@ async function main(): Promise<void> {
mcpServers,
env: { ...process.env },
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
model: config.model,
effort: config.effort,
});
await runPollLoop({
+107 -140
View File
@@ -3,7 +3,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
import { getUndeliveredMessages } from './db/messages-out.js';
import { getPendingMessages } from './db/messages-in.js';
import { getContinuation, setContinuation } from './db/session-state.js';
import { MockProvider } from './providers/mock.js';
import { runPollLoop } from './poll-loop.js';
@@ -295,8 +294,115 @@ describe('poll loop integration', () => {
await loopPromise.catch(() => {});
});
it('should inject destination reminder after a compacted event', async () => {
// Two destinations — required for the reminder to fire (single-destination
// groups have a fallback path that works without <message to="…"> wrapping).
getInboundDb()
.prepare(
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
VALUES ('discord-second', 'Discord Second', 'channel', 'discord', 'chan-2', NULL)`,
)
.run();
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
expect(provider.pushes.length).toBeGreaterThanOrEqual(1);
const reminder = provider.pushes.find((p) => p.includes('Context was just compacted'));
expect(reminder).toBeDefined();
expect(reminder).toContain('2 destinations');
expect(reminder).toContain('discord-test');
expect(reminder).toContain('discord-second');
expect(reminder).toContain('<message to="name">');
await loopPromise.catch(() => {});
});
it('should NOT inject destination reminder with a single destination', async () => {
insertMessage('m1', { sender: 'Alice', text: 'First message' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new CompactingProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2500);
await waitFor(() => getUndeliveredMessages().length > 0, 2500);
controller.abort();
// Only the original prompt push (if any) — no reminder, since beforeEach
// seeds exactly one destination.
const reminders = provider.pushes.filter((p) => p.includes('Context was just compacted'));
expect(reminders).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that emits a single compacted event mid-stream, then returns a
* result. Captures every push() call so tests can assert on the injected
* reminder content.
*/
class CompactingProvider {
readonly supportsNativeSlashCommands = false;
readonly pushes: string[] = [];
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const pushes = this.pushes;
let ended = false;
let aborted = false;
let resolveWaiter: (() => void) | null = null;
async function* events() {
yield { type: 'activity' as const };
yield { type: 'init' as const, continuation: 'compaction-test-session' };
yield { type: 'activity' as const };
yield { type: 'compacted' as const, text: 'Context compacted (50,000 tokens compacted).' };
// Wait for poll-loop to push the reminder (or end / abort)
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
// Belt-and-braces: don't hang forever if the reminder never arrives
setTimeout(resolve, 200);
});
yield { type: 'activity' as const };
yield { type: 'result' as const, text: '<message to="discord-test">ack</message>' };
while (!ended && !aborted) {
await new Promise<void>((resolve) => {
resolveWaiter = resolve;
setTimeout(resolve, 50);
});
}
}
return {
push(message: string) {
pushes.push(message);
resolveWaiter?.();
},
end() {
ended = true;
resolveWaiter?.();
},
abort() {
aborted = true;
resolveWaiter?.();
},
events: events(),
};
}
}
// Helper: run poll loop until aborted or timeout
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
return Promise.race([
@@ -323,142 +429,3 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe('poll loop — provider error recovery', () => {
it('writes error to outbound and continues loop on provider throw', async () => {
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new ThrowingProvider('API rate limit exceeded');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
// Input message should be marked completed despite the error
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
describe('poll loop — stale session recovery', () => {
it('clears continuation when provider reports session invalid', async () => {
// Pre-seed a continuation so the local variable in runPollLoop is set.
// Without this, the `if (continuation && isSessionInvalid)` check skips.
setContinuation('mock', 'pre-existing-session');
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
const provider = new InvalidSessionProvider();
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
// Error was written to outbound
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toContain('Error:');
// Continuation was cleared (isSessionInvalid returned true)
expect(getContinuation('mock')).toBeUndefined();
await loopPromise.catch(() => {});
});
});
describe('poll loop — /clear command', () => {
it('clears session, writes confirmation, skips query', async () => {
// Seed a continuation so we can verify it gets cleared
setContinuation('mock', 'existing-session-id');
expect(getContinuation('mock')).toBe('existing-session-id');
// Insert a /clear command
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
)
.run(JSON.stringify({ text: '/clear' }));
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
const controller = new AbortController();
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
controller.abort();
const out = getUndeliveredMessages();
expect(out).toHaveLength(1);
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
// Continuation was cleared
expect(getContinuation('mock')).toBeUndefined();
// Command message was completed
const pending = getPendingMessages();
expect(pending).toHaveLength(0);
await loopPromise.catch(() => {});
});
});
/**
* Provider that throws on every query, simulating API failures.
*/
class ThrowingProvider {
readonly supportsNativeSlashCommands = false;
private errorMessage: string;
constructor(errorMessage: string) {
this.errorMessage = errorMessage;
}
isSessionInvalid(): boolean {
return false;
}
query(_input: { prompt: string; cwd: string }) {
const errorMessage = this.errorMessage;
return {
push() {},
end() {},
abort() {},
events: (async function* () {
throw new Error(errorMessage);
})(),
};
}
}
/**
* Provider that throws with an error that triggers isSessionInvalid.
* First emits an init event (setting continuation), then throws.
*/
class InvalidSessionProvider {
readonly supportsNativeSlashCommands = false;
isSessionInvalid(): boolean {
return true;
}
query(_input: { prompt: string; cwd: string }) {
return {
push() {},
end() {},
abort() {},
events: (async function* () {
yield { type: 'init' as const, continuation: 'doomed-session' };
throw new Error('session not found');
})(),
};
}
}
@@ -1,83 +0,0 @@
## 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.
@@ -1,6 +1,6 @@
## Sending messages
**Every response** must be wrapped in `<message to="name">...</message>` blocks — even if you only have one destination. Bare text outside of `<message>` blocks is scratchpad (logged but never sent). See the `## Sending messages` section in your runtime system prompt for the current destination list and names.
Your final response is delivered via the `## Sending messages` rules in your runtime system prompt (single-destination: just write; multi-destination: use `<message to="name">...</message>` blocks). See that section for the current destination list.
### Mid-turn updates (`send_message`)
@@ -22,4 +22,4 @@ Use **`add_mcp_server`** to add an MCP server to your configuration. Browse avai
add_mcp_server({ name: "memory", command: "pnpm", args: ["dlx", "@modelcontextprotocol/server-memory"] })
```
Do not ask the user to give you credentials 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).
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.
+4 -61
View File
@@ -14,18 +14,13 @@ afterEach(() => {
closeSessionDb();
});
function insertMessage(
id: string,
kind: string,
content: object,
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
) {
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
)
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
}
describe('formatter', () => {
@@ -136,58 +131,6 @@ describe('accumulate gate (trigger column)', () => {
});
});
describe('on_wake filtering', () => {
it('first poll returns on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('subsequent polls skip on_wake=1 messages', () => {
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(0);
});
it('normal messages returned regardless of isFirstPoll', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
expect(getPendingMessages(true)).toHaveLength(1);
// Reset: mark completed so we can re-test with a fresh message
markCompleted(['m1']);
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
expect(getPendingMessages(false)).toHaveLength(1);
});
it('mixed batch: first poll returns both normal and on_wake messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(true);
expect(messages).toHaveLength(2);
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
});
it('mixed batch: subsequent poll returns only normal messages', () => {
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
const messages = getPendingMessages(false);
expect(messages).toHaveLength(1);
expect(messages[0].id).toBe('m1');
});
it('on_wake defaults to 0 for inserts without explicit value', () => {
getInboundDb()
.prepare(
`INSERT INTO messages_in (id, kind, timestamp, status, content)
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
)
.run();
// Should be returned even on non-first poll (on_wake=0)
expect(getPendingMessages(false)).toHaveLength(1);
});
});
describe('routing', () => {
it('should extract routing from messages', () => {
getInboundDb()
+24 -21
View File
@@ -67,11 +67,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
clearStaleProcessingAcks();
let pollCount = 0;
let isFirstPoll = true;
while (true) {
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
isFirstPoll = false;
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
pollCount++;
// Periodic heartbeat so we know the loop is alive
@@ -265,7 +263,6 @@ async function processQuery(
): Promise<QueryResult> {
let queryContinuation: string | undefined;
let done = false;
let unwrappedNudged = false;
// Concurrent polling: push follow-ups into the active query as they arrive.
// We do NOT force-end the stream on silence — keeping the query open avoids
@@ -339,7 +336,6 @@ async function processQuery(
const keptIds = keep.map((m) => m.id);
const prompt = formatMessages(keep);
log(`Pushing ${keep.length} follow-up message(s) into active query`);
unwrappedNudged = false;
query.push(prompt);
markCompleted(keptIds);
} catch (err) {
@@ -378,18 +374,24 @@ async function processQuery(
// at all — either way the turn is finished.
markCompleted(initialBatchIds);
if (event.text) {
const { hasUnwrapped } = dispatchResultText(event.text, routing);
if (hasUnwrapped && !unwrappedNudged) {
unwrappedNudged = true;
const destinations = getAllDestinations();
const names = destinations.map((d) => d.name).join(', ');
query.push(
`<system>Your response was not delivered — it was not wrapped in <message to="name">...</message> blocks. ` +
`All output must be wrapped: use <message to="name"> for content to send, or <internal> for scratchpad. ` +
`Your destinations: ${names}. ` +
`Please re-send your response with the correct wrapping.</system>`,
);
}
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.`,
);
}
}
}
@@ -417,6 +419,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
case 'progress':
log(`Progress: ${event.message}`);
break;
case 'compacted':
log(`Compacted: ${event.text}`);
break;
}
}
@@ -428,7 +433,7 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
* The agent must always wrap output in <message to="name">...</message>
* blocks, even with a single destination. Bare text is scratchpad only.
*/
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
function dispatchResultText(text: string, routing: RoutingContext): void {
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
let match: RegExpExecArray | null;
@@ -463,11 +468,9 @@ function dispatchResultText(text: string, routing: RoutingContext): { sent: numb
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
}
const hasUnwrapped = sent === 0 && !!scratchpad;
if (hasUnwrapped) {
if (sent === 0 && text.trim()) {
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
}
return { sent, hasUnwrapped };
}
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
@@ -257,15 +257,11 @@ export class ClaudeProvider implements AgentProvider {
private mcpServers: Record<string, McpServerConfig>;
private env: Record<string, string | undefined>;
private additionalDirectories?: string[];
private model?: string;
private effort?: string;
constructor(options: ProviderOptions = {}) {
this.assistantName = options.assistantName;
this.mcpServers = options.mcpServers ?? {};
this.additionalDirectories = options.additionalDirectories;
this.model = options.model;
this.effort = options.effort;
this.env = {
...(options.env ?? {}),
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
@@ -297,9 +293,6 @@ export class ClaudeProvider implements AgentProvider {
],
disallowedTools: SDK_DISALLOWED_TOOLS,
env: this.env,
model: this.model,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
effort: this.effort as any,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'],
@@ -336,7 +329,7 @@ export class ClaudeProvider implements AgentProvider {
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'compact_boundary') {
const meta = (message as { compact_metadata?: { pre_tokens?: number } }).compact_metadata;
const detail = meta?.pre_tokens ? ` (${meta.pre_tokens.toLocaleString()} tokens compacted)` : '';
yield { type: 'result', text: `Context compacted${detail}.` };
yield { type: 'compacted', text: `Context compacted${detail}.` };
} else if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
const tn = message as { summary?: string };
yield { type: 'progress', message: tn.summary || 'Task notification' };
+9 -11
View File
@@ -25,16 +25,6 @@ export interface ProviderOptions {
mcpServers?: Record<string, McpServerConfig>;
env?: Record<string, string | undefined>;
additionalDirectories?: string[];
/**
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
* to the underlying SDK. If omitted, the SDK default is used.
*/
model?: string;
/**
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
* through to the underlying SDK. If omitted, the SDK default is used.
*/
effort?: string;
}
export interface QueryInput {
@@ -89,4 +79,12 @@ export type ProviderEvent =
* event (tool call, thinking, partial message, anything) so the
* poll-loop's idle timer stays honest during long tool runs.
*/
| { type: 'activity' };
| { type: 'activity' }
/**
* The provider's underlying SDK auto-compacted the conversation context.
* The poll-loop reacts by injecting a destination reminder back into
* the live query so the agent doesn't drop `<message to="…">` wrapping
* after compaction. Distinct from `result` so it doesn't mark the turn
* completed or get dispatched as a chat message. See qwibitai/nanoclaw#2325.
*/
| { type: 'compacted'; text: string };
@@ -4,4 +4,4 @@ Your HTTP requests go through the OneCLI proxy, which injects real credentials a
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.
If you get a `401`/`403`/`app_not_connected`, run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens — if credentials are missing, the fix is connecting the service in OneCLI.
+1 -1
View File
@@ -9,7 +9,7 @@ You've just been connected to a new user. This your time to shine and make a str
## What to do
1. Send a short, warm greeting
1. Send a short, warm greeting using `send_message`
2. State your name (from your system prompt / CLAUDE.md)
3. Signal that you're capable of a lot — but don't list everything upfront. Be intriguing, not encyclopedic
4. Ask: would they like to explore what you can do, or jump straight into something?
+1 -1
View File
@@ -2,7 +2,7 @@
## Structure
**`nanocoai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
+1 -29
View File
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
### 1.1 `agent_groups`
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` 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.
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.
```sql
CREATE TABLE agent_groups (
@@ -294,32 +294,6 @@ 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
@@ -339,8 +313,6 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
+13 -16
View File
@@ -33,22 +33,19 @@ Every message landing in the session: user chat, scheduled task, recurring task,
```sql
CREATE TABLE messages_in (
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL, -- JSON; shape depends on kind
source_session_id TEXT, -- agent-to-agent return path
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
id TEXT PRIMARY KEY,
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
kind TEXT NOT NULL,
timestamp TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
process_after TEXT,
recurrence TEXT, -- cron expr for recurring
series_id TEXT, -- groups occurrences of a recurring task
tries INTEGER DEFAULT 0,
platform_id TEXT,
channel_type TEXT,
thread_id TEXT,
content TEXT NOT NULL -- JSON; shape depends on kind
);
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
```
+2 -2
View File
@@ -77,7 +77,7 @@ NanoClaw must live inside the workspace directory — Docker-in-Docker can only
```bash
# Clone to home first (virtiofs can corrupt git pack files during clone)
cd ~
git clone https://github.com/nanocoai/nanoclaw.git
git clone https://github.com/qwibitai/nanoclaw.git
# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
WORKSPACE=/Users/you/nanoclaw-workspace
@@ -347,7 +347,7 @@ docker sandbox network proxy <sandbox-name> \
### Git clone fails with "inflate: data stream error"
Clone to a non-workspace path first, then move:
```bash
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
```
### WhatsApp QR code doesn't display
+22 -22
View File
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
### Repository structure
The upstream repo (`nanocoai/nanoclaw`) maintains:
The upstream repo (`qwibitai/nanoclaw`) maintains:
- `main` — core NanoClaw (no skill code)
- `skill/discord` — main + Discord integration
@@ -46,7 +46,7 @@ Skills are split into two categories:
**Feature skills** (in marketplace, installed on demand):
- `/add-discord`, `/add-telegram`, `/add-slack`, `/add-gmail`, etc.
- Each has a SKILL.md with setup instructions and a corresponding `skill/*` branch with code
- Live in the marketplace repo (`nanocoai/nanoclaw-skills`)
- Live in the marketplace repo (`qwibitai/nanoclaw-skills`)
Users never interact with the marketplace directly. The operational skills `/setup` and `/customize` handle plugin installation transparently:
@@ -78,7 +78,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
}
}
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
The marketplace repo uses Claude Code's plugin structure:
```
nanocoai/nanoclaw-skills/
qwibitai/nanoclaw-skills/
.claude-plugin/
marketplace.json # Plugin catalog
plugins/
@@ -213,7 +213,7 @@ A GitHub Action runs on every push to `main`:
### New users (recommended)
1. Fork `nanocoai/nanoclaw` on GitHub (click the Fork button)
1. Fork `qwibitai/nanoclaw` on GitHub (click the Fork button)
2. Clone your fork:
```bash
git clone https://github.com/<you>/nanoclaw.git
@@ -229,9 +229,9 @@ Forking is recommended because it gives users a remote to push their customizati
### Existing users migrating from clone
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
Users who previously ran `git clone https://github.com/qwibitai/nanoclaw.git` and have local customizations:
1. Fork `nanocoai/nanoclaw` on GitHub
1. Fork `qwibitai/nanoclaw` on GitHub
2. Reroute remotes:
```bash
git remote rename origin upstream
@@ -239,7 +239,7 @@ Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` an
git push --force origin main
```
The `--force` is needed because the fresh fork's main is at upstream's latest, but the user wants their (possibly behind) version. The fork was just created so there's nothing to lose.
3. From this point, `origin` = their fork, `upstream` = nanocoai/nanoclaw
3. From this point, `origin` = their fork, `upstream` = qwibitai/nanoclaw
### Existing users migrating from the old skills engine
@@ -316,7 +316,7 @@ git fetch upstream main
git checkout -b my-fix upstream/main
# Make changes
git push origin my-fix
# Create PR from my-fix to nanocoai/nanoclaw:main
# Create PR from my-fix to qwibitai/nanoclaw:main
```
Standard fork contribution workflow. Their custom changes stay on their main and don't leak into the PR.
@@ -327,7 +327,7 @@ The flow below is for **feature skills** (branch-based). For utility skills (sel
### Contributor flow (feature skills)
1. Fork `nanocoai/nanoclaw`
1. Fork `qwibitai/nanoclaw`
2. Branch from `main`
3. Make the code changes (new channel file, modified integration points, updated package.json, .env.example additions, etc.)
4. Open a PR to `main`
@@ -345,7 +345,7 @@ When a skill PR is reviewed and approved:
```
2. Force-push to the contributor's PR branch, replacing it with a single commit that adds the contributor to `CONTRIBUTORS.md` (removing all code changes)
3. Merge the slimmed PR into `main` (just the contributor addition)
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/nanoclaw-skills`)
4. Add the skill's SKILL.md to the marketplace repo (`qwibitai/nanoclaw-skills`)
This way:
- The contributor gets merge credit (their PR is merged)
@@ -388,7 +388,7 @@ If the community contributor is trusted, they can open a PR to add their marketp
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
},
"alice-nanoclaw-skills": {
@@ -434,7 +434,7 @@ A flavor is a curated fork of NanoClaw — a combination of skills, custom chang
### Creating a flavor
1. Fork `nanocoai/nanoclaw`
1. Fork `qwibitai/nanoclaw`
2. Merge in the skills you want
3. Make custom changes (trigger word, prompts, integrations, etc.)
4. Your fork's `main` IS the flavor
@@ -462,7 +462,7 @@ Then setup continues normally (dependencies, auth, container, service).
After installation, the user's fork has three remotes:
- `origin` — their fork (push customizations here)
- `upstream``nanocoai/nanoclaw` (core updates)
- `upstream``qwibitai/nanoclaw` (core updates)
- `<flavor-name>` — the flavor fork (flavor updates)
### Updating a flavor
@@ -538,14 +538,14 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
Before:
```bash
git clone https://github.com/nanocoai/NanoClaw.git
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
```
After:
```
1. Fork nanocoai/nanoclaw on GitHub
1. Fork qwibitai/nanoclaw on GitHub
2. git clone https://github.com/<you>/nanoclaw.git
3. cd nanoclaw
4. claude
@@ -556,8 +556,8 @@ After:
Updates to the setup flow:
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/nanocoai/nanoclaw.git`
- Check if `origin` points to the user's fork (not nanocoai). If it points to nanocoai, guide them through the fork migration.
- Check if `upstream` remote exists; if not, add it: `git remote add upstream https://github.com/qwibitai/nanoclaw.git`
- Check if `origin` points to the user's fork (not qwibitai). If it points to qwibitai, guide them through the fork migration.
- **Install marketplace plugin:** `claude plugin install nanoclaw-skills@nanoclaw-skills --scope project` — makes all feature skills available (hot-loaded, no restart)
- **Ask which channels to add:** present channel options (Discord, Telegram, Slack, WhatsApp, Gmail), run corresponding `/add-*` skills for selected channels
- **Offer dependent skills:** after a channel is set up, offer relevant add-ons (e.g., Agent Swarm after Telegram, voice transcription after WhatsApp)
@@ -573,7 +573,7 @@ Marketplace configuration so the official marketplace is auto-registered:
"nanoclaw-skills": {
"source": {
"source": "github",
"repo": "nanocoai/nanoclaw-skills"
"repo": "qwibitai/nanoclaw-skills"
}
}
}
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
### New infrastructure
- **Marketplace repo** (`nanocoai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
- **CI GitHub Action** — merge-forward `main` into all `skill/*` branches on every push to `main`, using Claude (Haiku) for conflict resolution
- **`/update-skills` skill** — checks for and applies skill branch updates using git history
- **`CONTRIBUTORS.md`** — tracks skill contributors
@@ -650,7 +650,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **We now recommend forking instead of cloning.** This gives you a remote to push your customizations to.
>
> **If you currently have a clone with local changes**, migrate to a fork:
> 1. Fork `nanocoai/nanoclaw` on GitHub
> 1. Fork `qwibitai/nanoclaw` on GitHub
> 2. Run:
> ```
> git remote rename origin upstream
@@ -668,7 +668,7 @@ Users only need to re-merge a skill branch if the skill itself was updated (not
> **Contributing skills**
>
> To contribute a skill:
> 1. Fork `nanocoai/nanoclaw`
> 1. Fork `qwibitai/nanoclaw`
> 2. Branch from `main` and make your code changes
> 3. Open a regular PR
>
+1 -1
View File
@@ -240,7 +240,7 @@ if [ "$(uname -s)" = "Linux" ] && [ "$(id -u)" -eq 0 ]; then
printf ' %s\n' "$(dim '3. Enable passwordless sudo: echo "nanoclaw ALL=(ALL) NOPASSWD:ALL" | tee /etc/sudoers.d/nanoclaw')"
printf ' %s\n' "$(dim '4. Log out: exit')"
printf ' %s\n' "$(dim '5. Log back in as the new user: ssh nanoclaw@your-server')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/qwibitai/nanoclaw.git && cd nanoclaw')"
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
exit 1
;;
+1 -5
View File
@@ -1,13 +1,10 @@
{
"name": "nanoclaw",
"version": "2.0.63",
"version": "2.0.44",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
"main": "dist/index.js",
"bin": {
"ncl": "bin/ncl"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
@@ -19,7 +16,6 @@
"prepare": "husky",
"setup": "tsx setup/index.ts",
"setup:auto": "tsx setup/auto.ts",
"ncl": "tsx src/cli/client.ts",
"chat": "tsx scripts/chat.ts",
"auth": "tsx src/whatsapp-auth.ts",
"lint": "eslint src/",
+3 -3
View File
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
## Usage
```yaml
- uses: nanocoai/nanoclaw/repo-tokens@v1
- uses: qwibitai/nanoclaw/repo-tokens@v1
with:
include: 'src/**/*.ts'
exclude: 'src/**/*.test.ts'
@@ -34,7 +34,7 @@ Repos using repo-tokens:
| Repo | Badge |
|------|-------|
| [NanoClaw](https://github.com/nanocoai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/nanocoai/NanoClaw/main/repo-tokens/badge.svg) |
| [NanoClaw](https://github.com/qwibitai/NanoClaw) | ![tokens](https://raw.githubusercontent.com/qwibitai/NanoClaw/main/repo-tokens/badge.svg) |
### Full workflow example
@@ -59,7 +59,7 @@ jobs:
with:
python-version: '3.12'
- uses: nanocoai/nanoclaw/repo-tokens@v1
- uses: qwibitai/nanoclaw/repo-tokens@v1
id: tokens
with:
include: 'src/**/*.ts'
+2 -2
View File
@@ -114,7 +114,7 @@ runs:
with open(readme_path, "r", encoding="utf-8") as f:
content = f.read()
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
linked_badge = f'<a href="{repo_tokens_url}">{badge}</a>'
new_content = marker_re.sub(rf"\1{linked_badge}\2", content)
@@ -148,7 +148,7 @@ runs:
lx = label_w // 2
vx = label_w + value_w // 2
repo_tokens_url = "https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens"
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="{total_w}" height="20" role="img" aria-label="{full_desc}">
<title>{full_desc}</title>
+5 -5
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="174k tokens, 87% of context window">
<title>174k tokens, 87% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="149k tokens, 75% of context window">
<title>149k tokens, 75% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -7,7 +7,7 @@
<clipPath id="r">
<rect width="90" height="20" rx="3" fill="#fff"/>
</clipPath>
<a xlink:href="https://github.com/nanocoai/nanoclaw/tree/main/repo-tokens">
<a xlink:href="https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens">
<g clip-path="url(#r)">
<rect width="52" height="20" fill="#555"/>
<rect x="52" width="38" height="20" fill="#e05d44"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">174k</text>
<text x="71" y="14">174k</text>
<text aria-hidden="true" x="71" y="15" fill="#010101" fill-opacity=".3">149k</text>
<text x="71" y="14">149k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+2 -2
View File
@@ -1,5 +1,5 @@
/**
* ncl chat with your NanoClaw agent from the terminal.
* nc chat with your NanoClaw agent from the terminal.
*
* Usage:
* pnpm run chat <message...>
@@ -36,7 +36,7 @@ function main(): void {
const e = err as NodeJS.ErrnoException;
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
console.error('Start the service (launchctl/systemd) before running ncl.');
console.error('Start the service (launchctl/systemd) before running nc.');
} else {
console.error('CLI socket error:', err);
}
-3
View File
@@ -47,7 +47,6 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
import { upsertUser } from '../src/modules/permissions/db/users.js';
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
import { initGroupFilesystem } from '../src/group-init.js';
import { namespacedPlatformId } from '../src/platform-id.js';
import type { AgentGroup, MessagingGroup } from '../src/types.js';
@@ -232,8 +231,6 @@ async function main(): Promise<void> {
granted_at: now,
});
}
// Owner's agent group gets global CLI access
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
} else if (args.role === 'admin') {
const alreadyAdmin = existingRoles.some(
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
+3 -3
View File
@@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
import { runWhatsAppChannel } from './channels/whatsapp.js';
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
import { brightSelect } from './lib/bright-select.js';
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
import { offerClaudeAssist } from './lib/claude-assist.js';
import {
applyToEnv,
parseFlags,
@@ -416,7 +416,7 @@ async function main(): Promise<void> {
} else {
phEmit('first_chat_failed', { reason: ping });
renderPingFailureNote(ping);
await offerClaudeOnFailure({
await offerClaudeAssist({
stepName: 'cli-agent',
msg:
ping === 'socket_error'
@@ -528,7 +528,7 @@ async function main(): Promise<void> {
service_running: res.terminal?.fields.SERVICE === 'running',
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
});
await offerClaudeOnFailure({
await offerClaudeAssist({
stepName: 'verify',
msg: summary || 'Verification completed with unresolved issues.',
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
+2 -3
View File
@@ -146,7 +146,6 @@ async function walkThroughAppCreation(): Promise<'continue' | 'back'> {
' • chat:write',
' • users:read',
' • reactions:write',
' • files:read, files:write',
' 3. App Home → enable "Messages Tab" and "Allow users to send',
' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"',
@@ -318,9 +317,9 @@ async function collectSlackUserId(): Promise<string> {
[
"To get your Slack member ID:",
'',
' 1. In Slack, click your profile picture (bottom left)',
' 1. In Slack, click your profile picture (top right)',
' 2. Click "Profile"',
' 3. Click the three dots () → "Copy member ID"',
' 3. Click the three dots () → "Copy member ID"',
].join('\n'),
'Find your Slack user ID',
);
Executable → Regular
+4 -4
View File
@@ -6,10 +6,10 @@
# `upstream`, with `origin` pointing at the user's fork. The channels branch
# only lives upstream, so a hardcoded `git fetch origin channels` fails for
# forks. This helper walks `git remote -v`, picks the remote whose URL points
# at nanocoai/nanoclaw, and prints its name.
# at qwibitai/nanoclaw, and prints its name.
#
# Fallback: if no existing remote matches, add `upstream` pointing at
# github.com/nanocoai/nanoclaw and return that — keeps forks without an
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
# explicit upstream configured working on the first try.
#
# Explicit override: set NANOCLAW_CHANNELS_REMOTE=<name> to skip detection.
@@ -23,7 +23,7 @@ resolve_channels_remote() {
local remote url
while IFS=$'\t' read -r remote url; do
case "$url" in
*qwibitai/nanoclaw*|*nanocoai/nanoclaw*)
*qwibitai/nanoclaw*)
printf '%s' "$remote"
return 0
;;
@@ -33,6 +33,6 @@ resolve_channels_remote() {
# No matching remote — add `upstream` and use it. Silent on failure so
# callers see the eventual `git fetch` error rather than a cryptic
# remote-add failure.
git remote add upstream https://github.com/nanocoai/nanoclaw.git 2>/dev/null || true
git remote add upstream https://github.com/qwibitai/nanoclaw.git 2>/dev/null || true
printf '%s' "upstream"
}
+3 -3
View File
@@ -43,7 +43,7 @@ export interface AssistContext {
* rather than us stuffing contents into the prompt. Keys are step names as
* they appear in fail() calls; values are repo-relative paths.
*/
export const STEP_FILES: Record<string, string[]> = {
const STEP_FILES: Record<string, string[]> = {
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
environment: ['setup/environment.ts'],
container: [
@@ -81,7 +81,7 @@ export const STEP_FILES: Record<string, string[]> = {
],
};
export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
/**
* Returns `true` if the user ran a Claude-suggested fix command; callers
@@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean {
}
}
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
if (!isClaudeInstalled()) {
const install = ensureAnswer(
await p.confirm({
-116
View File
@@ -23,19 +23,10 @@
* attempting to parse it as a real answer.
*/
import { execSync, spawn } from 'child_process';
import path from 'path';
import * as p from '@clack/prompts';
import k from 'kleur';
import {
type AssistContext,
BIG_PICTURE_FILES,
ensureClaudeReady,
offerClaudeAssist,
STEP_FILES,
} from './claude-assist.js';
import { ensureAnswer } from './runner.js';
import { brandBody, note } from './theme.js';
export interface HandoffContext {
@@ -203,110 +194,3 @@ function buildSystemPrompt(ctx: HandoffContext): string {
return lines.join('\n');
}
/**
* Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either
* the interactive failure handoff (default) or the non-interactive assist.
*
* Drop-in replacement for `offerClaudeAssist` at failure call sites.
*/
export async function offerClaudeOnFailure(
ctx: AssistContext,
projectRoot: string = process.cwd(),
): Promise<boolean> {
if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') {
return offerClaudeAssist(ctx, projectRoot);
}
return offerFailureHandoff(ctx, projectRoot);
}
/**
* Interactive Claude handoff for setup failures. Same role as
* `offerClaudeAssist` but spawns an interactive session instead of
* parsing a structured REASON/COMMAND response.
*
* Returns `true` if Claude was launched (the user may have fixed
* things during the session), `false` if skipped/declined/unavailable.
*/
async function offerFailureHandoff(
ctx: AssistContext,
projectRoot: string,
): Promise<boolean> {
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
if (!(await ensureClaudeReady(projectRoot))) return false;
const want = ensureAnswer(
await p.confirm({
message: 'Want to debug this with Claude?',
initialValue: true,
}),
);
if (!want) return false;
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
note(
[
"Launching Claude to help debug this failure.",
"It has the context of what went wrong.",
"",
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
].join('\n'),
'Handing off to Claude',
);
return new Promise<boolean>((resolve) => {
const child = spawn(
'claude',
[
'--append-system-prompt',
systemPrompt,
'--permission-mode',
'acceptEdits',
],
{ stdio: 'inherit' },
);
child.on('close', () => {
p.log.success(brandBody("Back from Claude. Let's continue."));
resolve(true);
});
child.on('error', () => {
p.log.error("Couldn't launch Claude. Continuing without handoff.");
resolve(false);
});
});
}
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
const references = [
...BIG_PICTURE_FILES,
...stepRefs,
'logs/setup.log',
ctx.rawLogPath
? path.relative(projectRoot, ctx.rawLogPath)
: 'logs/setup-steps/',
].filter((v, i, a) => a.indexOf(v) === i);
const lines: string[] = [
"The user is running NanoClaw's interactive setup flow and hit a failure.",
'',
`Failed step: ${ctx.stepName}`,
`Error: ${ctx.msg}`,
];
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
lines.push(
'',
'Your job: help them diagnose and fix this issue. Read the referenced files',
'and logs to understand what went wrong, then help them fix it. You can read',
'files, run commands, check logs, and explain what happened. Be concise.',
"When they're ready to resume setup, tell them to type /exit.",
'',
'Relevant files (read as needed with the Read tool):',
);
for (const f of references) lines.push(` - ${f}`);
return lines.join('\n');
}
+2 -2
View File
@@ -18,7 +18,7 @@ import * as p from '@clack/prompts';
import k from 'kleur';
import * as setupLog from '../logs.js';
import { offerClaudeOnFailure } from './claude-handoff.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
@@ -367,7 +367,7 @@ export async function fail(
if (hint) p.log.message(k.dim(hint));
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath });
const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath });
// If the user just ran a Claude-suggested fix, offer to resume the flow
// at the step that failed instead of aborting. We re-exec via spawnSync
-9
View File
@@ -123,15 +123,6 @@ export const CONFIG: Entry[] = [
surface: 'flag',
type: 'string',
},
{
key: 'assistMode',
envVar: 'NANOCLAW_SETUP_ASSIST_MODE',
label: 'Assist mode',
help: 'Use non-interactive Claude assist on failure instead of interactive handoff.',
surface: 'flag',
type: 'boolean',
default: false,
},
];
// ─── name derivation ───────────────────────────────────────────────────
+2 -2
View File
@@ -18,7 +18,7 @@
import * as p from '@clack/prompts';
import k from 'kleur';
import { offerClaudeOnFailure } from './claude-handoff.js';
import { offerClaudeAssist } from './claude-assist.js';
import { emit as phEmit } from './diagnostics.js';
import type { StepResult, SpinnerLabels } from './runner.js';
import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js';
@@ -212,7 +212,7 @@ async function handleStall(
// offerClaudeAssist runs its own spinner and may propose a fix command.
// We don't attempt to restart the stalled build from here — if Claude
// proposes a command the user accepts, they can retry setup afterwards.
await offerClaudeOnFailure({
await offerClaudeAssist({
stepName,
msg: `The ${stepName} step has produced no output for 60 seconds.`,
hint: 'It may be hung on a slow network pull or a failing Dockerfile step.',
+1 -1
View File
@@ -66,7 +66,7 @@ async function getJson<T>(url: string, token: string, fetchImpl: FetchFn): Promi
const res = await fetchImpl(url, {
headers: {
Authorization: `Bot ${token}`,
'User-Agent': 'NanoClaw-Migration (https://github.com/nanocoai/nanoclaw, 2.x)',
'User-Agent': 'NanoClaw-Migration (https://github.com/qwibitai/nanoclaw, 2.x)',
},
});
if (!res.ok) {
+1 -2
View File
@@ -105,7 +105,6 @@ function writeEnvOnecliUrl(url: string): void {
// Last-known-good CLI release. Used only if BOTH the upstream installer
// and the redirect-based version probe fail. Bump deliberately when a
// new CLI release ships.
const ONECLI_GATEWAY_VERSION = '1.23.0';
const ONECLI_CLI_FALLBACK_VERSION = '1.3.0';
const ONECLI_CLI_REPO = 'onecli/onecli-cli';
@@ -154,7 +153,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
if (cleanup) stdout += cleanup + '\n';
// Gateway install (docker-compose based, no rate-limit concerns).
const gw = runInstall(`export ONECLI_VERSION=${ONECLI_GATEWAY_VERSION} && curl -fsSL onecli.sh/install | sh`);
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
stdout += gw.stdout;
if (!gw.ok) {
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
-35
View File
@@ -82,41 +82,6 @@ export async function run(_args: string[]): Promise<void> {
});
process.exit(1);
}
installCliSymlink(projectRoot, homeDir);
}
/**
* Symlink bin/ncl into ~/.local/bin so `ncl` is available from anywhere.
* Idempotent overwrites an existing symlink but won't clobber a real file.
*/
function installCliSymlink(projectRoot: string, homeDir: string): void {
const source = path.join(projectRoot, 'bin', 'ncl');
const targetDir = path.join(homeDir, '.local', 'bin');
const target = path.join(targetDir, 'ncl');
try {
fs.mkdirSync(targetDir, { recursive: true });
// Remove existing symlink (but not a real file)
try {
const stat = fs.lstatSync(target);
if (stat.isSymbolicLink()) {
fs.unlinkSync(target);
} else {
log.warn('~/.local/bin/ncl exists and is not a symlink — skipping', { target });
return;
}
} catch (e) {
const err = e as NodeJS.ErrnoException;
if (err.code !== 'ENOENT') throw err;
}
fs.symlinkSync(source, target);
log.info('Installed ncl CLI symlink', { target, source });
} catch (err) {
log.warn('Could not install ncl CLI symlink (non-fatal)', { err });
}
}
function setupLaunchd(
-78
View File
@@ -1,78 +0,0 @@
/**
* 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 });
}
}
+4 -10
View File
@@ -18,8 +18,7 @@ import fs from 'fs';
import path from 'path';
import { GROUPS_DIR } from './config.js';
import type { McpServerConfig } from './container-config.js';
import { getContainerConfig } from './db/container-configs.js';
import { readContainerConfig } from './container-config.js';
import { log } from './log.js';
import type { AgentGroup } from './types.js';
@@ -55,10 +54,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
}
// Desired fragment set.
const configRow = getContainerConfig(group.id);
const mcpServers: Record<string, McpServerConfig> = configRow
? (JSON.parse(configRow.mcp_servers) as Record<string, McpServerConfig>)
: {};
const config = readContainerConfig(group.folder);
const desired = new Map<string, { type: 'symlink' | 'inline'; content: string }>();
// Skill fragments — every skill that ships an `instructions.md`.
@@ -79,15 +75,13 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// Built-in module fragments — every MCP tool source file that ships a
// sibling `<name>.instructions.md`. These describe how the agent should
// use that module's MCP tools (schedule_task, install_packages, etc.).
// Skip cli.instructions.md when cli_scope is disabled.
const cliDisabled = configRow?.cli_scope === 'disabled';
// Always included — these are built-in, not toggleable.
const mcpToolsHostDir = path.join(process.cwd(), MCP_TOOLS_HOST_SUBPATH);
if (fs.existsSync(mcpToolsHostDir)) {
for (const entry of fs.readdirSync(mcpToolsHostDir)) {
const match = entry.match(/^(.+)\.instructions\.md$/);
if (!match) continue;
const moduleName = match[1];
if (moduleName === 'cli' && cliDisabled) continue;
desired.set(`module-${moduleName}.md`, {
type: 'symlink',
content: `${SHARED_MCP_TOOLS_CONTAINER_BASE}/${entry}`,
@@ -97,7 +91,7 @@ export function composeGroupClaudeMd(group: AgentGroup): void {
// MCP server fragments — inline instructions from container.json for
// user-added external MCP servers.
for (const [name, mcp] of Object.entries(mcpServers)) {
for (const [name, mcp] of Object.entries(config.mcpServers)) {
if (mcp.instructions) {
desired.set(`mcp-${name}.md`, {
type: 'inline',
-112
View File
@@ -1,112 +0,0 @@
/**
* `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';
import { formatTransportError } from './transport-errors.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'),
);
}
main().catch((err) => {
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(2);
});
-137
View File
@@ -1,137 +0,0 @@
/**
* 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
View File
@@ -1,10 +0,0 @@
/**
* 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();
-299
View File
@@ -1,299 +0,0 @@
/**
* 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;
/**
* Column that carries the agent group ID for group-scope enforcement.
* Required on every resource in the CLI whitelist (groups, sessions,
* destinations, members). When absent, post-handler filtering fails closed.
*/
scopeField?: 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,
generic: 'list',
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,
generic: 'get',
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
View File
@@ -1,59 +0,0 @@
/**
* 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 });
});
-514
View File
@@ -1,514 +0,0 @@
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),
}));
// dispatch's post-handler looks up the resource's `scopeField` via getResource.
// The real resources aren't registered in this unit test, so mock it.
const mockGetResource = vi.fn();
vi.mock('./crud.js', () => ({
getResource: (...args: unknown[]) => mockGetResource(...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',
generic: 'list',
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',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({
id: args.id,
agent_group_id: (args as Record<string, unknown>).belongs_to ?? 'g1',
}),
});
// A custom op under the `groups` resource that returns a config-shaped object
// (no `id` key). The post-handler must not touch this — only `generic` handlers.
register({
name: 'groups-config-get',
description: 'custom op returning a config object (no id)',
resource: 'groups',
access: 'open',
parseArgs: (raw) => raw,
handler: async () => ({ agent_group_id: 'g1', model: 'opus' }),
});
// The real `sessions-get` name — triggers the pre-handler ownership check.
register({
name: 'sessions-get',
description: 'generic sessions get',
resource: 'sessions',
access: 'open',
generic: 'get',
parseArgs: (raw) => raw,
handler: async (args) => ({ id: (args as Record<string, unknown>).id, agent_group_id: 'g1' }),
});
import { dispatch } from './dispatch.js';
import type { CallerContext } from './frame.js';
beforeEach(() => {
vi.clearAllMocks();
// Default: the four CLI-whitelisted resources with their real scopeFields.
const scopeFields: Record<string, string> = {
groups: 'id',
sessions: 'agent_group_id',
destinations: 'agent_group_id',
members: 'agent_group_id',
};
mockGetResource.mockImplementation((plural: string) =>
scopeFields[plural] ? { scopeField: scopeFields[plural] } : undefined,
);
});
// --- 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
}
});
// --- Custom ops bypass post-handler row filtering (regression: #2392 review) ---
it('group: a custom op returning a non-row object is not falsely rejected', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
// groups-config-get is access:open and reachable by a group-scoped agent;
// it returns { agent_group_id, model } with no `id` field. Before this fix
// the post-handler compared data['id'] (undefined) and returned forbidden.
const resp = await dispatch({ id: '1', command: 'groups-config-get', args: {} }, agentCtx());
expect(resp.ok).toBe(true);
if (resp.ok) {
expect((resp.data as { model: string }).model).toBe('opus');
}
});
// --- sessions-get pre-handler ownership check (no existence oracle) ---
it('group: sessions-get returns "session not found" for a foreign session UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-x', agent_group_id: 'other-group' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-x' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get returns "session not found" for a non-existent UUID', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue(undefined);
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-nope' } }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('handler-error');
expect(resp.error.message).toContain('session not found');
}
});
it('group: sessions-get allows the callers own session', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetSession.mockReturnValue({ id: 's-mine', agent_group_id: 'g1' });
const resp = await dispatch({ id: '1', command: 'sessions-get', args: { id: 's-mine' } }, agentCtx());
expect(resp.ok).toBe(true);
});
// --- Fail-closed regression guard for a missing scopeField ---
it('group: generic list/get fails closed when the resource declares no scopeField', async () => {
mockGetContainerConfig.mockReturnValue({ cli_scope: 'group' });
mockGetResource.mockReturnValue(undefined); // a whitelisted resource that forgot scopeField
const resp = await dispatch({ id: '1', command: 'groups-list-data', args: {} }, agentCtx());
expect(resp.ok).toBe(false);
if (!resp.ok) {
expect(resp.error.code).toBe('forbidden');
expect(resp.error.message).toContain('not available in group scope');
}
});
});
-199
View File
@@ -1,199 +0,0 @@
/**
* 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 { getResource } from './crud.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 } };
// Fail-closed pre-handler check for sessions-get: returns "not found"
// regardless of whether the UUID exists in another group, preventing an
// existence oracle across group boundaries.
if (cmd.resource === 'sessions' && req.command === 'sessions-get' && req.args.id) {
const s = getSession(req.args.id as string);
if (!s || s.agent_group_id !== ctx.agentGroupId) {
return err(req.id, 'handler-error', `session not found: ${req.args.id}`);
}
}
}
}
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. Applies only to the auto-generated
// `list` / `get` handlers (`cmd.generic`), which return raw DB rows carrying
// the resource's `scopeField`:
// - `list` → drop rows that don't belong to the caller's agent group
// (covers `groups list`, where the generic list handler ignores
// the auto-filled `--id`)
// - `get` → reject if the single row belongs to another group
// Custom operations return ad-hoc shapes (e.g. `groups config get` → a config
// object with no `id`) and are NOT checked here — they would be falsely
// rejected, and they're already pinned to the caller's group by the
// pre-handler `--id` auto-fill (groups/destinations) or gated behind approval,
// so they can't reach another group's data anyway.
if (ctx.caller === 'agent' && cmd.resource && cmd.generic) {
const configRow = getContainerConfig(ctx.agentGroupId);
if ((configRow?.cli_scope ?? 'group') === 'group') {
const def = getResource(cmd.resource);
const groupField = def?.scopeField;
if (!groupField) {
// Fail closed: a whitelisted resource exposing list/get must declare
// `scopeField` so its rows can be filtered.
return err(req.id, 'forbidden', `"${cmd.resource}" is not available in group scope.`);
}
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') {
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
View File
@@ -1,52 +0,0 @@
/**
* 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
View File
@@ -1,45 +0,0 @@
/**
* 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;
};
-45
View File
@@ -1,45 +0,0 @@
/**
* 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;
/**
* Set on the auto-generated `list` / `get` handlers (see `registerResource`).
* These return raw DB rows that carry the resource's `scopeField`, so the
* dispatcher applies post-handler group-scope filtering to their output.
* Custom operations return ad-hoc shapes and leave this undefined.
*/
generic?: 'list' | 'get';
/** 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
View File
@@ -1,53 +0,0 @@
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' },
});
-78
View File
@@ -1,78 +0,0 @@
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',
scopeField: '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
View File
@@ -1,28 +0,0 @@
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' },
});
-283
View File
@@ -1,283 +0,0 @@
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',
scopeField: '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.',
};
},
},
},
});

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