mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9141218ad | |||
| 341b5950e1 | |||
| 8cb4ed27ef | |||
| 729cd8d2a6 | |||
| 3601a8a1fe | |||
| 991969085e | |||
| 81d99e1dc9 | |||
| 24922593e3 | |||
| b142055a1f | |||
| 0c5104df68 | |||
| cabc7c0f82 | |||
| 3e533413e5 | |||
| c76ecb43f8 | |||
| 9dc9efa3bf | |||
| 8f332e0f29 | |||
| 5443ca8b7f | |||
| ecca637fb3 | |||
| 6a2e34463d | |||
| 4d92b6dd47 | |||
| 136cb4d198 | |||
| c727bb638c | |||
| 3df30475ed | |||
| df90f9952c | |||
| f00f8637a3 | |||
| 68448c40c0 | |||
| 30f2b6e553 | |||
| cea78a7832 | |||
| 650b0449fa | |||
| 77b5ee4897 | |||
| 4f63ef67a7 | |||
| 8901fcc23f | |||
| e794223968 | |||
| d9868449c2 | |||
| 0eef8fafdd | |||
| 1204440266 | |||
| bef362e324 | |||
| 13eb53f64e | |||
| b6d5f76f87 | |||
| d2b63308a3 | |||
| 5466109104 | |||
| ea06453bcb | |||
| edbb9c3686 | |||
| ed3c56aa67 | |||
| d365728372 | |||
| 6686315a10 | |||
| 3a87953bc9 | |||
| 0ec51d440f | |||
| 7d15dbceeb | |||
| 6db6919086 | |||
| 1b29a60358 | |||
| fe2e881b37 | |||
| 7f92f17669 | |||
| e5e8e9bca2 | |||
| 0683c6ec58 | |||
| 8dbe8c1de8 | |||
| 4635c406e7 | |||
| d1a53a0deb | |||
| cdc4db596d | |||
| 289b99444c | |||
| 78bb6cb087 | |||
| ce804afb73 | |||
| 898f4b5f66 | |||
| 4b7bfb0a11 | |||
| 2ab69269ce | |||
| 6418dda3da | |||
| 975a2f0f5b | |||
| d2a015074d | |||
| 8ea451aced | |||
| 5b14ae249a | |||
| 06711b5e47 | |||
| d0139a7c0f | |||
| 2abb34bc78 | |||
| b8d7777740 | |||
| 43ff3a4644 | |||
| 34b9b259ea | |||
| f3d5b82899 | |||
| e603236223 | |||
| 5fff2d2728 | |||
| 529d2db8e2 | |||
| 26eb89c771 | |||
| fa945a1d0c | |||
| bec10fe4e3 | |||
| cbdebe55fc | |||
| 8f30a7aad3 | |||
| b2894bf44c | |||
| ca52d2c6c1 | |||
| b779a0b5c6 | |||
| 4d81dc4e0e | |||
| e263352aed | |||
| d27b1bb291 | |||
| 1d4d920629 | |||
| c9c5ffadc9 | |||
| 001c62c2e4 | |||
| 7334feb8dc | |||
| 2eb6a1c62e | |||
| 61d7ca6bba | |||
| 1baea6b9e9 | |||
| 7f4fa65f3c | |||
| e0f5967128 | |||
| c1fd830add | |||
| 74744599d3 | |||
| fcbc204a24 | |||
| 00ddb3b169 | |||
| a760da7fef | |||
| 48dfb1b1e0 | |||
| 9dfd68d14a | |||
| 8ac3cf2912 | |||
| 0a1b396d12 | |||
| cf7da26c34 | |||
| 6e3c60ce94 | |||
| bda72a4bf4 | |||
| 35d667c3ae | |||
| a98ce59374 | |||
| 069928a445 | |||
| 45189abaf1 | |||
| 43d69a9966 | |||
| e185bb8bad | |||
| c6d5cd7d02 | |||
| b323b55efe | |||
| bf34857d11 | |||
| d8aa46c0a7 | |||
| 610a692519 | |||
| 8a8ec84ef1 | |||
| 47c85d0985 | |||
| f338bd47ea | |||
| 0de46f8b38 | |||
| f49de0fb01 | |||
| a33b1ae8bb | |||
| bdb8cf559c | |||
| ff90c8f565 | |||
| 295275df69 | |||
| b92fdb5771 | |||
| d3581bc65e | |||
| ae2c09cbde |
@@ -182,9 +182,12 @@ ATOMIC_CHAT_API_KEY=sk-...
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -93,10 +93,13 @@ 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
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or: launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### 7. Verify
|
||||
|
||||
@@ -23,14 +23,17 @@ 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 nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
## 4. Remove account data (optional)
|
||||
|
||||
@@ -98,12 +98,16 @@ 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 nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
|
||||
# macOS
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
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`).
|
||||
@@ -232,7 +236,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 nanoclaw
|
||||
systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"
|
||||
```
|
||||
|
||||
### Bot not responding after restart
|
||||
|
||||
@@ -162,10 +162,13 @@ If you changed `EMACS_CHANNEL_PORT` from the default:
|
||||
|
||||
## Restart NanoClaw
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Verify
|
||||
@@ -240,7 +243,7 @@ grep -q "import './emacs.js'" src/channels/index.ts && echo "imported" || echo "
|
||||
|
||||
### No response from agent
|
||||
|
||||
1. NanoClaw running: `launchctl list | grep nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
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)
|
||||
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`
|
||||
|
||||
@@ -282,13 +285,16 @@ 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
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# systemctl --user restart $(systemd_unit) # Linux
|
||||
|
||||
# Remove the NanoClaw block from your Emacs config
|
||||
# Optionally clean up the messaging group:
|
||||
|
||||
@@ -92,7 +92,6 @@ 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"
|
||||
```
|
||||
|
||||
@@ -121,9 +120,7 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@cocal/google-calendar-mcp@${CALENDAR_MCP_VERSION}"
|
||||
```
|
||||
|
||||
### 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).
|
||||
**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.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
@@ -133,40 +130,59 @@ Edit `container/agent-runner/src/providers/claude.ts`. Add `'mcp__calendar__*'`
|
||||
|
||||
## Phase 3: Wire Per-Agent-Group
|
||||
|
||||
For each agent group, merge into `groups/<folder>/container.json`:
|
||||
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.
|
||||
|
||||
```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
|
||||
}
|
||||
]
|
||||
}
|
||||
### 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"}'
|
||||
```
|
||||
|
||||
Substitute `<user>` with `echo $HOME`. `containerPath` is relative (mount-security rejects absolute paths — additional mounts land at `/workspace/extra/<relative>`).
|
||||
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.
|
||||
|
||||
**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`.
|
||||
### 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.
|
||||
|
||||
## Phase 4: Build and Restart
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Kill any existing agent containers so they respawn with the new mcpServers config:
|
||||
@@ -193,16 +209,28 @@ 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" → `mcp__calendar__*` missing from `TOOL_ALLOWLIST`, or image cache stale (`./container/build.sh` again).
|
||||
- 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).
|
||||
|
||||
## Removal
|
||||
|
||||
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`.
|
||||
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>';"
|
||||
```
|
||||
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 nanoclaw`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
|
||||
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.
|
||||
|
||||
@@ -136,7 +136,15 @@ 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 (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
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
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -98,7 +98,6 @@ 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"
|
||||
```
|
||||
|
||||
@@ -132,9 +131,7 @@ 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`.
|
||||
|
||||
### Add tools to allowlist
|
||||
|
||||
Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in `TOOL_ALLOWLIST` and add `'mcp__gmail__*',` after it.
|
||||
**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.
|
||||
|
||||
### Rebuild the container image
|
||||
|
||||
@@ -146,42 +143,63 @@ 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), edit `groups/<folder>/container.json` to add the mount and MCP server.
|
||||
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.
|
||||
|
||||
Merge these into the group's `container.json`:
|
||||
### List groups, pick which ones get Gmail
|
||||
|
||||
```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
|
||||
}
|
||||
]
|
||||
}
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
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).
|
||||
### 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.
|
||||
|
||||
**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
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Phase 5: Verify
|
||||
@@ -206,17 +224,29 @@ 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" → `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).
|
||||
- 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).
|
||||
|
||||
## Removal
|
||||
|
||||
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`.
|
||||
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>';"
|
||||
```
|
||||
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 nanoclaw`.
|
||||
4. `pnpm run build && ./container/build.sh && systemctl --user restart "$(. setup/lib/install-slug.sh && systemd_unit)"`.
|
||||
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.
|
||||
@@ -228,5 +258,5 @@ Common signals:
|
||||
- **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/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.
|
||||
- **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.
|
||||
|
||||
@@ -75,7 +75,7 @@ Stop and wait for the user to confirm before continuing.
|
||||
|
||||
### Remote Mode (Photon API)
|
||||
|
||||
1. Set up a [Photon](https://photon.im) account
|
||||
1. Set up a [Photon](https://photon.codes) account
|
||||
2. Get your server URL and API key
|
||||
|
||||
### Configure environment
|
||||
|
||||
@@ -75,9 +75,12 @@ 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
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
Tell the user to test by sending a source to the wiki group.
|
||||
|
||||
@@ -156,7 +156,15 @@ 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 (`systemctl --user restart nanoclaw` or `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`) to pick up the new channel.
|
||||
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
|
||||
```
|
||||
|
||||
## Channel Info
|
||||
|
||||
|
||||
@@ -89,9 +89,12 @@ docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
@@ -130,12 +130,15 @@ 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
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
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)
|
||||
```
|
||||
|
||||
## 6. Verify
|
||||
|
||||
@@ -54,7 +54,7 @@ git remote -v
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -122,9 +122,12 @@ OLLAMA_HOST=http://your-ollama-host:11434
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
```
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
@@ -229,19 +229,22 @@ echo '{}' | docker run -i --entrypoint /bin/echo nanoclaw-agent:latest "Containe
|
||||
|
||||
### 7. Restart Service
|
||||
|
||||
Rebuild the main app and restart:
|
||||
Rebuild the main app and restart.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
Wait 3 seconds for service to start, then verify:
|
||||
```bash
|
||||
sleep 3
|
||||
launchctl list | grep nanoclaw # macOS
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
launchctl list | grep "$(. setup/lib/install-slug.sh && launchd_label)" # macOS
|
||||
# Linux: systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"
|
||||
```
|
||||
|
||||
### 8. Test Integration
|
||||
@@ -287,4 +290,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: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: add-rtk
|
||||
description: Install rtk token-compression proxy into agent containers. Routes Bash tool calls through rtk for 60–90% token savings on dev commands (git, cargo, pytest, docker, kubectl, etc.).
|
||||
---
|
||||
|
||||
# Add rtk
|
||||
|
||||
Install [rtk](https://github.com/rtk-ai/rtk) — a CLI proxy delivering 60–90% token savings on common dev commands (git, cargo, pytest, docker, kubectl, etc.) — and wire it transparently into agent containers via the Claude Code `PreToolUse` hook.
|
||||
|
||||
## What this sets up
|
||||
|
||||
- `rtk` binary at `~/.local/bin/rtk` on the host
|
||||
- `~/.local/bin/rtk` mounted read-only at `/usr/local/bin/rtk` inside the target agent group's containers
|
||||
- `PreToolUse` hook in the agent group's `settings.json` so every Bash call is automatically filtered through rtk — no CLAUDE.md instructions needed
|
||||
|
||||
## Step 1 — Install rtk on the host
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh
|
||||
```
|
||||
|
||||
If the script put the binary elsewhere, move it:
|
||||
|
||||
```bash
|
||||
find ~/.local ~/.cargo/bin ~/bin -name rtk 2>/dev/null
|
||||
mv "$(which rtk 2>/dev/null)" ~/.local/bin/rtk
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk --version
|
||||
chmod +x ~/.local/bin/rtk # if needed
|
||||
```
|
||||
|
||||
## Step 2 — Identify the target agent group
|
||||
|
||||
```bash
|
||||
ncl groups list
|
||||
```
|
||||
|
||||
Note the group ID (e.g. `ag-1776342942165-ptgddd`). Repeat Steps 3–5 for each group.
|
||||
|
||||
## Step 3 — Mount rtk into the container config
|
||||
|
||||
`additional_mounts` is a JSON column not exposed via `ncl config update`. Update it directly via the DB helper, merging with any existing mounts.
|
||||
|
||||
Read current mounts first:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
Then write the merged array (include all existing entries plus the rtk entry):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"UPDATE container_configs SET additional_mounts = '<merged-json>' WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
The rtk entry to append: `{"hostPath":"/home/<user>/.local/bin/rtk","containerPath":"/usr/local/bin/rtk","readonly":true}`
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
```
|
||||
|
||||
## Step 4 — Add the PreToolUse hook to settings.json
|
||||
|
||||
Each agent group has a `settings.json` at:
|
||||
|
||||
```
|
||||
data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
This file is mounted at `/home/node/.claude/settings.json` inside the container and is read by Claude Code for hooks, env, and model config.
|
||||
|
||||
Add the `PreToolUse` entry using `jq` to merge safely:
|
||||
|
||||
```bash
|
||||
SETTINGS="data/v2-sessions/<group-id>/.claude-shared/settings.json"
|
||||
|
||||
jq '.hooks.PreToolUse = [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
If `PreToolUse` already exists, append instead of overwriting:
|
||||
|
||||
```bash
|
||||
jq '.hooks.PreToolUse += [{"matcher":"Bash","hooks":[{"type":"command","command":"rtk hook claude"}]}]' \
|
||||
"$SETTINGS" > /tmp/rtk-settings.json && mv /tmp/rtk-settings.json "$SETTINGS"
|
||||
```
|
||||
|
||||
## Step 5 — Restart the container
|
||||
|
||||
```bash
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
No `--message` needed — the hook is transparent and requires no agent awareness.
|
||||
|
||||
## Verify
|
||||
|
||||
Ask the agent to run `git status` or any other supported command. rtk intercepts it silently. Check savings with:
|
||||
|
||||
```bash
|
||||
~/.local/bin/rtk gain
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `rtk: command not found` inside the container
|
||||
|
||||
Mount wasn't applied or container wasn't restarted:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx scripts/q.ts data/v2.db \
|
||||
"SELECT additional_mounts FROM container_configs WHERE agent_group_id = '<group-id>'"
|
||||
# Look for entry with /usr/local/bin/rtk
|
||||
ncl groups restart --id <group-id>
|
||||
```
|
||||
|
||||
### Hook not firing
|
||||
|
||||
Verify the hook is in `settings.json`:
|
||||
|
||||
```bash
|
||||
jq '.hooks.PreToolUse' data/v2-sessions/<group-id>/.claude-shared/settings.json
|
||||
```
|
||||
|
||||
If missing, re-run Step 4.
|
||||
|
||||
### Binary won't execute — permission denied
|
||||
|
||||
```bash
|
||||
chmod +x ~/.local/bin/rtk
|
||||
```
|
||||
@@ -90,17 +90,21 @@ 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/com.nanoclaw.plist
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
# optionally: --avatar /path/to/avatar.jpg
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
|
||||
# Linux
|
||||
systemctl --user stop nanoclaw
|
||||
systemctl --user stop $(systemd_unit)
|
||||
signal-cli -a +1YOURNUMBER updateProfile --name "YourBotName"
|
||||
systemctl --user start nanoclaw
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Path B: Link as secondary device
|
||||
@@ -185,12 +189,16 @@ 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)/com.nanoclaw
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
|
||||
# Linux
|
||||
systemctl --user restart nanoclaw
|
||||
systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Wiring
|
||||
@@ -283,7 +291,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)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux)
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -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`
|
||||
- `chat:write`, `im:write`, `channels:history`, `groups:history`, `im:history`, `channels:read`, `groups:read`, `users:read`, `reactions:write`, `files:read`, `files:write`
|
||||
4. Click **Install to Workspace** and copy the **Bot User OAuth Token** (`xoxb-...`)
|
||||
5. Go to **Basic Information** and copy the **Signing Secret**
|
||||
|
||||
|
||||
@@ -55,6 +55,47 @@ pnpm run build
|
||||
|
||||
## Credentials
|
||||
|
||||
Two paths — manual (Azure Portal) or auto (Teams CLI).
|
||||
|
||||
### Auto: Teams CLI
|
||||
|
||||
Requires Node.js 18+, a Microsoft 365 account with sideloading permissions, and a public HTTPS endpoint (ngrok, Cloudflare Tunnel, or similar).
|
||||
|
||||
1. Install the CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @microsoft/teams.cli@preview
|
||||
```
|
||||
|
||||
2. Sign in and verify:
|
||||
|
||||
```bash
|
||||
teams login
|
||||
teams status
|
||||
```
|
||||
|
||||
3. Create the Entra app, client secret, and bot registration:
|
||||
|
||||
```bash
|
||||
teams app create \
|
||||
--name "NanoClaw" \
|
||||
--endpoint "https://your-domain/api/webhooks/teams"
|
||||
```
|
||||
|
||||
The CLI prints the credentials as `CLIENT_ID`, `CLIENT_SECRET`, and `TENANT_ID`. Map them to NanoClaw's env keys:
|
||||
|
||||
- `CLIENT_ID` → `TEAMS_APP_ID`
|
||||
- `CLIENT_SECRET` → `TEAMS_APP_PASSWORD`
|
||||
- `TENANT_ID` → `TEAMS_APP_TENANT_ID`
|
||||
|
||||
4. Pick **Install in Teams** from the post-create menu and confirm in the Teams dialog.
|
||||
|
||||
Continue to [Configure environment](#configure-environment).
|
||||
|
||||
---
|
||||
|
||||
The steps below describe the **manual Azure Portal path**.
|
||||
|
||||
### Step 1: Create an Azure AD App Registration
|
||||
|
||||
1. Go to [Azure Portal](https://portal.azure.com) > **App registrations** > **New registration**
|
||||
|
||||
@@ -41,9 +41,12 @@ DELETE FROM messaging_groups WHERE channel_type = 'wechat';
|
||||
|
||||
### 6. Rebuild and restart
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
@@ -82,12 +82,15 @@ Sync to container: `mkdir -p data/env && cp .env data/env/env`
|
||||
|
||||
### 2. Start the service and scan the QR
|
||||
|
||||
Restart NanoClaw:
|
||||
Restart NanoClaw.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user restart nanoclaw # Linux
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# or
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
The adapter will print a **QR URL** to the logs and save it to `data/wechat/qr.txt`:
|
||||
|
||||
@@ -20,6 +20,7 @@ Skip to **Credentials** if all of these are already in place:
|
||||
- `setup/whatsapp-auth.ts` and `setup/groups.ts` both exist
|
||||
- `setup/index.ts`'s `STEPS` map contains both `'whatsapp-auth':` and `groups:`
|
||||
- `@whiskeysockets/baileys`, `qrcode`, `pino` are listed in `package.json` dependencies
|
||||
- `.claude/skills/add-whatsapp/scripts/wa-qr-browser.ts` exists (ships with this skill)
|
||||
|
||||
Otherwise continue. Every step below is safe to re-run.
|
||||
|
||||
@@ -95,7 +96,7 @@ If IS_HEADLESS=true AND not WSL → AskUserQuestion: How do you want to authenti
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to authenticate WhatsApp?
|
||||
- **QR code in browser** (Recommended) - Opens a browser window with a large, scannable QR code
|
||||
- **QR code in browser** (Recommended) - Runs a small local HTTP server that renders the rotating QR as a PNG and auto-opens your default browser
|
||||
- **Pairing code** - Enter a numeric code on your phone (no camera needed, requires phone number)
|
||||
- **QR code in terminal** - Displays QR code in the terminal (can be too small on some displays)
|
||||
|
||||
@@ -114,11 +115,13 @@ rm -rf store/auth/
|
||||
For QR code in browser (recommended):
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The wrapper spawns `setup/index.ts --step whatsapp-auth -- --method qr`, parses each rotating QR from its `WHATSAPP_AUTH_QR` status blocks, and serves the current QR as a PNG on a local HTTP server (default port `8765`, falls back to a free port). Flags: `--clean` (wipes `store/auth/` before spawning) and `--port N`.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> A browser window will open with a QR code.
|
||||
@@ -130,11 +133,13 @@ Tell the user:
|
||||
For QR code in terminal:
|
||||
|
||||
```bash
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-terminal
|
||||
pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr
|
||||
```
|
||||
|
||||
(Bash timeout: 150000ms)
|
||||
|
||||
The setup driver emits each rotating QR as a `WHATSAPP_AUTH_QR` status block; when run directly (not through `setup:auto`) the raw QR string is printed and your terminal must render it as ASCII. If your terminal can't render it readably, use the browser method above.
|
||||
|
||||
Tell the user:
|
||||
|
||||
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
|
||||
@@ -220,10 +225,10 @@ Not supported (WhatsApp linked device limitation): edit messages, delete message
|
||||
|
||||
### QR code expired
|
||||
|
||||
QR codes expire after ~60 seconds. Re-run the auth command:
|
||||
QR codes expire after ~60 seconds. The browser wrapper rotates automatically as long as it's running; if it was stopped, re-run with `--clean`:
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
```
|
||||
|
||||
### Pairing code not working
|
||||
@@ -236,20 +241,23 @@ rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --met
|
||||
|
||||
Ensure: digits only (no `+`), phone has internet, WhatsApp is updated.
|
||||
|
||||
If pairing code keeps failing, switch to QR-browser auth instead:
|
||||
WhatsApp's pairing-code flow occasionally rejects valid codes with "Couldn't link device — An error happened. Please try again." This is a server-side rejection unrelated to the code itself; we've seen it happen twice in a row on fresh dedicated numbers. If you hit it more than once, switch to QR-browser auth — it has a noticeably higher success rate:
|
||||
|
||||
```bash
|
||||
rm -rf store/auth/ && pnpm exec tsx setup/index.ts --step whatsapp-auth -- --method qr-browser
|
||||
pnpm exec tsx .claude/skills/add-whatsapp/scripts/wa-qr-browser.ts --clean
|
||||
```
|
||||
|
||||
### "waiting for this message" on reactions
|
||||
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions:
|
||||
Signal sessions corrupted from rapid restarts. Clear sessions.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
systemctl --user stop nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user stop $(systemd_unit)
|
||||
rm store/auth/session-*.json
|
||||
systemctl --user start nanoclaw
|
||||
systemctl --user start $(systemd_unit)
|
||||
```
|
||||
|
||||
### Bot not responding
|
||||
@@ -257,7 +265,7 @@ systemctl --user start nanoclaw
|
||||
1. Auth exists: `test -f store/auth/creds.json`
|
||||
2. Connected: `grep "Connected to WhatsApp" logs/nanoclaw.log | tail -1`
|
||||
3. Channel wired: `pnpm exec tsx scripts/q.ts data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id=mga.messaging_group_id WHERE mg.channel_type='whatsapp'"`
|
||||
4. Service running: `systemctl --user status nanoclaw`
|
||||
4. Service running: `systemctl --user status "$(. setup/lib/install-slug.sh && systemd_unit)"`
|
||||
|
||||
### "conflict" disconnection
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* scripts/wa-qr-browser.ts — serve WhatsApp pairing QR in the browser.
|
||||
*
|
||||
* Wraps `setup/index.ts --step whatsapp-auth -- --method qr` and renders the
|
||||
* rotating QR string as a PNG in a small local HTTP page. Avoids the unreadable
|
||||
* ASCII terminal QR. macOS / desktop-Linux only — no headless support needed.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm exec tsx scripts/wa-qr-browser.ts [--clean] [--port 8765]
|
||||
*
|
||||
* --clean rm -rf store/auth/ before spawning the auth step.
|
||||
* --port N bind to port N (default 8765, falls back to a free port).
|
||||
*/
|
||||
import { spawn, exec } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
type Status = 'waiting' | 'ready' | 'success' | 'failed';
|
||||
type State = {
|
||||
qr: string | null;
|
||||
status: Status;
|
||||
error?: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
const state: State = { qr: null, status: 'waiting', version: 0 };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const clean = args.includes('--clean');
|
||||
const portIdx = args.indexOf('--port');
|
||||
const requestedPort = portIdx >= 0 ? Number(args[portIdx + 1]) : 8765;
|
||||
|
||||
if (clean) {
|
||||
fs.rmSync(path.join(process.cwd(), 'store', 'auth'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
console.log('[wa-qr-browser] cleaned store/auth/');
|
||||
}
|
||||
|
||||
function htmlPage(): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>WhatsApp pairing</title>
|
||||
<style>
|
||||
body { margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #0b141a; color: #e9edef; }
|
||||
.card { background: #202c33; padding: 32px 40px; border-radius: 16px;
|
||||
box-shadow: 0 12px 36px rgba(0,0,0,0.4); text-align: center;
|
||||
min-width: 420px; }
|
||||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 20px; color: #aebac1; }
|
||||
.qr-wrap { background: white; padding: 16px; border-radius: 12px;
|
||||
display: inline-block; }
|
||||
#qr { width: 360px; height: 360px; display: block; image-rendering: pixelated; }
|
||||
#status { margin-top: 20px; font-size: 14px; color: #8696a0; min-height: 20px; }
|
||||
#status.ok { color: #00d26a; font-size: 18px; font-weight: 500; }
|
||||
#status.err { color: #ff6b6b; }
|
||||
ol { text-align: left; color: #aebac1; font-size: 13px; line-height: 1.8;
|
||||
margin: 20px 0 0; padding-left: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Scan with WhatsApp</h1>
|
||||
<div class="qr-wrap"><img id="qr" alt="QR code" /></div>
|
||||
<div id="status">Waiting for QR…</div>
|
||||
<ol>
|
||||
<li>Open WhatsApp on your phone</li>
|
||||
<li>Settings → Linked Devices → Link a Device</li>
|
||||
<li>Point the camera at this QR code</li>
|
||||
</ol>
|
||||
</div>
|
||||
<script>
|
||||
let lastVersion = -1;
|
||||
const qr = document.getElementById('qr');
|
||||
const status = document.getElementById('status');
|
||||
async function tick() {
|
||||
try {
|
||||
const r = await fetch('/qr.json', { cache: 'no-store' });
|
||||
const s = await r.json();
|
||||
if (s.status === 'success') {
|
||||
qr.style.display = 'none';
|
||||
status.className = 'ok';
|
||||
status.textContent = '✓ Authenticated!';
|
||||
return;
|
||||
}
|
||||
if (s.status === 'failed') {
|
||||
qr.style.display = 'none';
|
||||
status.className = 'err';
|
||||
status.textContent = '✗ ' + (s.error || 'failed');
|
||||
return;
|
||||
}
|
||||
if (s.qr && s.version !== lastVersion) {
|
||||
lastVersion = s.version;
|
||||
qr.src = '/qr.png?v=' + s.version;
|
||||
status.textContent = 'QR ready — scan within ~20s';
|
||||
}
|
||||
} catch (e) { /* server closing, ignore */ }
|
||||
setTimeout(tick, 1500);
|
||||
}
|
||||
tick();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = req.url ?? '/';
|
||||
if (url === '/' || url.startsWith('/?')) {
|
||||
res.setHeader('content-type', 'text/html; charset=utf-8');
|
||||
res.end(htmlPage());
|
||||
return;
|
||||
}
|
||||
if (url === '/qr.json') {
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.setHeader('cache-control', 'no-store');
|
||||
res.end(JSON.stringify(state));
|
||||
return;
|
||||
}
|
||||
if (url.startsWith('/qr.png')) {
|
||||
if (!state.qr) {
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const buf = await QRCode.toBuffer(state.qr, { width: 360, margin: 1 });
|
||||
res.setHeader('content-type', 'image/png');
|
||||
res.setHeader('cache-control', 'no-store');
|
||||
res.end(buf);
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.end(String(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
res.statusCode = 404;
|
||||
res.end();
|
||||
});
|
||||
|
||||
function listen(port: number): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'EADDRINUSE' && port === requestedPort) {
|
||||
server.listen(0, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') resolve(addr.port);
|
||||
else reject(new Error('unexpected address'));
|
||||
});
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
server.listen(port, () => {
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === 'object') resolve(addr.port);
|
||||
else reject(new Error('unexpected address'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const port = await listen(requestedPort);
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(`[wa-qr-browser] QR server on ${url}`);
|
||||
|
||||
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
||||
exec(`${opener} ${url}`, (err) => {
|
||||
if (err) console.log(`[wa-qr-browser] could not auto-open browser: ${err.message}`);
|
||||
else console.log('[wa-qr-browser] opening browser…');
|
||||
});
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['exec', 'tsx', 'setup/index.ts', '--step', 'whatsapp-auth', '--', '--method', 'qr'],
|
||||
{ stdio: ['inherit', 'pipe', 'inherit'] },
|
||||
);
|
||||
|
||||
let stdoutBuf = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
process.stdout.write(text);
|
||||
stdoutBuf += text;
|
||||
|
||||
const blockRe = /=== NANOCLAW SETUP: (\w+) ===\n([\s\S]*?)\n=== END ===/g;
|
||||
let m: RegExpExecArray | null;
|
||||
let lastEnd = 0;
|
||||
while ((m = blockRe.exec(stdoutBuf)) !== null) {
|
||||
const [, name, body] = m;
|
||||
const fields: Record<string, string> = {};
|
||||
for (const line of body.split('\n')) {
|
||||
const kv = line.match(/^(\w+):\s*(.*)$/);
|
||||
if (kv) fields[kv[1]] = kv[2];
|
||||
}
|
||||
handleBlock(name, fields);
|
||||
lastEnd = m.index + m[0].length;
|
||||
}
|
||||
if (lastEnd > 0) stdoutBuf = stdoutBuf.slice(lastEnd);
|
||||
});
|
||||
|
||||
function handleBlock(name: string, fields: Record<string, string>): void {
|
||||
if (name === 'WHATSAPP_AUTH_QR' && fields.QR) {
|
||||
state.qr = fields.QR;
|
||||
state.status = 'ready';
|
||||
state.version++;
|
||||
return;
|
||||
}
|
||||
if (name === 'WHATSAPP_AUTH') {
|
||||
if (fields.STATUS === 'success') {
|
||||
state.status = 'success';
|
||||
console.log('[wa-qr-browser] authenticated');
|
||||
setTimeout(() => server.close(() => process.exit(0)), 3000);
|
||||
} else if (fields.STATUS === 'skipped') {
|
||||
state.status = 'success';
|
||||
state.error = `already authenticated (${fields.REASON ?? 'unknown'})`;
|
||||
console.log(`[wa-qr-browser] ${state.error}`);
|
||||
setTimeout(() => server.close(() => process.exit(0)), 3000);
|
||||
} else if (fields.STATUS === 'failed') {
|
||||
state.status = 'failed';
|
||||
state.error = fields.ERROR ?? 'unknown error';
|
||||
console.error(`[wa-qr-browser] failed: ${state.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.on('exit', (code) => {
|
||||
if (state.status === 'success') return;
|
||||
if (state.status !== 'failed') {
|
||||
state.status = 'failed';
|
||||
state.error = `auth process exited (code=${code ?? 'null'})`;
|
||||
}
|
||||
setTimeout(() => {
|
||||
server.close(() => process.exit(1));
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n[wa-qr-browser] aborting…');
|
||||
child.kill('SIGTERM');
|
||||
server.close(() => process.exit(130));
|
||||
});
|
||||
@@ -58,7 +58,7 @@ git remote -v
|
||||
If `upstream` is missing, add it:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -171,9 +171,12 @@ Expected: Both operations succeed.
|
||||
|
||||
### Full integration test
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label)
|
||||
```
|
||||
|
||||
Send a message via WhatsApp and verify the agent responds.
|
||||
|
||||
@@ -88,15 +88,19 @@ Implementation:
|
||||
|
||||
## After Changes
|
||||
|
||||
Always tell the user:
|
||||
Always tell the user.
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
# Rebuild and restart
|
||||
pnpm run build
|
||||
source setup/lib/install-slug.sh
|
||||
# macOS:
|
||||
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
|
||||
launchctl unload ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
launchctl load ~/Library/LaunchAgents/$(launchd_label).plist
|
||||
# Linux:
|
||||
# systemctl --user restart nanoclaw
|
||||
# systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
## Example Interaction
|
||||
|
||||
@@ -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 nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux). If stopped, tell the user to run `/setup` first.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -236,9 +236,12 @@ pnpm run build
|
||||
|
||||
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `pnpm install` first.
|
||||
|
||||
Restart the service:
|
||||
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux (systemd): `systemctl --user restart nanoclaw`
|
||||
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)"`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
@@ -41,7 +41,12 @@ npx tsx setup/index.ts --step mounts --force -- --empty
|
||||
|
||||
## After Changes
|
||||
|
||||
Restart the service so containers pick up the new config:
|
||||
Restart the service so containers pick up the new config (the unit/label names are per-install — see `setup/lib/install-slug.sh`).
|
||||
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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/qwibitai/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/nanocoai/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.
|
||||
|
||||
|
||||
@@ -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/qwibitai/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/nanocoai/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/qwibitai/nanoclaw.git`).
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/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. 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>`
|
||||
- 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)"`)
|
||||
- **Manual** (no service found): restart `pnpm run dev`
|
||||
|
||||
|
||||
|
||||
@@ -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/qwibitai/nanoclaw.git`).
|
||||
- Ask the user for the upstream repo URL (default: `https://github.com/nanocoai/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/qwibitai/nanoclaw.git
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git
|
||||
```
|
||||
|
||||
### Merge the skill branch
|
||||
@@ -128,9 +128,12 @@ echo 'ANTHROPIC_API_KEY=<key>' >> .env
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
Then restart the service:
|
||||
- macOS: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
|
||||
- Linux: `systemctl --user restart nanoclaw`
|
||||
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)"`
|
||||
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
|
||||
|
||||
2. Check logs for successful proxy startup:
|
||||
|
||||
@@ -38,6 +38,8 @@ 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
|
||||
@@ -49,9 +51,10 @@ pnpm exec dotenv -e .env -- pnpm exec tsx .claude/skills/x-integration/scripts/s
|
||||
|
||||
# 3. Rebuild host and restart service
|
||||
pnpm run build
|
||||
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)
|
||||
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)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -270,16 +273,23 @@ cat data/x-auth.json # Should show {"authenticated": true, ...}
|
||||
|
||||
### 4. Restart Service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
pnpm run build
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
**Verify success:**
|
||||
**Verify success.**
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
launchctl list | grep nanoclaw # macOS — should show PID and exit code 0 or -
|
||||
# Linux: systemctl --user status nanoclaw
|
||||
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)
|
||||
```
|
||||
|
||||
## Usage via WhatsApp
|
||||
@@ -343,10 +353,13 @@ 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
|
||||
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
# Linux: systemctl --user restart nanoclaw
|
||||
source setup/lib/install-slug.sh
|
||||
launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
# Linux: systemctl --user restart $(systemd_unit)
|
||||
```
|
||||
|
||||
### Browser Lock Files
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
bump-version:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
if: github.repository == 'nanocoai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
update-tokens:
|
||||
if: github.repository == 'qwibitai/nanoclaw'
|
||||
if: github.repository == 'nanocoai/nanoclaw'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
|
||||
+18
-1
@@ -2,7 +2,24 @@
|
||||
|
||||
All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
## [2.0.64] - 2026-05-18
|
||||
|
||||
- **`ncl destinations add` and `remove` through the approval flow now reach the receiver immediately.** Approved destinations weren't being projected into the receiving agent's local session state, so a freshly-added destination silently failed at `send_message` with `unknown destination`, and a removed destination stayed resolvable until the next container restart. Both now take effect the moment the approval executes. Direct (non-approval) calls were unaffected.
|
||||
|
||||
## [2.0.63] - 2026-05-15
|
||||
|
||||
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).
|
||||
|
||||
- [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
|
||||
|
||||
|
||||
+3
-3
@@ -4,8 +4,8 @@
|
||||
|
||||
1. **Check for existing work.** Search open PRs and issues before starting:
|
||||
```bash
|
||||
gh pr list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
gh issue list --repo qwibitai/nanoclaw --search "<your feature>"
|
||||
gh pr list --repo nanocoai/nanoclaw --search "<your feature>"
|
||||
gh issue list --repo nanocoai/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 `qwibitai/nanoclaw` and branch from `main`
|
||||
1. Fork `nanocoai/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
|
||||
|
||||
@@ -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/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/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/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash migrate-v2.sh
|
||||
```
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ NanoClawは同じコア機能を提供しますが、理解できる規模のコ
|
||||
## クイックスタート
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ NanoClaw 用一个您能轻松理解的代码库提供了同样的核心功能
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/qwibitai/nanoclaw.git nanoclaw-v2
|
||||
git clone https://github.com/nanocoai/nanoclaw.git nanoclaw-v2
|
||||
cd nanoclaw-v2
|
||||
bash nanoclaw.sh
|
||||
```
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
@@ -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.154
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0",
|
||||
},
|
||||
@@ -18,25 +19,25 @@
|
||||
},
|
||||
},
|
||||
"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.3.154", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.154", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.154", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.154" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-iEn25urI2QrMPFIhId3h7v/7EG5gsmF7ooe+6EvsAosePeLmpVVerp5nXtHnlmBkMinLecurcPA+OddKw76jYw=="],
|
||||
|
||||
"@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.3.154", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oFW3LD5lYrKAU+AKu27Z8hrzqkrh362qQrwi/i3DxGcud9BXUycsXYjShpDj3D3JZu169UzZuSPhx1Wajmbiwg=="],
|
||||
|
||||
"@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.3.154", "", { "os": "darwin", "cpu": "x64" }, "sha512-5BgWEueP+cqoctWjZYhCbyltuaV/N2DmKDXD3/69cKaVmJp8XL9OCzlq/HEirA/+Ssjskx6hDUBaOcpuZ3iwQA=="],
|
||||
|
||||
"@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.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-rRkW4SBL3W7zQvKscCIfIGlmoeuTbMV6dXFbPdmpRGvmYZIs79RpzO6xrGBnnhmm+B7znQ9oHAnffi/2FBgJbA=="],
|
||||
|
||||
"@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.3.154", "", { "os": "linux", "cpu": "arm64" }, "sha512-o2bCQN4Xn3UqCLErC5m4T7u0yYArJYmgFCUFnA6K96DdW2RERvx+gTKXxWuHEBkDO+eMoHLHLxk0u2jGES00Ng=="],
|
||||
|
||||
"@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.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-GpiFF8Ez6PbM3m0gqtCo/FKM346qyRdP7VhbmJzdnbNKTiiUZ66vDQyEUPZPCG24ZkrG4m96KpRIUwY08rHiNg=="],
|
||||
|
||||
"@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.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
|
||||
|
||||
"@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.3.154", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDW1YFbU/PJFlrGXhlAGcbkXt80sEO6WtnH8nN8YHXLn5NWduy2q7o/qC6i8XozgvRGf6t/eMoH7IasGIEDhDw=="],
|
||||
|
||||
"@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.3.154", "", { "os": "win32", "cpu": "x64" }, "sha512-tSKaIIpL72OPg3WfzZTCIl8OJgcbq4qieu8/fDWjsdeQuari9gQMIuEflFphk9HqNsxpSmDqKi8Sm5mW2V566Q=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
|
||||
|
||||
@@ -44,6 +45,8 @@
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="],
|
||||
@@ -108,6 +111,8 @@
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
@@ -216,6 +221,8 @@
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
"test": "bun test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.128",
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.154",
|
||||
"@anthropic-ai/sdk": "^0.100.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"cron-parser": "^5.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
|
||||
@@ -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. 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)'}`,
|
||||
'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)'}."`,
|
||||
];
|
||||
|
||||
console.log(instructions.join('\n'));
|
||||
|
||||
@@ -10,6 +10,19 @@
|
||||
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;
|
||||
@@ -54,12 +67,13 @@ export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
|
||||
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'))
|
||||
AND (on_wake = 0 OR ?1 = 1)
|
||||
${onWakeFilter}
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?2`,
|
||||
)
|
||||
|
||||
@@ -27,18 +27,18 @@ describe('buildSystemPromptAddendum — multi-destination routing guidance', ()
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('from="name"');
|
||||
expect(prompt).toContain('`casa`');
|
||||
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||
});
|
||||
|
||||
it('requires explicit wrapping even for a single destination', () => {
|
||||
it('describes message wrapping for a single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Every response must be wrapped');
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
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 routing');
|
||||
expect(prompt).not.toContain('default to addressing');
|
||||
});
|
||||
|
||||
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('Every response must be wrapped');
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('Default routing');
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,17 +115,16 @@ 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(
|
||||
'**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…").',
|
||||
'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.',
|
||||
);
|
||||
lines.push('');
|
||||
lines.push(
|
||||
'To send a message mid-response (e.g., an acknowledgment before a long task), call the `send_message` MCP tool with the `to` parameter set to a destination name.',
|
||||
'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.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -51,14 +51,43 @@ describe('context timezone header', () => {
|
||||
expect(result).toContain(`<context timezone="${TIMEZONE}"`);
|
||||
});
|
||||
|
||||
it('header comes before the <messages> block', () => {
|
||||
it('header comes before the first <message> block when multiple are present', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
|
||||
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
const ctxIdx = result.indexOf('<context');
|
||||
const msgsIdx = result.indexOf('<messages>');
|
||||
const firstMsgIdx = result.indexOf('<message ');
|
||||
expect(ctxIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(msgsIdx).toBeGreaterThan(ctxIdx);
|
||||
expect(firstMsgIdx).toBeGreaterThan(ctxIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-message chat batches', () => {
|
||||
// Regression guard for #2555: an outer `<messages>` envelope around
|
||||
// multiple chat messages caused the Claude Agent SDK to emit a synthetic
|
||||
// `No response requested.` stub instead of calling the API. Each
|
||||
// `<message>` block is self-contained; concatenating them is enough.
|
||||
it('does NOT wrap multiple chat messages in an outer <messages> envelope', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'one' });
|
||||
insertMessage('m2', 'chat', { sender: 'Bob', text: 'two' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
expect(result).not.toContain('<messages>');
|
||||
expect(result).not.toContain('</messages>');
|
||||
});
|
||||
|
||||
it('emits one <message> block per inbound row, in order', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'first' });
|
||||
insertMessage('m2', 'chat', { sender: 'Bob', text: 'second' });
|
||||
insertMessage('m3', 'chat', { sender: 'Carol', text: 'third' });
|
||||
const result = formatMessages(getPendingMessages());
|
||||
const matches = result.match(/<message [^>]*>/g) ?? [];
|
||||
expect(matches.length).toBe(3);
|
||||
const firstIdx = result.indexOf('first');
|
||||
const secondIdx = result.indexOf('second');
|
||||
const thirdIdx = result.indexOf('third');
|
||||
expect(firstIdx).toBeGreaterThan(0);
|
||||
expect(secondIdx).toBeGreaterThan(firstIdx);
|
||||
expect(thirdIdx).toBeGreaterThan(secondIdx);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js';
|
||||
*/
|
||||
export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none';
|
||||
|
||||
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']);
|
||||
const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']);
|
||||
|
||||
export interface CommandInfo {
|
||||
@@ -155,16 +155,15 @@ export function formatMessages(messages: MessageInRow[]): string {
|
||||
}
|
||||
|
||||
function formatChatMessages(messages: MessageInRow[]): string {
|
||||
if (messages.length === 1) {
|
||||
return formatSingleChat(messages[0]);
|
||||
}
|
||||
|
||||
const lines = ['<messages>'];
|
||||
for (const msg of messages) {
|
||||
lines.push(formatSingleChat(msg));
|
||||
}
|
||||
lines.push('</messages>');
|
||||
return lines.join('\n');
|
||||
// Each `<message id="..." from="...">...</message>` block is self-contained;
|
||||
// concatenating them reads to the agent as a sequence of distinct messages.
|
||||
// Earlier revisions wrapped multi-message batches in an outer `<messages>`
|
||||
// envelope, but the Claude Agent SDK responded to that shape with a
|
||||
// synthetic stub (`model: "<synthetic>"`, `content: "No response
|
||||
// requested."`) instead of calling the API — see #2555 for the full trace.
|
||||
// The fix is simply to drop the wrapper; the single-message path (which
|
||||
// already worked) is now just the N=1 case of the same code.
|
||||
return messages.map(formatSingleChat).join('\n');
|
||||
}
|
||||
|
||||
function formatSingleChat(msg: MessageInRow): string {
|
||||
|
||||
@@ -295,115 +295,8 @@ 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([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
## Sending messages
|
||||
|
||||
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.
|
||||
**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.
|
||||
|
||||
### 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. Credentials are managed by the user in the OneCLI agent vault. Add a "placeholder" string instead of the credential, and ask the user to add the credential to the vault. You can make a test request before the secret is added and the vault proxy will respond with the local url of the vault dashboard on the user's machine and a link to a form for adding that specific credential.
|
||||
Do not ask the user to give you credentials or tell them how to create credentials (OAuth, API keys, etc.) — NEVER fabricate credential setup instructions. Credentials are handled by the OneCLI gateway. Use `"onecli-managed"` as the placeholder value for any credential env vars or config fields. After the MCP server is installed and the container restarts, load `/onecli-gateway` for the full credential-handling flow (connect URLs, stubs, error recovery).
|
||||
|
||||
@@ -4,6 +4,7 @@ import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from '
|
||||
import { getPendingMessages, markCompleted } from './db/messages-in.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { formatMessages, extractRouting } from './formatter.js';
|
||||
import { isCorruptionError } from './poll-loop.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -37,13 +38,15 @@ describe('formatter', () => {
|
||||
expect(prompt).toContain('Hello world');
|
||||
});
|
||||
|
||||
it('should format multiple chat messages as XML block', () => {
|
||||
it('should format multiple chat messages as distinct <message> blocks', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'John', text: 'Hello' });
|
||||
insertMessage('m2', 'chat', { sender: 'Jane', text: 'Hi there' });
|
||||
const messages = getPendingMessages();
|
||||
const prompt = formatMessages(messages);
|
||||
expect(prompt).toContain('<messages>');
|
||||
expect(prompt).toContain('</messages>');
|
||||
// The <messages> envelope was dropped in fe2e881b (#2556) so the SDK calls
|
||||
// the API; each message is now its own self-contained <message> block.
|
||||
expect(prompt).not.toContain('<messages>');
|
||||
expect(prompt.match(/<message /g) ?? []).toHaveLength(2);
|
||||
expect(prompt).toContain('sender="John"');
|
||||
expect(prompt).toContain('sender="Jane"');
|
||||
});
|
||||
@@ -375,3 +378,20 @@ describe('end-to-end with mock provider', () => {
|
||||
expect(outMessages[0].in_reply_to).toBe('m1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCorruptionError', () => {
|
||||
it('matches the Docker Desktop macOS torn-read symptom', () => {
|
||||
expect(isCorruptionError('database disk image is malformed')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches wrapped SQLite corruption codes', () => {
|
||||
expect(isCorruptionError('SqliteError: SQLITE_CORRUPT_VTAB: ...')).toBe(true);
|
||||
expect(isCorruptionError('file is not a database')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for unrelated errors', () => {
|
||||
expect(isCorruptionError('database is locked')).toBe(false);
|
||||
expect(isCorruptionError('no such table: messages_in')).toBe(false);
|
||||
expect(isCorruptionError('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,36 @@ import {
|
||||
stripInternalTags,
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { isUploadTraceCommand, uploadTrace } from './upload-trace.js';
|
||||
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
const ACTIVE_POLL_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Number of consecutive `database disk image is malformed` errors after which
|
||||
* the follow-up poll gives up and exits the process. At ACTIVE_POLL_INTERVAL_MS
|
||||
* = 500ms this is roughly 5 seconds — long enough to dodge a transient torn
|
||||
* read during a host write, short enough to recover quickly from a poisoned
|
||||
* page cache (host-sweep then respawns with a fresh mount).
|
||||
*/
|
||||
const CORRUPTION_STREAK_EXIT = 10;
|
||||
|
||||
/**
|
||||
* True for SQLite errors that indicate a corrupt READ view — almost always a
|
||||
* cross-mount page-cache coherency issue on Docker Desktop macOS rather than
|
||||
* actual file damage (host-side integrity_check passes). Reopening the DB
|
||||
* handle inside this process does NOT recover; only a fresh container mount
|
||||
* does. Caller's job is to exit so host-sweep respawns the container.
|
||||
*/
|
||||
export function isCorruptionError(msg: string): boolean {
|
||||
return (
|
||||
msg.includes('database disk image is malformed') ||
|
||||
msg.includes('SQLITE_CORRUPT') ||
|
||||
msg.includes('file is not a database')
|
||||
);
|
||||
}
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[poll-loop] ${msg}`);
|
||||
}
|
||||
@@ -58,6 +83,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// a Codex thread id never gets handed to Claude or vice versa.
|
||||
let continuation: string | undefined = migrateLegacyContinuation(config.providerName);
|
||||
|
||||
// Before resuming, drop a session whose on-disk transcript has grown too
|
||||
// large/old to cold-resume within the host's idle ceiling. Without this a
|
||||
// long-lived hub keeps trying to reload an ever-growing .jsonl, hangs the
|
||||
// first turn, and gets killed before it can reply (then repeats forever).
|
||||
if (continuation) {
|
||||
const rotateReason = config.provider.maybeRotateContinuation?.(continuation, config.cwd);
|
||||
if (rotateReason) {
|
||||
log(`Rotating session — ${rotateReason}; starting fresh`);
|
||||
clearContinuation(config.providerName);
|
||||
continuation = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (continuation) {
|
||||
log(`Resuming agent session ${continuation}`);
|
||||
}
|
||||
@@ -124,6 +162,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isUploadTraceCommand(msg)) {
|
||||
log('Uploading session trace to Hugging Face');
|
||||
writeMessageOut({
|
||||
id: generateId(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platformId,
|
||||
channel_type: routing.channelType,
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: uploadTrace() }),
|
||||
});
|
||||
commandIds.push(msg.id);
|
||||
continue;
|
||||
}
|
||||
normalMessages.push(msg);
|
||||
}
|
||||
|
||||
@@ -265,6 +316,7 @@ 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
|
||||
@@ -277,6 +329,7 @@ async function processQuery(
|
||||
// will kill the container and messages get reset to pending.
|
||||
let pollInFlight = false;
|
||||
let endedForCommand = false;
|
||||
let corruptionStreak = 0;
|
||||
const pollHandle = setInterval(() => {
|
||||
if (done || pollInFlight || endedForCommand) return;
|
||||
pollInFlight = true;
|
||||
@@ -338,6 +391,7 @@ 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) {
|
||||
@@ -347,6 +401,31 @@ async function processQuery(
|
||||
// path is not, so it needs its own.
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Follow-up poll error: ${errMsg}`);
|
||||
|
||||
// Detect SQLite cross-mount corruption (Docker Desktop macOS virtiofs /
|
||||
// gRPC-FUSE coherency bug — the kernel page cache for the inbound.db
|
||||
// bind mount can latch a torn snapshot mid-host-write, after which
|
||||
// every fresh openInboundDb() in this process sees the same broken
|
||||
// view. Reopening inside the container does NOT recover; only a fresh
|
||||
// container mount does. Exit so the host sweep respawns us.
|
||||
if (isCorruptionError(errMsg)) {
|
||||
corruptionStreak += 1;
|
||||
if (corruptionStreak >= CORRUPTION_STREAK_EXIT) {
|
||||
log(
|
||||
`Follow-up poll: ${corruptionStreak} consecutive '${errMsg}' errors — ` +
|
||||
`inbound.db page cache is poisoned. Exiting so host respawns with a fresh mount.`,
|
||||
);
|
||||
// Stop touching the heartbeat so host-sweep stale detection fires
|
||||
// promptly even if exit() races with in-flight async work.
|
||||
done = true;
|
||||
clearInterval(pollHandle);
|
||||
// Defer exit one tick so this log line flushes through Docker's
|
||||
// log driver before the process dies.
|
||||
setTimeout(() => process.exit(75), 100);
|
||||
}
|
||||
} else {
|
||||
corruptionStreak = 0;
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
}
|
||||
@@ -376,24 +455,18 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
}
|
||||
} else if (event.type === 'compacted') {
|
||||
// The SDK auto-compacted the conversation. After compaction the
|
||||
// model often drops the learned `<message to="…">` wrapping
|
||||
// discipline (the destinations are still in the system prompt,
|
||||
// but the behavioral pattern is summarized away). Inject a
|
||||
// reminder back into the live query so the next turn re-anchors
|
||||
// on the destination model. Only do this when there's >1
|
||||
// destination — single-destination groups have a fallback that
|
||||
// works without wrapping. See qwibitai/nanoclaw#2325.
|
||||
const destinations = getAllDestinations();
|
||||
if (destinations.length > 1) {
|
||||
const names = destinations.map((d) => d.name).join(', ');
|
||||
query.push(
|
||||
`[system] Context was just compacted. Reminder: you have ${destinations.length} destinations (${names}). ` +
|
||||
`Use <message to="name"> blocks to address them. Bare text goes to the scratchpad fallback only.`,
|
||||
);
|
||||
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>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,9 +494,6 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
case 'progress':
|
||||
log(`Progress: ${event.message}`);
|
||||
break;
|
||||
case 'compacted':
|
||||
log(`Compacted: ${event.text}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +505,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): void {
|
||||
function dispatchResultText(text: string, routing: RoutingContext): { sent: number; hasUnwrapped: boolean } {
|
||||
const MESSAGE_RE = /<message\s+to="([^"]+)"\s*>([\s\S]*?)<\/message>/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
@@ -470,9 +540,11 @@ function dispatchResultText(text: string, routing: RoutingContext): void {
|
||||
log(`[scratchpad] ${scratchpad.slice(0, 500)}${scratchpad.length > 500 ? '…' : ''}`);
|
||||
}
|
||||
|
||||
if (sent === 0 && text.trim()) {
|
||||
const hasUnwrapped = sent === 0 && !!scratchpad;
|
||||
if (hasUnwrapped) {
|
||||
log(`WARNING: agent output had no <message to="..."> blocks — nothing was sent`);
|
||||
}
|
||||
return { sent, hasUnwrapped };
|
||||
}
|
||||
|
||||
function sendToDestination(dest: DestinationEntry, body: string, routing: RoutingContext): void {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ClaudeProvider } from './claude.js';
|
||||
|
||||
// maybeRotateContinuation guards the cold-resume failure mode: a long-lived
|
||||
// session whose on-disk transcript has grown so large (or old) that the SDK
|
||||
// can't reload it before the host's idle ceiling kills the container.
|
||||
|
||||
let tmp: string;
|
||||
let prevHome: string | undefined;
|
||||
let prevConv: string | undefined;
|
||||
let prevBytes: string | undefined;
|
||||
let prevDays: string | undefined;
|
||||
|
||||
const PROJECT_DIR = '-workspace-agent';
|
||||
const CWD = '/workspace/agent';
|
||||
|
||||
function writeTranscript(sessionId: string, bytes: number, firstTs?: string): string {
|
||||
const dir = path.join(tmp, '.claude', 'projects', PROJECT_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const p = path.join(dir, `${sessionId}.jsonl`);
|
||||
const first =
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
timestamp: firstTs ?? new Date().toISOString(),
|
||||
message: { role: 'user', content: 'hello' },
|
||||
}) + '\n';
|
||||
const filler = 'x'.repeat(Math.max(0, bytes - first.length));
|
||||
fs.writeFileSync(p, first + filler);
|
||||
return p;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-rotate-'));
|
||||
prevHome = process.env.HOME;
|
||||
prevConv = process.env.NANOCLAW_CONVERSATIONS_DIR;
|
||||
prevBytes = process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES;
|
||||
prevDays = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS;
|
||||
process.env.HOME = tmp;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.NANOCLAW_CONVERSATIONS_DIR = path.join(tmp, 'conversations');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
const restore = (k: string, v: string | undefined) => (v === undefined ? delete process.env[k] : (process.env[k] = v));
|
||||
restore('HOME', prevHome);
|
||||
restore('NANOCLAW_CONVERSATIONS_DIR', prevConv);
|
||||
restore('CLAUDE_TRANSCRIPT_ROTATE_BYTES', prevBytes);
|
||||
restore('CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS', prevDays);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('ClaudeProvider.maybeRotateContinuation', () => {
|
||||
it('keeps a small, recent transcript (returns null, leaves file in place)', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024);
|
||||
const p = writeTranscript('sess-small', 4096);
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('sess-small', CWD)).toBeNull();
|
||||
expect(fs.existsSync(p)).toBe(true);
|
||||
});
|
||||
|
||||
it('rotates an oversized transcript (returns reason, moves the .jsonl aside)', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(64 * 1024);
|
||||
const p = writeTranscript('sess-big', 200 * 1024);
|
||||
const provider = new ClaudeProvider();
|
||||
const reason = provider.maybeRotateContinuation('sess-big', CWD);
|
||||
expect(reason).toContain('MB');
|
||||
expect(fs.existsSync(p)).toBe(false); // original moved out of the resume path
|
||||
const dir = path.dirname(p);
|
||||
expect(fs.readdirSync(dir).some((f) => f.startsWith('sess-big.jsonl.rotated-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rotates an aged transcript even when small', () => {
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES = String(1024 * 1024);
|
||||
process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS = '7';
|
||||
const old = new Date(Date.now() - 10 * 86400_000).toISOString();
|
||||
writeTranscript('sess-old', 2048, old);
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('sess-old', CWD)).toContain('d');
|
||||
});
|
||||
|
||||
it('returns null for an unknown session id', () => {
|
||||
const provider = new ClaudeProvider();
|
||||
expect(provider.maybeRotateContinuation('does-not-exist', CWD)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
|
||||
@@ -188,49 +189,126 @@ const postToolUseHook: HookCallback = async () => {
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Read a Claude transcript .jsonl, render a markdown summary, and drop it into
|
||||
* the agent's `conversations/` folder so context survives a compaction or a
|
||||
* session rotation. Best-effort: returns false (and logs) on any failure.
|
||||
*/
|
||||
function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: string | undefined, assistantName?: string): boolean {
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
if (messages.length === 0) return false;
|
||||
|
||||
// Try to get summary from sessions index
|
||||
let summary: string | undefined;
|
||||
const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const name = summary
|
||||
? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
|
||||
: `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function createPreCompactHook(assistantName?: string): HookCallback {
|
||||
return async (input) => {
|
||||
const preCompact = input as PreCompactHookInput;
|
||||
const { transcript_path: transcriptPath, session_id: sessionId } = preCompact;
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
log('No transcript found for archiving');
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(transcriptPath, 'utf-8');
|
||||
const messages = parseTranscript(content);
|
||||
if (messages.length === 0) return {};
|
||||
|
||||
// Try to get summary from sessions index
|
||||
let summary: string | undefined;
|
||||
const indexPath = path.join(path.dirname(transcriptPath), 'sessions-index.json');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
try {
|
||||
const index = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
|
||||
summary = index.entries?.find((e: { sessionId: string; summary?: string }) => e.sessionId === sessionId)?.summary;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const name = summary
|
||||
? summary.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50)
|
||||
: `conversation-${new Date().getHours().toString().padStart(2, '0')}${new Date().getMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const conversationsDir = '/workspace/agent/conversations';
|
||||
fs.mkdirSync(conversationsDir, { recursive: true });
|
||||
const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`;
|
||||
fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName));
|
||||
log(`Archived conversation to ${filename}`);
|
||||
} catch (err) {
|
||||
log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
archiveTranscriptFile(preCompact.transcript_path, preCompact.session_id, assistantName);
|
||||
return {};
|
||||
};
|
||||
}
|
||||
|
||||
// ── Continuation rotation (cold-resume guard) ──
|
||||
|
||||
/**
|
||||
* Resume cost is dominated by transcript size. Past this many bytes a fresh
|
||||
* cold container can't reload the .jsonl before the host's 30-min idle ceiling
|
||||
* fires, so the session is dropped and started clean. Operator-overridable.
|
||||
*/
|
||||
function transcriptRotateBytes(): number {
|
||||
return Number(process.env.CLAUDE_TRANSCRIPT_ROTATE_BYTES) || 12 * 1024 * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secondary age trigger, measured from the transcript's first entry. 0 (or a
|
||||
* non-positive value) disables the age check; size alone then governs.
|
||||
*/
|
||||
function transcriptRotateAgeMs(): number {
|
||||
const raw = process.env.CLAUDE_TRANSCRIPT_ROTATE_AGE_DAYS;
|
||||
if (raw === undefined || raw.trim() === '') return 14 * 86_400_000;
|
||||
const days = Number(raw);
|
||||
if (!Number.isFinite(days)) return 14 * 86_400_000;
|
||||
// Explicit non-positive override disables the age check; size alone governs.
|
||||
return days > 0 ? days * 86_400_000 : Infinity;
|
||||
}
|
||||
|
||||
function claudeProjectsDir(): string {
|
||||
const base = process.env.CLAUDE_CONFIG_DIR || path.join(process.env.HOME || os.homedir(), '.claude');
|
||||
return path.join(base, 'projects');
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the .jsonl backing a session id. The SDK names project dirs by a
|
||||
* mangled cwd; rather than reproduce that convention we scan project dirs for
|
||||
* `<sessionId>.jsonl` (session ids are UUIDs, so this is unambiguous).
|
||||
*/
|
||||
function findTranscriptPath(sessionId: string): string | null {
|
||||
const projects = claudeProjectsDir();
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(projects);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
const candidate = path.join(projects, dir, `${sessionId}.jsonl`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Epoch-ms of the first transcript entry, or null if unreadable. */
|
||||
function transcriptStartMs(transcriptPath: string): number | null {
|
||||
try {
|
||||
const fd = fs.openSync(transcriptPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(4096);
|
||||
const n = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
|
||||
const ts = JSON.parse(firstLine)?.timestamp;
|
||||
const ms = ts ? Date.parse(ts) : NaN;
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provider ──
|
||||
|
||||
/**
|
||||
@@ -277,6 +355,41 @@ export class ClaudeProvider implements AgentProvider {
|
||||
return STALE_SESSION_RE.test(msg);
|
||||
}
|
||||
|
||||
maybeRotateContinuation(continuation: string): string | null {
|
||||
const transcriptPath = findTranscriptPath(continuation);
|
||||
if (!transcriptPath) return null;
|
||||
|
||||
let size: number;
|
||||
try {
|
||||
size = fs.statSync(transcriptPath).size;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxBytes = transcriptRotateBytes();
|
||||
const startMs = transcriptStartMs(transcriptPath);
|
||||
const ageMs = startMs === null ? 0 : Date.now() - startMs;
|
||||
const maxAgeMs = transcriptRotateAgeMs();
|
||||
|
||||
let reason: string | null = null;
|
||||
if (size > maxBytes) {
|
||||
reason = `transcript ${(size / 1_048_576).toFixed(1)}MB > ${(maxBytes / 1_048_576).toFixed(0)}MB cap`;
|
||||
} else if (startMs !== null && ageMs > maxAgeMs) {
|
||||
reason = `transcript ${(ageMs / 86_400_000).toFixed(1)}d old > ${(maxAgeMs / 86_400_000).toFixed(0)}d cap`;
|
||||
}
|
||||
if (!reason) return null;
|
||||
|
||||
// Preserve a readable summary, then move the heavy .jsonl out of the
|
||||
// resume path so the SDK starts a fresh session and the disk is reclaimed.
|
||||
archiveTranscriptFile(transcriptPath, continuation, this.assistantName);
|
||||
try {
|
||||
fs.renameSync(transcriptPath, `${transcriptPath}.rotated-${Date.now()}`);
|
||||
} catch (err) {
|
||||
log(`Failed to move rotated transcript aside: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
query(input: QueryInput): AgentQuery {
|
||||
const stream = new MessageStream();
|
||||
stream.push(input.prompt);
|
||||
@@ -302,7 +415,7 @@ export class ClaudeProvider implements AgentProvider {
|
||||
effort: this.effort as any,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
@@ -336,7 +449,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: 'compacted', text: `Context compacted${detail}.` };
|
||||
yield { type: 'result', 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' };
|
||||
|
||||
@@ -14,6 +14,21 @@ export interface AgentProvider {
|
||||
* (missing transcript, unknown session, etc.) and should be cleared.
|
||||
*/
|
||||
isSessionInvalid(err: unknown): boolean;
|
||||
|
||||
/**
|
||||
* Optional pre-resume maintenance. Given the stored continuation token,
|
||||
* decide whether its backing transcript has grown too large or too old to
|
||||
* resume cheaply. Return a non-null reason string to tell the caller to drop
|
||||
* the continuation and start a fresh session (the provider archives any
|
||||
* recoverable summary first); return null to keep resuming.
|
||||
*
|
||||
* Guards the cold-resume failure mode: a long-lived hub session accumulates
|
||||
* days of history — including base64 image blocks the agent Read — and the
|
||||
* SDK reloads the whole .jsonl on every resume. Past a threshold the first
|
||||
* turn alone can exceed the host's idle ceiling, so the container is killed
|
||||
* before it ever replies. Providers without an on-disk transcript omit this.
|
||||
*/
|
||||
maybeRotateContinuation?(continuation: string, cwd: string): string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,12 +104,4 @@ 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' }
|
||||
/**
|
||||
* 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 };
|
||||
| { type: 'activity' };
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
import { isUploadTraceCommand } from './upload-trace.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('isUploadTraceCommand', () => {
|
||||
const make = (text: unknown) => ({ content: JSON.stringify({ text }) }) as MessageInRow;
|
||||
|
||||
it('matches /upload-trace (case-insensitive, with args)', () => {
|
||||
expect(isUploadTraceCommand(make('/upload-trace'))).toBe(true);
|
||||
expect(isUploadTraceCommand(make('/UPLOAD-TRACE'))).toBe(true);
|
||||
expect(isUploadTraceCommand(make(' /upload-trace now '))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match other text or commands', () => {
|
||||
expect(isUploadTraceCommand(make('hello'))).toBe(false);
|
||||
expect(isUploadTraceCommand(make('/upload'))).toBe(false);
|
||||
expect(isUploadTraceCommand(make('/clear'))).toBe(false);
|
||||
expect(isUploadTraceCommand({ content: 'not json' } as MessageInRow)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — /upload-trace command', () => {
|
||||
it('handles the command in the runner, writes a status, skips query', async () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('m-upload-trace', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ text: '/upload-trace' }));
|
||||
|
||||
// If the provider were ever queried it would emit this — asserting its
|
||||
// absence proves the runner intercepted /upload-trace instead of the LLM.
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 5000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 5000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
// A status line from uploadTrace() — never the provider's reply.
|
||||
const text = JSON.parse(out[0].content).text as string;
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
expect(text).not.toBe('should not run');
|
||||
|
||||
// Command message was completed (not left pending).
|
||||
expect(getPendingMessages()).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise<void> {
|
||||
return Promise.race([
|
||||
runPollLoop({ provider, providerName: 'mock', cwd: '/tmp' }),
|
||||
new Promise<void>((_, reject) => {
|
||||
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
}),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)),
|
||||
]);
|
||||
}
|
||||
|
||||
async function waitFor(condition: () => boolean, timeoutMs: number): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout');
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { MessageInRow } from './db/messages-in.js';
|
||||
|
||||
/**
|
||||
* `/upload-trace` command: upload this session's Claude Code transcript to the user's
|
||||
* own private `{hf_user}/nanoclaw-traces` dataset, browsable in the HF Agent
|
||||
* Trace Viewer. The transcript the Claude provider keeps under
|
||||
* `~/.claude/projects/<dir>/<sessionId>.jsonl` is already in the format the
|
||||
* viewer auto-detects, so this just locates the newest one and pushes it.
|
||||
*
|
||||
* Auth is the OneCLI gateway's job: curl goes out through the injected
|
||||
* HTTPS_PROXY, which adds the user's HF token. We never see the raw token, and
|
||||
* a 401 from `whoami` is our "not signed in" signal.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Narrow check for /upload-trace — the runner handles this command directly
|
||||
* (no LLM turn). Admin-gated by the host router before it reaches the container.
|
||||
*/
|
||||
export function isUploadTraceCommand(msg: MessageInRow): boolean {
|
||||
let text = '';
|
||||
try {
|
||||
text = (JSON.parse(msg.content)?.text ?? '').trim();
|
||||
} catch {
|
||||
return false; // non-JSON content is never a command
|
||||
}
|
||||
return text.toLowerCase().startsWith('/upload-trace');
|
||||
}
|
||||
|
||||
/** Newest Claude Code transcript jsonl (the current session). */
|
||||
function newestTranscript(): string | null {
|
||||
const projects = path.join(os.homedir(), '.claude', 'projects');
|
||||
let best: { p: string; m: number } | null = null;
|
||||
let dirs: string[];
|
||||
try {
|
||||
dirs = fs.readdirSync(projects);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
for (const dir of dirs) {
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(path.join(projects, dir));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.jsonl')) continue;
|
||||
const p = path.join(projects, dir, f);
|
||||
const m = fs.statSync(p).mtimeMs;
|
||||
if (!best || m > best.m) best = { p, m };
|
||||
}
|
||||
}
|
||||
return best?.p ?? null;
|
||||
}
|
||||
|
||||
function curl(args: string[], input?: string): { ok: boolean; out: string } {
|
||||
const r = spawnSync('curl', args, { input, encoding: 'utf-8' });
|
||||
return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') };
|
||||
}
|
||||
|
||||
/** Returns a user-facing status line. Never throws. */
|
||||
export function uploadTrace(): string {
|
||||
const file = newestTranscript();
|
||||
if (!file) return 'No transcript to upload for this session yet.';
|
||||
|
||||
const who = curl(['-sf', 'https://huggingface.co/api/whoami-v2']);
|
||||
if (!who.ok) {
|
||||
return [
|
||||
"Can't upload — no Hugging Face token is available to this agent. To set it up:",
|
||||
'',
|
||||
'1. Create a token with WRITE access at https://huggingface.co/settings/tokens',
|
||||
' (New token → type "Write" → copy it).',
|
||||
'',
|
||||
'2. Add it to the OneCLI vault. Open the dashboard — remotely at https://app.onecli.sh/',
|
||||
' or on the host at http://127.0.0.1:10254 — then Secrets → New secret,',
|
||||
' paste the token, and set the host pattern to huggingface.co',
|
||||
'',
|
||||
'3. Assign it to this agent — new agents start with no secrets attached.',
|
||||
' In the same dashboard, open this agent and set its secret mode to "all"; or from the host run:',
|
||||
' onecli agents list # find this agent\'s id',
|
||||
' onecli agents set-secret-mode --id <agent-id> --mode all',
|
||||
'',
|
||||
'Then run /upload-trace again — no restart needed.',
|
||||
].join('\n');
|
||||
}
|
||||
let user: string | undefined;
|
||||
try {
|
||||
user = JSON.parse(who.out)?.name;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
if (!user) return 'Could not resolve your Hugging Face username.';
|
||||
|
||||
const repo = `${user}/nanoclaw-traces`;
|
||||
// Idempotent create — ignore failure (already exists / no-op). The
|
||||
// Content-Type header is required: without it curl sends form-encoding and
|
||||
// the Hub rejects the body with 400 (expected string at "name").
|
||||
curl([
|
||||
'-sf',
|
||||
'-X',
|
||||
'POST',
|
||||
'https://huggingface.co/api/repos/create',
|
||||
'-H',
|
||||
'Content-Type: application/json',
|
||||
'-d',
|
||||
JSON.stringify({ type: 'dataset', name: 'nanoclaw-traces', private: true }),
|
||||
]);
|
||||
|
||||
const content = fs.readFileSync(file).toString('base64');
|
||||
const repoPath = `sessions/${path.basename(file)}`;
|
||||
const ndjson =
|
||||
JSON.stringify({ key: 'header', value: { summary: 'add session trace' } }) +
|
||||
'\n' +
|
||||
JSON.stringify({
|
||||
key: 'file',
|
||||
value: { path: repoPath, encoding: 'base64', content },
|
||||
}) +
|
||||
'\n';
|
||||
|
||||
const commit = curl(
|
||||
[
|
||||
'-sf',
|
||||
'-X',
|
||||
'POST',
|
||||
`https://huggingface.co/api/datasets/${repo}/commit/main`,
|
||||
'-H',
|
||||
'Content-Type: application/x-ndjson',
|
||||
'--data-binary',
|
||||
'@-',
|
||||
],
|
||||
ndjson,
|
||||
);
|
||||
if (!commit.ok) {
|
||||
return 'Upload to Hugging Face failed (the transcript may be too large for an inline commit).';
|
||||
}
|
||||
return `Uploaded → https://huggingface.co/datasets/${repo}/blob/main/${repoPath}`;
|
||||
}
|
||||
@@ -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`, 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.
|
||||
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.
|
||||
|
||||
@@ -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 using `send_message`
|
||||
1. Send a short, warm greeting
|
||||
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?
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: whatsapp-formatting
|
||||
description: Format messages for WhatsApp, including mentions that render as real WhatsApp tags. Use when responding in a WhatsApp conversation (platform_id / chatJid ends with @s.whatsapp.net or @g.us).
|
||||
---
|
||||
|
||||
# WhatsApp Message Formatting
|
||||
|
||||
WhatsApp uses its own lightweight markup and a phone-number-based mention syntax. The host's WhatsApp adapter (Baileys) handles markdown conversion automatically, but **mentions are only protocol-level mentions if you use the right syntax** — otherwise they render as plain text and don't notify the recipient.
|
||||
|
||||
## How to detect WhatsApp context
|
||||
|
||||
You're in a WhatsApp conversation when any of these are true:
|
||||
- The chat JID / platform id ends with `@s.whatsapp.net` (1-on-1 DM)
|
||||
- The chat JID / platform id ends with `@g.us` (group)
|
||||
- Your inbound message metadata has `chatJid` matching the above
|
||||
|
||||
## Mentions — the important part
|
||||
|
||||
To tag a user so their name appears **bold and clickable** in WhatsApp and they get a push notification, write the `@` followed by their phone number digits (no `+`, no spaces, no display name):
|
||||
|
||||
```
|
||||
@15551234567 can you confirm?
|
||||
```
|
||||
|
||||
The adapter scans your outgoing text for `@<digits>` (5–15 digits, optional leading `+` is stripped) and tells WhatsApp to render them as real mention tags.
|
||||
|
||||
**The sender's phone JID is always in your inbound message metadata.** When a user writes to you, inbound `content.sender` looks like `15551234567@s.whatsapp.net`. The part before the `@` is exactly what you put after `@` when tagging them back.
|
||||
|
||||
### Wrong vs right
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam can you...` | Plain text `@Adam`. No tag, no notification. |
|
||||
| `@15551234567 can you...` | Bold/blue **@Adam** (or whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — adapter strips the `+`. |
|
||||
|
||||
### Picking who to tag
|
||||
|
||||
- In a DM, there's no real need to tag the recipient (they already see every message), but tagging still works if you want emphasis.
|
||||
- In a group, look at the `participants` / inbound `content.sender` to find the JID of the person you mean. Don't guess from display names — pushNames can collide and are not reliable.
|
||||
- If you don't know the JID, just refer to the person by name in plain prose. Don't write `@<name>` — it won't tag and it will look like a tag that failed.
|
||||
|
||||
## Text styles
|
||||
|
||||
WhatsApp uses single-character delimiters, *not* doubled like standard Markdown.
|
||||
|
||||
| Style | Syntax | Renders as |
|
||||
|-------|--------|------------|
|
||||
| Bold | `*bold*` | **bold** |
|
||||
| Italic | `_italic_` | *italic* |
|
||||
| Strikethrough | `~strike~` | ~strike~ |
|
||||
| Monospace | `` `code` `` | `code` |
|
||||
| Block monospace | ```` ```block``` ```` | preformatted block |
|
||||
|
||||
The adapter converts standard Markdown (`**bold**`, `[link](url)`, `# heading`) to the WhatsApp-native form automatically, so you don't have to think about it — but be aware that single asterisks become italics, not bold.
|
||||
|
||||
## What not to do
|
||||
|
||||
- Don't write `<@U123>` (that's Slack), `<@!123>` (Discord), or any other channel's mention syntax.
|
||||
- Don't paste a full JID like `@15551234567@s.whatsapp.net` in the text — only the digits before the JID's `@` go after your `@`.
|
||||
- Don't try to tag display names. WhatsApp has no display-name-based mention API.
|
||||
@@ -0,0 +1,19 @@
|
||||
## WhatsApp mentions — always use phone digits
|
||||
|
||||
When you are replying in a WhatsApp conversation (the inbound message's `chatJid` ends with `@s.whatsapp.net` for a DM or `@g.us` for a group), and you want to tag a person so their name appears **bold and clickable** with a push notification, write `@` followed by their phone-number digits — never the display name.
|
||||
|
||||
**The sender's phone JID is in your inbound message metadata** at `content.sender` (e.g. `15551234567@s.whatsapp.net`). The part before the `@` is exactly what you put after `@` when tagging them.
|
||||
|
||||
| You write | What recipients see |
|
||||
|-----------|---------------------|
|
||||
| `@Adam, can you...` | Plain text. No tag, no notification. |
|
||||
| `@15551234567, can you...` | Bold/blue **@Adam** (whatever name they're saved as), notification fires. |
|
||||
| `@+15551234567 ...` | Same as above — the adapter strips the `+` automatically. |
|
||||
|
||||
The host adapter scans your outbound text for `@<5–15 digits>` (with optional leading `+`) and tells WhatsApp to render those as real mention tags. If the digits aren't in the text, the tag doesn't render — no exceptions.
|
||||
|
||||
### In groups
|
||||
|
||||
Tag the person you're addressing using their JID from inbound metadata (look at the most recent message from them). Don't guess — pushNames collide and aren't reliable.
|
||||
|
||||
If you don't know someone's JID, refer to them by name in plain prose. Do not write `@<displayname>` hoping it works.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Structure
|
||||
|
||||
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
|
||||
**`nanocoai/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.
|
||||
|
||||
|
||||
@@ -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/qwibitai/nanoclaw.git
|
||||
git clone https://github.com/nanocoai/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/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
cd ~ && git clone https://github.com/nanocoai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
|
||||
```
|
||||
|
||||
### WhatsApp QR code doesn't display
|
||||
|
||||
+22
-22
@@ -23,7 +23,7 @@ This replaces the previous `skills-engine/` system (three-way file merging, `.na
|
||||
|
||||
### Repository structure
|
||||
|
||||
The upstream repo (`qwibitai/nanoclaw`) maintains:
|
||||
The upstream repo (`nanocoai/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 (`qwibitai/nanoclaw-skills`)
|
||||
- Live in the marketplace repo (`nanocoai/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": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ NanoClaw's `.claude/settings.json` registers the official marketplace:
|
||||
The marketplace repo uses Claude Code's plugin structure:
|
||||
|
||||
```
|
||||
qwibitai/nanoclaw-skills/
|
||||
nanocoai/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 `qwibitai/nanoclaw` on GitHub (click the Fork button)
|
||||
1. Fork `nanocoai/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/qwibitai/nanoclaw.git` and have local customizations:
|
||||
Users who previously ran `git clone https://github.com/nanocoai/nanoclaw.git` and have local customizations:
|
||||
|
||||
1. Fork `qwibitai/nanoclaw` on GitHub
|
||||
1. Fork `nanocoai/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/qwibitai/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` = qwibitai/nanoclaw
|
||||
3. From this point, `origin` = their fork, `upstream` = nanocoai/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 qwibitai/nanoclaw:main
|
||||
# Create PR from my-fix to nanocoai/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 `qwibitai/nanoclaw`
|
||||
1. Fork `nanocoai/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 (`qwibitai/nanoclaw-skills`)
|
||||
4. Add the skill's SKILL.md to the marketplace repo (`nanocoai/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": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/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 `qwibitai/nanoclaw`
|
||||
1. Fork `nanocoai/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` — `qwibitai/nanoclaw` (core updates)
|
||||
- `upstream` — `nanocoai/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/qwibitai/NanoClaw.git
|
||||
git clone https://github.com/nanocoai/NanoClaw.git
|
||||
cd NanoClaw
|
||||
claude
|
||||
```
|
||||
|
||||
After:
|
||||
```
|
||||
1. Fork qwibitai/nanoclaw on GitHub
|
||||
1. Fork nanocoai/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/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.
|
||||
- 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.
|
||||
- **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": "qwibitai/nanoclaw-skills"
|
||||
"repo": "nanocoai/nanoclaw-skills"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -601,7 +601,7 @@ Operational skills (`setup`, `debug`, `update-nanoclaw`, `customize`, `update-sk
|
||||
|
||||
### New infrastructure
|
||||
|
||||
- **Marketplace repo** (`qwibitai/nanoclaw-skills`) — single Claude Code plugin bundling SKILL.md files for all feature skills
|
||||
- **Marketplace repo** (`nanocoai/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 `qwibitai/nanoclaw` on GitHub
|
||||
> 1. Fork `nanocoai/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 `qwibitai/nanoclaw`
|
||||
> 1. Fork `nanocoai/nanoclaw`
|
||||
> 2. Branch from `main` and make your code changes
|
||||
> 3. Open a regular PR
|
||||
>
|
||||
|
||||
+1
-1
@@ -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/qwibitai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n' "$(dim '6. Clone the repo: git clone https://github.com/nanocoai/nanoclaw.git && cd nanoclaw')"
|
||||
printf ' %s\n\n' "$(dim '7. Re-run setup: bash nanoclaw.sh')"
|
||||
exit 1
|
||||
;;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.54",
|
||||
"version": "2.0.72",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ A GitHub Action that calculates the size of your codebase in terms of tokens and
|
||||
## Usage
|
||||
|
||||
```yaml
|
||||
- uses: qwibitai/nanoclaw/repo-tokens@v1
|
||||
- uses: nanocoai/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/qwibitai/NanoClaw) |  |
|
||||
| [NanoClaw](https://github.com/nanocoai/NanoClaw) |  |
|
||||
|
||||
### Full workflow example
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: qwibitai/nanoclaw/repo-tokens@v1
|
||||
- uses: nanocoai/nanoclaw/repo-tokens@v1
|
||||
id: tokens
|
||||
with:
|
||||
include: 'src/**/*.ts'
|
||||
|
||||
@@ -114,7 +114,7 @@ runs:
|
||||
with open(readme_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
repo_tokens_url = "https://github.com/qwibitai/nanoclaw/tree/main/repo-tokens"
|
||||
repo_tokens_url = "https://github.com/nanocoai/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/qwibitai/nanoclaw/tree/main/repo-tokens"
|
||||
repo_tokens_url = "https://github.com/nanocoai/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>
|
||||
|
||||
@@ -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="181k tokens, 91% of context window">
|
||||
<title>181k tokens, 91% 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/qwibitai/nanoclaw/tree/main/repo-tokens">
|
||||
<a xlink:href="https://github.com/nanocoai/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">181k</text>
|
||||
<text x="71" y="14">181k</text>
|
||||
</g>
|
||||
</g>
|
||||
</a>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -247,7 +247,7 @@ async function collectRemoteCreds(): Promise<RemoteCreds> {
|
||||
"Photon is a separate service that owns an iMessage account and",
|
||||
"exposes it over HTTP. NanoClaw will talk to it via its API.",
|
||||
'',
|
||||
' 1. Set up a Photon server: https://photon.im',
|
||||
' 1. Set up a Photon server: https://photon.codes',
|
||||
' 2. Copy the server URL and API key from your Photon dashboard',
|
||||
].join('\n'),
|
||||
'Remote iMessage via Photon',
|
||||
|
||||
@@ -146,6 +146,7 @@ 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"',
|
||||
|
||||
Regular → Executable
+4
-4
@@ -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 qwibitai/nanoclaw, and prints its name.
|
||||
# at nanocoai/nanoclaw, and prints its name.
|
||||
#
|
||||
# Fallback: if no existing remote matches, add `upstream` pointing at
|
||||
# github.com/qwibitai/nanoclaw and return that — keeps forks without an
|
||||
# github.com/nanocoai/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*)
|
||||
*qwibitai/nanoclaw*|*nanocoai/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/qwibitai/nanoclaw.git 2>/dev/null || true
|
||||
git remote add upstream https://github.com/nanocoai/nanoclaw.git 2>/dev/null || true
|
||||
printf '%s' "upstream"
|
||||
}
|
||||
|
||||
@@ -70,8 +70,8 @@ export const CONFIG: Entry[] = [
|
||||
surface: 'flag+ui',
|
||||
group: 'OneCLI',
|
||||
type: 'url',
|
||||
default: 'https://app.onecli.sh',
|
||||
placeholder: 'https://app.onecli.sh',
|
||||
default: 'https://api.onecli.sh',
|
||||
placeholder: 'https://api.onecli.sh',
|
||||
validate: httpUrl,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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/qwibitai/nanoclaw, 2.x)',
|
||||
'User-Agent': 'NanoClaw-Migration (https://github.com/nanocoai/nanoclaw, 2.x)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
+2
-1
@@ -105,6 +105,7 @@ 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';
|
||||
|
||||
@@ -153,7 +154,7 @@ function installOnecli(): { stdout: string; ok: boolean } {
|
||||
if (cleanup) stdout += cleanup + '\n';
|
||||
|
||||
// Gateway install (docker-compose based, no rate-limit concerns).
|
||||
const gw = runInstall('curl -fsSL onecli.sh/install | sh');
|
||||
const gw = runInstall(`export ONECLI_VERSION=${ONECLI_GATEWAY_VERSION} && curl -fsSL onecli.sh/install | sh`);
|
||||
stdout += gw.stdout;
|
||||
if (!gw.ok) {
|
||||
log.error('OneCLI gateway install failed', { stderr: gw.stderr });
|
||||
|
||||
+34
-31
@@ -194,7 +194,12 @@ export async function run(args: string[]): Promise<void> {
|
||||
|
||||
// 4. Send onboarding message — only on first wiring, not re-registration
|
||||
if (newlyWired) {
|
||||
const { session } = resolveSession(agentGroup.id, messagingGroup.id, null, parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared');
|
||||
const { session } = resolveSession(
|
||||
agentGroup.id,
|
||||
messagingGroup.id,
|
||||
null,
|
||||
parsed.sessionMode as 'shared' | 'per-thread' | 'agent-shared',
|
||||
);
|
||||
writeSessionMessage(agentGroup.id, session.id, {
|
||||
id: generateId('onboard'),
|
||||
kind: 'task',
|
||||
@@ -208,40 +213,38 @@ export async function run(args: string[]): Promise<void> {
|
||||
log.info('Onboarding message written', { sessionId: session.id, channel: parsed.channel });
|
||||
}
|
||||
|
||||
// 5. Update assistant name in CLAUDE.md files if different from default
|
||||
// 5. Apply assistant name to JUST the group being registered.
|
||||
//
|
||||
// Earlier behavior did a project-wide find-replace of "Andy" across every
|
||||
// `groups/*/CLAUDE.md` and overwrote `.env`'s `ASSISTANT_NAME`, which
|
||||
// caused two real-world problems:
|
||||
// - registering a second agent (e.g. "Homie") clobbered the unrelated
|
||||
// primary agent's CLAUDE.md (replacing "Andy" with "Homie" in
|
||||
// groups/diddyclaw/CLAUDE.md when Diddyclaw was already in place);
|
||||
// - the global `.env` ASSISTANT_NAME flipped to the most recently-
|
||||
// registered agent, which then became the install-wide default
|
||||
// trigger for any *new* group registered without an explicit
|
||||
// `--assistant-name`.
|
||||
// Both were unintentional global side-effects of a per-agent operation.
|
||||
// Scope is now strictly: only the freshly-registered agent's own
|
||||
// `groups/<folder>/CLAUDE.md`.
|
||||
let nameUpdated = false;
|
||||
if (parsed.assistantName !== 'Andy') {
|
||||
log.info('Updating assistant name', { from: 'Andy', to: parsed.assistantName });
|
||||
|
||||
const groupsDir = path.join(projectRoot, 'groups');
|
||||
const mdFiles = fs
|
||||
.readdirSync(groupsDir)
|
||||
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
|
||||
.filter((f) => fs.existsSync(f));
|
||||
|
||||
for (const mdFile of mdFiles) {
|
||||
let content = fs.readFileSync(mdFile, 'utf-8');
|
||||
content = content.replace(/^# Andy$/m, `# ${parsed.assistantName}`);
|
||||
content = content.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
fs.writeFileSync(mdFile, content);
|
||||
log.info('Updated CLAUDE.md', { file: mdFile });
|
||||
}
|
||||
|
||||
// Update .env
|
||||
const envFile = path.join(projectRoot, '.env');
|
||||
if (fs.existsSync(envFile)) {
|
||||
let envContent = fs.readFileSync(envFile, 'utf-8');
|
||||
if (envContent.includes('ASSISTANT_NAME=')) {
|
||||
envContent = envContent.replace(/^ASSISTANT_NAME=.*$/m, `ASSISTANT_NAME="${parsed.assistantName}"`);
|
||||
} else {
|
||||
envContent += `\nASSISTANT_NAME="${parsed.assistantName}"`;
|
||||
const mdFile = path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md');
|
||||
if (fs.existsSync(mdFile)) {
|
||||
const before = fs.readFileSync(mdFile, 'utf-8');
|
||||
const after = before
|
||||
.replace(/^# Andy$/m, `# ${parsed.assistantName}`)
|
||||
.replace(/You are Andy/g, `You are ${parsed.assistantName}`);
|
||||
if (after !== before) {
|
||||
fs.writeFileSync(mdFile, after);
|
||||
log.info('Updated assistant name in registered group only', {
|
||||
file: mdFile,
|
||||
to: parsed.assistantName,
|
||||
});
|
||||
nameUpdated = true;
|
||||
}
|
||||
fs.writeFileSync(envFile, envContent);
|
||||
} else {
|
||||
fs.writeFileSync(envFile, `ASSISTANT_NAME="${parsed.assistantName}"\n`);
|
||||
}
|
||||
log.info('Set ASSISTANT_NAME in .env');
|
||||
nameUpdated = true;
|
||||
}
|
||||
|
||||
emitStatus('REGISTER_CHANNEL', {
|
||||
|
||||
@@ -36,6 +36,7 @@ const LINK_TIMEOUT_MS = 180_000;
|
||||
const DEFAULT_DEVICE_NAME = 'NanoClaw';
|
||||
|
||||
interface SignalAccount {
|
||||
number?: string;
|
||||
account?: string;
|
||||
registered?: boolean;
|
||||
}
|
||||
@@ -59,7 +60,7 @@ function listAccounts(): string[] {
|
||||
const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[];
|
||||
return parsed
|
||||
.filter((a) => a.registered !== false)
|
||||
.map((a) => a.account ?? '')
|
||||
.map((a) => a.number ?? a.account ?? '')
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
|
||||
+1
-15
@@ -21,6 +21,7 @@ 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);
|
||||
@@ -105,21 +106,6 @@ function printUsage(): void {
|
||||
);
|
||||
}
|
||||
|
||||
function formatTransportError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
|
||||
return [
|
||||
`ncl: cannot reach NanoClaw host (${msg}).`,
|
||||
`Is the host running? Start it with: pnpm run dev`,
|
||||
`Or, if installed as a service:`,
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/com.nanoclaw`,
|
||||
` Linux: systemctl --user restart nanoclaw`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
return `ncl: transport error: ${msg}\n`;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`ncl: unexpected error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(2);
|
||||
|
||||
@@ -52,6 +52,12 @@ export interface ResourceDef {
|
||||
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: {
|
||||
@@ -226,6 +232,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
description: `List all ${def.plural}.`,
|
||||
access: def.operations.list,
|
||||
resource: def.plural,
|
||||
generic: 'list',
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
handler: genericList(def),
|
||||
});
|
||||
@@ -237,6 +244,7 @@ export function registerResource(def: ResourceDef): void {
|
||||
description: `Get a ${def.name} by ID.`,
|
||||
access: def.operations.get,
|
||||
resource: def.plural,
|
||||
generic: 'get',
|
||||
parseArgs: (raw) => normalizeArgs(raw),
|
||||
handler: genericGet(def),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,13 @@ 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(),
|
||||
@@ -97,6 +104,7 @@ register({
|
||||
description: 'returns mock group rows',
|
||||
resource: 'groups',
|
||||
access: 'open',
|
||||
generic: 'list',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async () => [
|
||||
{ id: 'g1', name: 'my-group' },
|
||||
@@ -109,6 +117,7 @@ register({
|
||||
description: 'returns a mock session row',
|
||||
resource: 'sessions',
|
||||
access: 'open',
|
||||
generic: 'get',
|
||||
parseArgs: (raw) => raw,
|
||||
handler: async (args) => ({
|
||||
id: args.id,
|
||||
@@ -116,11 +125,43 @@ register({
|
||||
}),
|
||||
});
|
||||
|
||||
// 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 ---
|
||||
@@ -402,4 +443,72 @@ describe('CLI scope enforcement', () => {
|
||||
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 caller’s 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+32
-7
@@ -11,6 +11,7 @@ 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> {
|
||||
@@ -87,6 +88,16 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +135,28 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
try {
|
||||
let data = await cmd.handler(parsed, ctx);
|
||||
|
||||
// Post-handler group scope enforcement: filter/verify results belong
|
||||
// to the caller's agent group. Catches leaks that pre-handler auto-fill
|
||||
// can't prevent (e.g. `groups list` where the id arg is skipped by the
|
||||
// generic list handler, or `sessions get` by UUID).
|
||||
if (ctx.caller === 'agent' && cmd.resource) {
|
||||
// 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 groupField = cmd.resource === 'groups' ? 'id' : 'agent_group_id';
|
||||
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) =>
|
||||
@@ -139,7 +164,7 @@ export async function dispatch(req: RequestFrame, ctx: CallerContext): Promise<R
|
||||
row !== null &&
|
||||
(row as Record<string, unknown>)[groupField] === ctx.agentGroupId,
|
||||
);
|
||||
} else if (data && typeof data === 'object' && groupField in (data as Record<string, unknown>)) {
|
||||
} 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.');
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ export type CommandDef<TArgs = unknown, TData = unknown> = {
|
||||
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>;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Regression test for #2465 — approval-path `ncl destinations add/remove`
|
||||
* must hydrate every active session's `inbound.db` `destinations` table,
|
||||
* not just the central `agent_destinations` row.
|
||||
*
|
||||
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
|
||||
* `caller: 'host'` after admin approval, so this test invokes dispatch
|
||||
* with the host caller — same code path as a real approval payload.
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-destinations' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-destinations';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { initSessionFolder, inboundDbPath } from '../../session-manager.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `destinations-add` / `destinations-remove` commands.
|
||||
import './destinations.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function readSessionDestinations(agentGroupId: string, sessionId: string) {
|
||||
const db = new Database(inboundDbPath(agentGroupId, sessionId), { readonly: true });
|
||||
const rows = db.prepare('SELECT name, type, agent_group_id FROM destinations ORDER BY name').all() as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
agent_group_id: string | null;
|
||||
}>;
|
||||
db.close();
|
||||
return rows;
|
||||
}
|
||||
|
||||
describe('destinations CLI custom ops project to inbound.db (#2465)', () => {
|
||||
const SOURCE = 'ag-source';
|
||||
const TARGET = 'ag-target';
|
||||
const SESSION_A = 'sess-source-1';
|
||||
const SESSION_B = 'sess-source-2';
|
||||
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
|
||||
createAgentGroup({ id: SOURCE, name: 'source', folder: 'source', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: TARGET, name: 'target', folder: 'target', agent_provider: null, created_at: now() });
|
||||
|
||||
// Two active sessions for the source agent — both must receive the
|
||||
// projected destination row. Fixing only the "newest" session is a
|
||||
// common regression shape, so the second session catches that.
|
||||
for (const sid of [SESSION_A, SESSION_B]) {
|
||||
createSession({
|
||||
id: sid,
|
||||
agent_group_id: SOURCE,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
initSessionFolder(SOURCE, sid);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('add: projects the new destination into every active session inbound.db', async () => {
|
||||
// Sanity: inbound.db starts with no destinations.
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
|
||||
|
||||
// caller: 'host' is what the cli_command approval handler in dispatch.ts
|
||||
// uses when it re-enters dispatch after admin approval.
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-1',
|
||||
command: 'destinations-add',
|
||||
args: {
|
||||
agent_group_id: SOURCE,
|
||||
local_name: 'helper',
|
||||
target_type: 'agent',
|
||||
target_id: TARGET,
|
||||
},
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
|
||||
for (const sid of [SESSION_A, SESSION_B]) {
|
||||
const rows = readSessionDestinations(SOURCE, sid);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({ name: 'helper', type: 'agent', agent_group_id: TARGET });
|
||||
}
|
||||
});
|
||||
|
||||
it('remove: clears the destination from every active session inbound.db', async () => {
|
||||
await dispatch(
|
||||
{
|
||||
id: 'req-add',
|
||||
command: 'destinations-add',
|
||||
args: { agent_group_id: SOURCE, local_name: 'helper', target_type: 'agent', target_id: TARGET },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
// Precondition: add succeeded and projected to both sessions.
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toHaveLength(1);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toHaveLength(1);
|
||||
|
||||
const resp = await dispatch(
|
||||
{
|
||||
id: 'req-remove',
|
||||
command: 'destinations-remove',
|
||||
args: { agent_group_id: SOURCE, local_name: 'helper' },
|
||||
},
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_A)).toEqual([]);
|
||||
expect(readSessionDestinations(SOURCE, SESSION_B)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,32 @@
|
||||
import { getDb } from '../../db/connection.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSessionsByAgentGroup } from '../../db/sessions.js';
|
||||
import { log } from '../../log.js';
|
||||
import { registerResource } from '../crud.js';
|
||||
|
||||
/**
|
||||
* Project the agent's central `agent_destinations` rows into every active
|
||||
* session's `inbound.db`. The agent-to-agent module is optional, so we guard
|
||||
* on `hasTable('agent_destinations')` and load `writeDestinations` lazily —
|
||||
* same pattern as container-runner.ts on container wake.
|
||||
*
|
||||
* Called from both `add` and `remove` so the live container picks up the
|
||||
* change without waiting for the next spawn. Without this, send_message to
|
||||
* the new local_name silently drops with "unknown destination" until restart.
|
||||
* See the destination-projection invariant in
|
||||
* src/modules/agent-to-agent/db/agent-destinations.ts.
|
||||
*/
|
||||
async function projectDestinationsToSessions(agentGroupId: string): Promise<void> {
|
||||
if (!hasTable(getDb(), 'agent_destinations')) return;
|
||||
const { writeDestinations } = await import('../../modules/agent-to-agent/write-destinations.js');
|
||||
for (const session of getSessionsByAgentGroup(agentGroupId)) {
|
||||
try {
|
||||
writeDestinations(agentGroupId, session.id);
|
||||
} catch (err) {
|
||||
log.warn('Failed to project destinations to session inbound.db', { agentGroupId, sessionId: session.id, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerResource({
|
||||
name: 'destination',
|
||||
plural: 'destinations',
|
||||
@@ -8,6 +34,7 @@ registerResource({
|
||||
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',
|
||||
@@ -55,6 +82,7 @@ registerResource({
|
||||
VALUES (?, ?, ?, ?, datetime('now'))`,
|
||||
)
|
||||
.run(agentGroupId, localName, targetType, targetId);
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId };
|
||||
},
|
||||
},
|
||||
@@ -70,6 +98,7 @@ registerResource({
|
||||
.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');
|
||||
await projectDestinationsToSessions(agentGroupId);
|
||||
return { removed: { agent_group_id: agentGroupId, local_name: localName } };
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Regression test for #2525 — `ncl groups delete` must cascade dependent
|
||||
* rows in FK order so the final `DELETE FROM agent_groups` succeeds even
|
||||
* when the group has sessions, destinations, approvals, role grants, etc.
|
||||
*
|
||||
* The bug pre-fix: the generic single-table DELETE handler ran a bare
|
||||
* `DELETE FROM agent_groups WHERE id = ?` which always failed with a
|
||||
* `SQLITE_CONSTRAINT_FOREIGNKEY` when anything pointed at the group.
|
||||
*
|
||||
* The approval handler in `dispatch.ts` re-enters `dispatch()` with
|
||||
* `caller: 'host'` after admin approval, so the test invokes dispatch
|
||||
* with the host caller — same code path a real approval would take.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../container-runner.js', () => ({
|
||||
wakeContainer: vi.fn().mockResolvedValue(undefined),
|
||||
isContainerRunning: vi.fn().mockReturnValue(false),
|
||||
getActiveContainerCount: vi.fn().mockReturnValue(0),
|
||||
killContainer: vi.fn(),
|
||||
buildAgentGroupImage: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('../../config.js', async () => {
|
||||
const actual = await vi.importActual('../../config.js');
|
||||
return { ...actual, DATA_DIR: '/tmp/nanoclaw-test-cli-groups' };
|
||||
});
|
||||
|
||||
const TEST_DIR = '/tmp/nanoclaw-test-cli-groups';
|
||||
|
||||
import { initTestDb, closeDb, runMigrations, createAgentGroup, getDb } from '../../db/index.js';
|
||||
import { createSession } from '../../db/sessions.js';
|
||||
import { dispatch } from '../dispatch.js';
|
||||
// Side-effect import: registers the `groups-*` commands (including delete).
|
||||
import './groups.js';
|
||||
|
||||
function now(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
function count(sql: string, ...params: unknown[]): number {
|
||||
return (
|
||||
getDb()
|
||||
.prepare(sql)
|
||||
.get(...params) as { c: number }
|
||||
).c;
|
||||
}
|
||||
|
||||
describe('groups CLI delete cascades dependent rows (#2525)', () => {
|
||||
beforeEach(() => {
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
fs.mkdirSync(TEST_DIR, { recursive: true });
|
||||
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
if (fs.existsSync(TEST_DIR)) fs.rmSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
it('deletes a group with sessions, destinations, approvals, members, roles, and wirings', async () => {
|
||||
const GID = 'ag-victim';
|
||||
const SID = 'sess-victim-1';
|
||||
const MGID = 'mg-1';
|
||||
const UID = 'tg:42';
|
||||
|
||||
createAgentGroup({ id: GID, name: 'victim', folder: 'victim', agent_provider: null, created_at: now() });
|
||||
createSession({
|
||||
id: SID,
|
||||
agent_group_id: GID,
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Direct inserts for the dependent tables. Keeps the fixture minimal —
|
||||
// we only need rows that establish FK relationships, not full domain
|
||||
// entities.
|
||||
db.prepare(`INSERT INTO users (id, kind, display_name, created_at) VALUES (?, 'telegram', 'someone', ?)`).run(
|
||||
UID,
|
||||
now(),
|
||||
);
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, unknown_sender_policy, created_at)
|
||||
VALUES (?, 'telegram', 'tg-1', 'chat', 1, 'strict', ?)`,
|
||||
).run(MGID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, 'chan', 'channel', ?, ?)`,
|
||||
).run(GID, MGID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_questions (question_id, session_id, message_out_id, title, options_json, created_at)
|
||||
VALUES (?, ?, 'mout-1', 'q', '[]', ?)`,
|
||||
).run('q-1', SID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, status, title, options_json)
|
||||
VALUES (?, ?, 'req-1', 'cli_command', '{}', ?, ?, 'pending', '', '[]')`,
|
||||
).run('pa-1', SID, now(), GID);
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_sender_approvals (id, messaging_group_id, agent_group_id, sender_identity, sender_name, original_message, approver_user_id, created_at)
|
||||
VALUES ('psa-1', ?, ?, 'tg:99', 'them', '{}', ?, ?)`,
|
||||
).run(MGID, GID, UID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO pending_channel_approvals (messaging_group_id, agent_group_id, original_message, approver_user_id, created_at)
|
||||
VALUES (?, ?, '{}', ?, ?)`,
|
||||
).run(MGID, GID, UID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, engage_mode, sender_scope, ignored_message_policy, session_mode, priority, created_at)
|
||||
VALUES ('mga-1', ?, ?, 'mention', 'all', 'drop', 'shared', 0, ?)`,
|
||||
).run(MGID, GID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO agent_group_members (user_id, agent_group_id, added_by, added_at) VALUES (?, ?, NULL, ?)`,
|
||||
).run(UID, GID, now());
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) VALUES (?, 'admin', ?, NULL, ?)`,
|
||||
).run(UID, GID, now());
|
||||
|
||||
// Container config row exercises the ON DELETE CASCADE on container_configs.
|
||||
db.prepare(
|
||||
`INSERT INTO container_configs
|
||||
(agent_group_id, provider, model, effort, image_tag, assistant_name, max_messages_per_prompt,
|
||||
skills, mcp_servers, packages_apt, packages_npm, additional_mounts, cli_scope, updated_at)
|
||||
VALUES (?, NULL, NULL, NULL, NULL, NULL, NULL, '"all"', '{}', '[]', '[]', '[]', 'group', ?)`,
|
||||
).run(GID, now());
|
||||
|
||||
const resp = await dispatch({ id: 'req-del', command: 'groups-delete', args: { id: GID } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const data = (resp as { ok: true; data: { deleted: string; removed: Record<string, number> } }).data;
|
||||
expect(data.deleted).toBe(GID);
|
||||
expect(data.removed).toMatchObject({
|
||||
sessions: 1,
|
||||
pending_questions: 1,
|
||||
pending_approvals: 1,
|
||||
agent_destinations_owned: 1,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 1,
|
||||
pending_channel_approvals: 1,
|
||||
messaging_group_agents: 1,
|
||||
agent_group_members: 1,
|
||||
user_roles: 1,
|
||||
container_configs: 1,
|
||||
});
|
||||
|
||||
// The group and every dependent row must be gone.
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM sessions WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_questions WHERE session_id = ?', SID)).toBe(0);
|
||||
expect(
|
||||
count('SELECT COUNT(*) AS c FROM pending_approvals WHERE agent_group_id = ? OR session_id = ?', GID, SID),
|
||||
).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_sender_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM pending_channel_approvals WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_group_members WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM user_roles WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM container_configs WHERE agent_group_id = ?', GID)).toBe(0);
|
||||
|
||||
// Unrelated tables untouched.
|
||||
expect(count('SELECT COUNT(*) AS c FROM users WHERE id = ?', UID)).toBe(1);
|
||||
expect(count('SELECT COUNT(*) AS c FROM messaging_groups WHERE id = ?', MGID)).toBe(1);
|
||||
});
|
||||
|
||||
it('removes polymorphic agent_destinations that point at the deleted group', async () => {
|
||||
const A = 'ag-a';
|
||||
const B = 'ag-b';
|
||||
createAgentGroup({ id: A, name: 'a', folder: 'a', agent_provider: null, created_at: now() });
|
||||
createAgentGroup({ id: B, name: 'b', folder: 'b', agent_provider: null, created_at: now() });
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// B has a destination pointing at A. target_id is polymorphic — no FK
|
||||
// constraint enforces it, so without explicit cleanup the row would
|
||||
// dangle after A is deleted.
|
||||
db.prepare(
|
||||
`INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at)
|
||||
VALUES (?, 'sibling', 'agent', ?, ?)`,
|
||||
).run(B, A, now());
|
||||
|
||||
const resp = await dispatch({ id: 'req-del-a', command: 'groups-delete', args: { id: A } }, { caller: 'host' });
|
||||
|
||||
expect(resp.ok).toBe(true);
|
||||
const data = (resp as { ok: true; data: { removed: Record<string, number> } }).data;
|
||||
expect(data.removed.agent_destinations_pointing).toBe(1);
|
||||
|
||||
// A is gone, B remains, and B's stale destination is cleaned up.
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', A)).toBe(0);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_groups WHERE id = ?', B)).toBe(1);
|
||||
expect(count('SELECT COUNT(*) AS c FROM agent_destinations WHERE agent_group_id = ?', B)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns a handler error for an unknown group id', async () => {
|
||||
const resp = await dispatch(
|
||||
{ id: 'req-missing', command: 'groups-delete', args: { id: 'ag-does-not-exist' } },
|
||||
{ caller: 'host' },
|
||||
);
|
||||
|
||||
expect(resp.ok).toBe(false);
|
||||
expect((resp as { ok: false; error: { code: string; message: string } }).error.code).toBe('handler-error');
|
||||
expect((resp as { ok: false; error: { code: string; message: string } }).error.message).toMatch(/not found/i);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { McpServerConfig } from '../../container-config.js';
|
||||
import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js';
|
||||
import { restartAgentGroupContainers } from '../../container-restart.js';
|
||||
import { getDb, hasTable } from '../../db/connection.js';
|
||||
import { getSession } from '../../db/sessions.js';
|
||||
import { writeSessionMessage } from '../../session-manager.js';
|
||||
import {
|
||||
@@ -38,6 +39,7 @@ registerResource({
|
||||
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 },
|
||||
{
|
||||
@@ -56,8 +58,97 @@ registerResource({
|
||||
},
|
||||
{ name: 'created_at', type: 'string', description: 'Auto-set.', generated: true },
|
||||
],
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' },
|
||||
// `delete` is intentionally not in `operations` — the generic single-table
|
||||
// DELETE violates FK constraints (see #2525). The cascading handler is
|
||||
// provided as `customOperations.delete` below.
|
||||
operations: { list: 'open', get: 'open', create: 'approval', update: 'approval' },
|
||||
customOperations: {
|
||||
delete: {
|
||||
access: 'approval',
|
||||
description:
|
||||
'Delete an agent group and its dependent rows (sessions, destinations, approvals, role grants, ' +
|
||||
'memberships, channel wirings). FK-ordered cascade in a single transaction. ' +
|
||||
'Use --id <group-id>. Out of scope: killing running containers, on-disk cleanup of groups/<folder>/ and data/v2-sessions/<group-id>/.',
|
||||
handler: async (args) => {
|
||||
const id = args.id as string;
|
||||
if (!id) throw new Error('--id is required');
|
||||
const db = getDb();
|
||||
|
||||
// Verify the group exists before doing anything — preserves the
|
||||
// genericDelete behaviour of throwing "not found" for unknown IDs.
|
||||
const exists = db.prepare('SELECT 1 FROM agent_groups WHERE id = ? LIMIT 1').get(id);
|
||||
if (!exists) throw new Error(`group not found: ${id}`);
|
||||
|
||||
const hasAgentDestinations = hasTable(db, 'agent_destinations');
|
||||
const hasPendingApprovals = hasTable(db, 'pending_approvals');
|
||||
|
||||
// FK-ordered cascade. Single sync transaction — better-sqlite3 rolls
|
||||
// back the whole thing if any statement throws (e.g. an FK constraint
|
||||
// we missed), so the central DB stays consistent. The `removed` counts
|
||||
// are sourced from each DELETE's `changes` so they describe exactly
|
||||
// what the transaction did, not a separate pre-flight snapshot.
|
||||
const cascade = db.transaction((groupId: string) => {
|
||||
const counts = {
|
||||
sessions: 0,
|
||||
pending_questions: 0,
|
||||
pending_approvals: 0,
|
||||
agent_destinations_owned: 0,
|
||||
agent_destinations_pointing: 0,
|
||||
pending_sender_approvals: 0,
|
||||
pending_channel_approvals: 0,
|
||||
messaging_group_agents: 0,
|
||||
agent_group_members: 0,
|
||||
user_roles: 0,
|
||||
container_configs: 0,
|
||||
};
|
||||
|
||||
if (hasAgentDestinations) {
|
||||
counts.agent_destinations_owned = db
|
||||
.prepare('DELETE FROM agent_destinations WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.agent_destinations_pointing = db
|
||||
.prepare('DELETE FROM agent_destinations WHERE target_type = ? AND target_id = ?')
|
||||
.run('agent', groupId).changes;
|
||||
}
|
||||
counts.pending_questions = db
|
||||
.prepare(
|
||||
'DELETE FROM pending_questions WHERE session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
|
||||
)
|
||||
.run(groupId).changes;
|
||||
if (hasPendingApprovals) {
|
||||
counts.pending_approvals = db
|
||||
.prepare(
|
||||
'DELETE FROM pending_approvals WHERE agent_group_id = ? OR session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)',
|
||||
)
|
||||
.run(groupId, groupId).changes;
|
||||
}
|
||||
counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes;
|
||||
counts.pending_sender_approvals = db
|
||||
.prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.pending_channel_approvals = db
|
||||
.prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.messaging_group_agents = db
|
||||
.prepare('DELETE FROM messaging_group_agents WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.agent_group_members = db
|
||||
.prepare('DELETE FROM agent_group_members WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
counts.user_roles = db.prepare('DELETE FROM user_roles WHERE agent_group_id = ?').run(groupId).changes;
|
||||
// migration-014 has ON DELETE CASCADE on container_configs.agent_group_id;
|
||||
// the explicit delete here mirrors the other tables and surfaces the count.
|
||||
counts.container_configs = db
|
||||
.prepare('DELETE FROM container_configs WHERE agent_group_id = ?')
|
||||
.run(groupId).changes;
|
||||
db.prepare('DELETE FROM agent_groups WHERE id = ?').run(groupId);
|
||||
return counts;
|
||||
});
|
||||
const removed = cascade(id);
|
||||
|
||||
return { deleted: id, removed };
|
||||
},
|
||||
},
|
||||
restart: {
|
||||
access: 'approval',
|
||||
description:
|
||||
|
||||
@@ -8,6 +8,7 @@ registerResource({
|
||||
description:
|
||||
'Agent group member — grants an unprivileged user permission to interact with an agent group. Users with admin or owner roles on the group are implicitly members and do not need a separate membership row. Membership is checked by the router when sender_scope is "known".',
|
||||
idColumn: 'user_id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{
|
||||
name: 'user_id',
|
||||
|
||||
@@ -7,6 +7,7 @@ registerResource({
|
||||
description:
|
||||
'Session — the runtime unit. Maps one (agent_group, messaging_group, thread) combination to a container with its own inbound.db and outbound.db. Created automatically by the router when a message arrives.',
|
||||
idColumn: 'id',
|
||||
scopeField: 'agent_group_id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'string', description: 'UUID.', generated: true },
|
||||
{ name: 'agent_group_id', type: 'string', description: 'Agent group this session runs.' },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
|
||||
import { formatTransportError } from './transport-errors.js';
|
||||
|
||||
describe('formatTransportError', () => {
|
||||
it('renders per-install service names on ENOENT, not the bare v1 names', () => {
|
||||
const out = formatTransportError(new Error('connect ENOENT /tmp/nanoclaw.sock'));
|
||||
|
||||
// Regression for #2484: pre-fix, this string was a hardcoded
|
||||
// `com.nanoclaw` / `nanoclaw`, which doesn't match the actual
|
||||
// v2 per-install slug-suffixed unit and label.
|
||||
expect(out).toContain(`gui/$(id -u)/${getLaunchdLabel()}`);
|
||||
expect(out).toContain(`systemctl --user restart ${getSystemdUnit()}`);
|
||||
expect(out).not.toMatch(/gui\/\$\(id -u\)\/com\.nanoclaw\b(?!-v2)/);
|
||||
expect(out).not.toMatch(/systemctl --user restart nanoclaw\b(?!-v2)/);
|
||||
});
|
||||
|
||||
it('renders the same on ECONNREFUSED', () => {
|
||||
const out = formatTransportError(new Error('connect ECONNREFUSED'));
|
||||
expect(out).toContain(getLaunchdLabel());
|
||||
expect(out).toContain(getSystemdUnit());
|
||||
});
|
||||
|
||||
it('falls back to a generic transport error for other failures', () => {
|
||||
const out = formatTransportError(new Error('some unrelated failure'));
|
||||
expect(out).toBe('ncl: transport error: some unrelated failure\n');
|
||||
expect(out).not.toContain('launchctl');
|
||||
expect(out).not.toContain('systemctl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getLaunchdLabel, getSystemdUnit } from '../install-slug.js';
|
||||
|
||||
export function formatTransportError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('ENOENT') || msg.includes('ECONNREFUSED')) {
|
||||
// `bin/ncl` cd's to the project root before exec'ing client.ts, so
|
||||
// process.cwd() is the install dir — install-slug helpers pick up
|
||||
// the right per-checkout suffix.
|
||||
return [
|
||||
`ncl: cannot reach NanoClaw host (${msg}).`,
|
||||
`Is the host running? Start it with: pnpm run dev`,
|
||||
`Or, if installed as a service:`,
|
||||
` macOS: launchctl kickstart -k gui/$(id -u)/${getLaunchdLabel()}`,
|
||||
` Linux: systemctl --user restart ${getSystemdUnit()}`,
|
||||
``,
|
||||
].join('\n');
|
||||
}
|
||||
return `ncl: transport error: ${msg}\n`;
|
||||
}
|
||||
+1
-1
@@ -12,7 +12,7 @@ import { getDb, hasTable } from './db/connection.js';
|
||||
export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string };
|
||||
|
||||
const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']);
|
||||
const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']);
|
||||
|
||||
/**
|
||||
* Classify a message and decide whether it should reach the container.
|
||||
|
||||
@@ -31,6 +31,8 @@
|
||||
* Affected call sites today (keep this list honest if you add more):
|
||||
* - src/delivery.ts::handleSystemAction case 'create_agent'
|
||||
* - src/db/messaging-groups.ts::createMessagingGroupAgent
|
||||
* - src/cli/resources/destinations.ts::add / remove (admin-time `ncl destinations`
|
||||
* — iterates over `getSessionsByAgentGroup(agentGroupId)`)
|
||||
*/
|
||||
import type { AgentDestination } from '../../../types.js';
|
||||
import { getDb } from '../../../db/connection.js';
|
||||
|
||||
@@ -357,6 +357,87 @@ describe('unknown-channel registration flow', () => {
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let a scoped admin connect an unknown channel to another agent group', async () => {
|
||||
const { routeInbound } = await import('../../router.js');
|
||||
const { getResponseHandlers } = await import('../../response-registry.js');
|
||||
const { getDb } = await import('../../db/connection.js');
|
||||
|
||||
createAgentGroup({ id: 'ag-2', name: 'Betty', folder: 'betty', agent_provider: null, created_at: now() });
|
||||
upsertUser({ id: 'telegram:scoped-admin', kind: 'telegram', display_name: 'Scoped Admin', created_at: now() });
|
||||
grantRole({
|
||||
user_id: 'telegram:scoped-admin',
|
||||
role: 'admin',
|
||||
agent_group_id: 'ag-1',
|
||||
granted_by: 'telegram:owner',
|
||||
granted_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-dm-scoped-admin',
|
||||
channel_type: 'telegram',
|
||||
platform_id: 'dm-scoped-admin',
|
||||
name: 'Scoped Admin DM',
|
||||
is_group: 0,
|
||||
unknown_sender_policy: 'public',
|
||||
created_at: now(),
|
||||
});
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO user_dms (user_id, channel_type, messaging_group_id, resolved_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run('telegram:scoped-admin', 'telegram', 'mg-dm-scoped-admin', now());
|
||||
|
||||
await routeInbound(groupMention('chat-scoped-cross-group'));
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
const pending = getDb().prepare('SELECT messaging_group_id FROM pending_channel_approvals').get() as {
|
||||
messaging_group_id: string;
|
||||
};
|
||||
expect(pending).toBeDefined();
|
||||
expect(deliverMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverMock.mock.calls[0][1]).toBe('dm-scoped-admin');
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'choose_existing',
|
||||
userId: 'scoped-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-scoped-admin',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
const followupPayload = JSON.parse(deliverMock.mock.calls[1][4] as string) as {
|
||||
options: Array<{ label: string; value: string }>;
|
||||
};
|
||||
expect(followupPayload.options.map((option) => option.value)).toContain('connect:ag-1');
|
||||
expect(followupPayload.options.map((option) => option.value)).not.toContain('connect:ag-2');
|
||||
|
||||
for (const handler of getResponseHandlers()) {
|
||||
const claimed = await handler({
|
||||
questionId: pending.messaging_group_id,
|
||||
value: 'connect:ag-2',
|
||||
userId: 'scoped-admin',
|
||||
channelType: 'telegram',
|
||||
platformId: 'dm-scoped-admin',
|
||||
threadId: null,
|
||||
});
|
||||
if (claimed) break;
|
||||
}
|
||||
|
||||
const mgaCount = (
|
||||
getDb()
|
||||
.prepare('SELECT COUNT(*) AS c FROM messaging_group_agents WHERE messaging_group_id = ?')
|
||||
.get(pending.messaging_group_id) as { c: number }
|
||||
).c;
|
||||
expect(mgaCount).toBe(0);
|
||||
const stillPending = (getDb().prepare('SELECT COUNT(*) AS c FROM pending_channel_approvals').get() as { c: number })
|
||||
.c;
|
||||
expect(stillPending).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('no-owner / no-agent failure modes', () => {
|
||||
|
||||
@@ -55,6 +55,7 @@ import type { InboundEvent } from '../../channels/adapter.js';
|
||||
import type { AgentGroup } from '../../types.js';
|
||||
import { pickApprovalDelivery, pickApprover } from '../approvals/primitive.js';
|
||||
import { createPendingChannelApproval, hasInFlightChannelApproval } from './db/pending-channel-approvals.js';
|
||||
import { hasAdminPrivilege } from './db/user-roles.js';
|
||||
|
||||
// ── Value constants (response handler in index.ts parses these) ──
|
||||
|
||||
@@ -76,15 +77,24 @@ function toFolder(name: string): string {
|
||||
|
||||
// ── Card builders ──
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[]): RawOption[] {
|
||||
function visibleAgentGroupsForApprover(
|
||||
agentGroups: AgentGroup[],
|
||||
approverUserId: string | null | undefined,
|
||||
): AgentGroup[] {
|
||||
if (!approverUserId) return agentGroups;
|
||||
return agentGroups.filter((agentGroup) => hasAdminPrivilege(approverUserId, agentGroup.id));
|
||||
}
|
||||
|
||||
function buildApprovalOptions(agentGroups: AgentGroup[], approverUserId?: string | null): RawOption[] {
|
||||
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||
const options: RawOption[] = [];
|
||||
if (agentGroups.length === 1) {
|
||||
if (visibleAgentGroups.length === 1) {
|
||||
options.push({
|
||||
label: `Connect to ${agentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${agentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${agentGroups[0].id}`,
|
||||
label: `Connect to ${visibleAgentGroups[0].name}`,
|
||||
selectedLabel: `✅ Connected to ${visibleAgentGroups[0].name}`,
|
||||
value: `${CONNECT_PREFIX}${visibleAgentGroups[0].id}`,
|
||||
});
|
||||
} else {
|
||||
} else if (visibleAgentGroups.length > 1) {
|
||||
options.push({
|
||||
label: 'Choose existing agent',
|
||||
selectedLabel: '📋 Choosing…',
|
||||
@@ -194,7 +204,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
const channelName = originMg?.name ?? null;
|
||||
const title = isGroup ? '📣 Bot mentioned in new channel' : '💬 New direct message';
|
||||
const question = buildQuestionText(isGroup, senderName, channelName, originChannelType);
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups));
|
||||
const options = normalizeOptions(buildApprovalOptions(agentGroups, delivery.userId));
|
||||
|
||||
createPendingChannelApproval({
|
||||
messaging_group_id: messagingGroupId,
|
||||
@@ -241,8 +251,12 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput)
|
||||
/**
|
||||
* Build normalized options for the agent-selection follow-up card.
|
||||
*/
|
||||
export function buildAgentSelectionOptions(agentGroups: AgentGroup[]): NormalizedOption[] {
|
||||
const options: RawOption[] = agentGroups.map((ag) => ({
|
||||
export function buildAgentSelectionOptions(
|
||||
agentGroups: AgentGroup[],
|
||||
approverUserId?: string | null,
|
||||
): NormalizedOption[] {
|
||||
const visibleAgentGroups = visibleAgentGroupsForApprover(agentGroups, approverUserId);
|
||||
const options: RawOption[] = visibleAgentGroups.map((ag) => ({
|
||||
label: ag.name,
|
||||
selectedLabel: `✅ Connected to ${ag.name}`,
|
||||
value: `${CONNECT_PREFIX}${ag.id}`,
|
||||
|
||||
@@ -227,11 +227,14 @@ async function handleSenderApprovalResponse(payload: ResponsePayload): Promise<b
|
||||
if (!row) return false;
|
||||
|
||||
// payload.userId is the raw platform userId (e.g. "6037840640"); namespace it
|
||||
// with the channel type so it matches users(id) format. Then verify the
|
||||
// clicker is the designated approver OR has owner/admin privilege over this
|
||||
// agent group — any other click is rejected so random users can't self-admit
|
||||
// via stolen card forwarding.
|
||||
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
||||
// with the channel type so it matches users(id) format. Some platforms
|
||||
// (e.g. Teams "29:xxx") already include a colon — mirror resolveOrCreateUser
|
||||
// logic and only prefix when the raw id has no colon.
|
||||
const clickerId = payload.userId
|
||||
? payload.userId.includes(':')
|
||||
? payload.userId
|
||||
: `${payload.channelType}:${payload.userId}`
|
||||
: null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
if (!isAuthorized) {
|
||||
@@ -308,7 +311,11 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
const row = getPendingChannelApproval(payload.questionId);
|
||||
if (!row) return false;
|
||||
|
||||
const clickerId = payload.userId ? `${payload.channelType}:${payload.userId}` : null;
|
||||
const clickerId = payload.userId
|
||||
? payload.userId.includes(':')
|
||||
? payload.userId
|
||||
: `${payload.channelType}:${payload.userId}`
|
||||
: null;
|
||||
const isAuthorized =
|
||||
clickerId !== null && (clickerId === row.approver_user_id || hasAdminPrivilege(clickerId, row.agent_group_id));
|
||||
if (!isAuthorized) {
|
||||
@@ -347,7 +354,7 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
if (!adapter) return true;
|
||||
|
||||
const agentGroups = getAllAgentGroups();
|
||||
const options = buildAgentSelectionOptions(agentGroups);
|
||||
const options = buildAgentSelectionOptions(agentGroups, approverId);
|
||||
const title = '📋 Choose an agent';
|
||||
updatePendingChannelApprovalCard(row.messaging_group_id, title, JSON.stringify(options));
|
||||
|
||||
@@ -431,6 +438,14 @@ async function handleChannelApprovalResponse(payload: ResponsePayload): Promise<
|
||||
deletePendingChannelApproval(row.messaging_group_id);
|
||||
return true;
|
||||
}
|
||||
if (!hasAdminPrivilege(approverId, targetAgentGroupId)) {
|
||||
log.warn('Channel registration: target agent group rejected for unauthorized approver', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
targetAgentGroupId,
|
||||
approverId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
log.warn('Channel registration: unknown response value', {
|
||||
messagingGroupId: row.messaging_group_id,
|
||||
|
||||
Reference in New Issue
Block a user