mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
Compare commits
248 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 | |||
| d8e3f9f959 | |||
| 8d57bdfa3d | |||
| ead25ee6e2 | |||
| 9e1dbdf48c | |||
| 0774667826 | |||
| 0ba4ecadb1 | |||
| ad5d4d2664 | |||
| 9267d52bdb | |||
| 4c57e4d69b | |||
| eff13717f9 | |||
| dc13300fb1 | |||
| d324419d7b | |||
| 0287d71595 | |||
| 05906e4b6a | |||
| 6539c0286a | |||
| 5ba9d23ea8 | |||
| f7a8df0e8e | |||
| 9312d467bd | |||
| bd50ef7e38 | |||
| 25a5b81c59 | |||
| f33f2d89ce | |||
| 661da3969e | |||
| aeeb54a495 | |||
| f9d30e8b9c | |||
| 1c7623ca41 | |||
| faeeba198e | |||
| 04e41fb0ef | |||
| aebcffe180 | |||
| be3a8a97c6 | |||
| a84327573e | |||
| 39e9583820 | |||
| 08698da0d2 | |||
| 9ce82588d9 | |||
| 37b54968ce | |||
| 1efe28ccdc | |||
| 78cf2433a3 | |||
| 4c83a8193b | |||
| 7eebcf74c2 | |||
| 31ccc61b27 | |||
| ef43cbb3d9 | |||
| 0060c6b84a | |||
| e6d470d831 | |||
| 0e11eaf186 | |||
| 4990994204 | |||
| 2d03c94252 | |||
| 01eac7b225 | |||
| 6caad0757a | |||
| ed571d1f66 | |||
| 93ec82ce38 | |||
| 046b99c745 | |||
| 0855369b79 | |||
| 33cbf59dd8 | |||
| 9a649fadc5 | |||
| 405dd34148 | |||
| 81cb13ec46 | |||
| 9629d1cc4a | |||
| 85850874ab | |||
| 6e9f35a646 | |||
| 635a49369f | |||
| 028cb017ed | |||
| 2f552ce1bb | |||
| f3e19872ac | |||
| 9b670563b8 | |||
| 6ea49898dd | |||
| 9090c33e7e | |||
| 3b64d6cf76 | |||
| 35233dabe8 | |||
| 107945f10c | |||
| 3b07c0ceaf | |||
| 1a358dc7e3 | |||
| 7da08b3327 | |||
| 684a98d078 | |||
| e1251da394 | |||
| eb6502a1b2 | |||
| 3af6e70c05 | |||
| 8a7311a7bb | |||
| 61ab60041c | |||
| ca17683e32 | |||
| 6a56b10ffc | |||
| 2754f7559a | |||
| 1594a0c682 | |||
| a6995cc17e | |||
| 93732a4978 | |||
| 350d9631fa | |||
| a90104b8e3 | |||
| 708f98e156 | |||
| b40d43725f | |||
| d92c676327 | |||
| 6f0b8f1961 | |||
| 1afbba6a91 | |||
| cd69bf5c45 | |||
| c3d1b3e976 | |||
| 1240a0cf4f | |||
| 42e8ae004e | |||
| 9ccafcda82 | |||
| 860d1310ca | |||
| 9ca3367229 | |||
| 12719be6e1 | |||
| 57dad14a01 | |||
| 6d8d085f96 | |||
| 4305c6a87d | |||
| 877d2a370a | |||
| 8eff3e558c | |||
| 1eb55e85a0 | |||
| bdb8cf559c | |||
| 5213c98506 | |||
| 3dc29bb674 | |||
| 8771e259a8 | |||
| a597b42648 | |||
| 6865811147 | |||
| 5e2bf1cb54 | |||
| bc19b716bf | |||
| ff90c8f565 | |||
| 13f6fc2093 | |||
| 295275df69 | |||
| 594d1b4055 | |||
| 3a3d2ee644 | |||
| 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
|
||||
|
||||
|
||||
@@ -82,11 +82,14 @@ For each target agent group, confirm OneCLI will inject Gmail secrets into its c
|
||||
onecli agents list
|
||||
```
|
||||
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets:
|
||||
If that agent's `secretMode` is `all`, you're done — Gmail secrets (identified by OneCLI's Gmail hostPattern) will auto-inject. If it's `selective`, explicitly assign the Gmail secrets using the safe merge pattern (`set-secrets` replaces the entire list — always read first):
|
||||
|
||||
```bash
|
||||
onecli secrets list # find Gmail secret IDs (OneCLI creates one per connected app)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-id>
|
||||
GMAIL_IDS=$(onecli secrets list | jq -r '[.data[] | select(.name | test("(?i)gmail")) | .id] | join(",")')
|
||||
CURRENT=$(onecli agents secrets --id <agent-id> | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$GMAIL_IDS" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids "$MERGED"
|
||||
onecli agents secrets --id <agent-id>
|
||||
```
|
||||
|
||||
## Phase 2: Apply Code Changes
|
||||
@@ -95,7 +98,6 @@ onecli agents set-secrets --id <agent-id> --secret-ids <gmail-secret-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"
|
||||
```
|
||||
|
||||
@@ -129,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
|
||||
|
||||
@@ -143,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
|
||||
@@ -203,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.
|
||||
@@ -225,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
|
||||
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
name: add-mnemon
|
||||
description: Add persistent graph-based memory via mnemon. Agents recall past context before responding and remember insights after each turn.
|
||||
---
|
||||
|
||||
# Add Mnemon — Persistent Memory
|
||||
|
||||
Installs [mnemon](https://github.com/mnemon-dev/mnemon) in the agent container image. On each container start, `mnemon setup` registers Claude Code hooks that surface relevant memory before the agent responds and store new insights after each turn. Memory is written to the per-agent-group `.claude/` mount and survives container restarts.
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
**mnemon hooks only work with `--target claude-code`.** If the agent group uses `AGENT_PROVIDER=opencode`, hooks registered by `mnemon setup` will never fire — OpenCode spawns its own process and doesn't invoke the `claude` CLI at all.
|
||||
|
||||
Check your provider:
|
||||
|
||||
```bash
|
||||
grep AGENT_PROVIDER .env groups/*/container.json 2>/dev/null
|
||||
```
|
||||
|
||||
- `AGENT_PROVIDER=claude` (default) — fully compatible, proceed with both Phase 2 steps.
|
||||
- `AGENT_PROVIDER=opencode` — use **Phase 2 (OpenCode path)** instead of the standard entrypoint step.
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
### Check if already applied
|
||||
|
||||
```bash
|
||||
grep -q 'MNEMON_VERSION' container/Dockerfile && echo "Already applied" || echo "Not applied"
|
||||
```
|
||||
|
||||
If already applied, skip to Phase 3 (Verify).
|
||||
|
||||
### Check latest mnemon version
|
||||
|
||||
```bash
|
||||
curl -fsSL https://api.github.com/repos/mnemon-dev/mnemon/releases/latest | grep '"tag_name"'
|
||||
```
|
||||
|
||||
Note the version (e.g. `v0.1.1`) — use it as `MNEMON_VERSION` in the next step.
|
||||
|
||||
## Phase 2: Apply Changes (Claude Code path)
|
||||
|
||||
### 1. Dockerfile — install mnemon binary
|
||||
|
||||
Add after the AWS CLI block, before the Bun runtime section:
|
||||
|
||||
```dockerfile
|
||||
# ---- mnemon — persistent agent memory ----------------------------------------
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
`MNEMON_DATA_DIR` points into the per-agent-group `.claude/` mount so memory persists across container restarts. No extra volume mounts needed.
|
||||
|
||||
### 2. Entrypoint — run mnemon setup on each container start
|
||||
|
||||
`mnemon setup` is idempotent. Edit `container/entrypoint.sh` to run it right after `set -e`, before the `cat` that captures stdin:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# NanoClaw agent container entrypoint.
|
||||
#
|
||||
# ...existing header comment...
|
||||
|
||||
set -e
|
||||
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
|
||||
cat > /tmp/input.json
|
||||
|
||||
exec bun run /app/src/index.ts < /tmp/input.json
|
||||
```
|
||||
|
||||
`>/dev/stderr 2>&1` routes all mnemon output to stderr (docker logs) so it doesn't interfere with the JSON stdin handshake between host and agent-runner.
|
||||
|
||||
### 3. Rebuild and smoke-test the image
|
||||
|
||||
```bash
|
||||
./container/build.sh
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Phase 3: Restart and Verify
|
||||
|
||||
### Restart the service
|
||||
|
||||
Run from your NanoClaw project root:
|
||||
|
||||
```bash
|
||||
source setup/lib/install-slug.sh
|
||||
systemctl --user restart $(systemd_unit) # Linux
|
||||
# launchctl kickstart -k gui/$(id -u)/$(launchd_label) # macOS
|
||||
```
|
||||
|
||||
### Confirm mnemon hooks are registered
|
||||
|
||||
After the next container starts, check that setup ran:
|
||||
|
||||
```bash
|
||||
docker logs $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) 2>&1 | grep -i mnemon
|
||||
```
|
||||
|
||||
Then inspect the hooks inside the running container:
|
||||
|
||||
```bash
|
||||
docker exec $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
cat /home/node/.claude/settings.json | grep -A5 mnemon
|
||||
```
|
||||
|
||||
### Test memory recall
|
||||
|
||||
Have a conversation with the agent, then start a new session and reference something from the earlier one. Mnemon should surface the relevant context automatically without you restating it.
|
||||
|
||||
## Phase 2 (OpenCode path) — context injection
|
||||
|
||||
mnemon hooks don't fire under OpenCode. Instead, the agent-runner injects mnemon context directly into every prompt via `wrapPromptWithContext()` in `container/agent-runner/src/providers/opencode.ts`. This is already implemented in NanoClaw — no code changes needed if you're on current `ester`/`main`.
|
||||
|
||||
**How it works:** On each prompt, `readMnemonContext()` checks for `MNEMON_DATA_DIR` (set by the Dockerfile `ENV`). If the env var is present, it reads `$MNEMON_DATA_DIR/prompt/guide.md` (mnemon's custom prompt guide, written by `mnemon setup`) or falls back to an inline guide. The content is prepended as a `<system>` block, instructing the agent to run `mnemon recall` at the start of relevant tasks and `mnemon remember` after key decisions.
|
||||
|
||||
**What this means for the agent:** The agent (running inside OpenCode) can call `mnemon recall`, `mnemon remember`, `mnemon link`, and `mnemon status` via its bash tool. mnemon writes its graph to `$MNEMON_DATA_DIR`, which is in the per-agent-group `.claude/` mount — so memory persists across container restarts.
|
||||
|
||||
**Applying:** Only the Dockerfile step from Phase 2 is needed for OpenCode agents. Skip `container/entrypoint.sh` entirely.
|
||||
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
Then rebuild: `./container/build.sh`
|
||||
|
||||
### Verify (OpenCode)
|
||||
|
||||
Start a session and ask the agent to run `mnemon status`. It should report empty graphs (no error) on first run.
|
||||
|
||||
```bash
|
||||
# Also confirm the binary is present in the image:
|
||||
docker run --rm --entrypoint mnemon nanoclaw-agent:latest --version
|
||||
```
|
||||
|
||||
## Memory Storage
|
||||
|
||||
Mnemon writes to `/home/node/.claude/mnemon/` inside the container, which maps to the per-agent-group `.claude/` directory on the host. To find the exact host path:
|
||||
|
||||
```bash
|
||||
docker inspect $(docker ps --filter name=nanoclaw-v2 --format '{{.Names}}' | head -1) \
|
||||
--format '{{range .Mounts}}{{if eq .Destination "/home/node/.claude"}}{{.Source}}{{end}}{{end}}'
|
||||
```
|
||||
|
||||
To reset all memory for an agent, stop the container and delete the `mnemon/` subdirectory from that host path.
|
||||
|
||||
## Migration Guide Update
|
||||
|
||||
If you are using `/migrate-nanoclaw`, add these entries to `.nanoclaw-migrations/05-dockerfile.md`:
|
||||
|
||||
**Dockerfile — after AWS CLI, before Bun runtime:**
|
||||
```dockerfile
|
||||
ARG MNEMON_VERSION=0.1.1
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
curl -fsSL "https://github.com/mnemon-dev/mnemon/releases/download/v${MNEMON_VERSION}/mnemon_${MNEMON_VERSION}_linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin mnemon && \
|
||||
chmod +x /usr/local/bin/mnemon
|
||||
ENV MNEMON_DATA_DIR=/home/node/.claude/mnemon
|
||||
```
|
||||
|
||||
**`container/entrypoint.sh` — add after `set -e`:**
|
||||
```bash
|
||||
mnemon setup --target claude-code --yes --global >/dev/stderr 2>&1
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `mnemon: command not found` in container
|
||||
|
||||
The image wasn't rebuilt after adding the Dockerfile layer. Run `./container/build.sh` and restart.
|
||||
|
||||
### Memory not persisting across restarts
|
||||
|
||||
Verify `MNEMON_DATA_DIR` resolves to a mounted path (not an in-container ephemeral directory):
|
||||
|
||||
```bash
|
||||
docker exec <container> sh -c 'ls -la $MNEMON_DATA_DIR'
|
||||
```
|
||||
|
||||
If the directory is empty after conversations, the mount is missing or the path is wrong. Check the host mount with the `docker inspect` command above.
|
||||
|
||||
### Agent not using past memory
|
||||
|
||||
`mnemon setup` writes hooks into `/home/node/.claude/settings.json`. Verify:
|
||||
|
||||
```bash
|
||||
docker exec <container> cat /home/node/.claude/settings.json
|
||||
```
|
||||
|
||||
If the hooks are absent, `mnemon setup` may have failed silently. Check container startup logs for errors from mnemon.
|
||||
|
||||
### Setup fails at container start
|
||||
|
||||
Run setup manually inside a running container to see the full error:
|
||||
|
||||
```bash
|
||||
docker exec -it <container> mnemon setup --target claude-code --yes --global
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -132,12 +132,15 @@ Credentials: register provider API keys in OneCLI with the matching `--host-patt
|
||||
|
||||
After adding a secret, **grant the agent access** — agents in `selective` mode only receive secrets they've been explicitly assigned:
|
||||
|
||||
```bash
|
||||
# Find the agent id and secret id, then:
|
||||
onecli agents set-secrets --id <agent-id> --secret-ids <existing-ids>,<new-secret-id>
|
||||
```
|
||||
Use the safe merge pattern — `set-secrets` replaces the entire list, so always read first:
|
||||
|
||||
Always include existing secret IDs in the list — `set-secrets` replaces, not appends.
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
#### Example: DeepSeek
|
||||
|
||||
|
||||
@@ -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,12 @@ 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)
|
||||
|
||||
Signal responses show `platformMsgId=undefined` in the main log. This means the delivery poll ran but found no adapter — likely a duplicate service instance issue (see above). Affected messages cannot be retried; the user must resend.
|
||||
|
||||
### Lost connection mid-session
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -90,12 +90,12 @@ onecli secrets list | grep -i vercel
|
||||
OneCLI uses selective secret mode — secrets must be explicitly assigned to each agent. Get the Vercel secret ID from the output above, then assign it to every agent:
|
||||
|
||||
```bash
|
||||
# For each agent, add the Vercel secret to its assigned secrets list.
|
||||
# First get current assignments, then set them with the new secret appended.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list 2>/dev/null | grep -B2 "Vercel" | grep '"id"' | head -1 | sed 's/.*"id": "//;s/".*//')
|
||||
for agent in $(onecli agents list 2>/dev/null | grep '"id"' | sed 's/.*"id": "//;s/".*//'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" 2>/dev/null | grep '"' | grep -v hint | grep -v data | sed 's/.*"//;s/".*//' | tr '\n' ',' | sed 's/,$//')
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "${CURRENT:+$CURRENT,}$VERCEL_SECRET_ID"
|
||||
# set-secrets replaces the entire list — read and merge for each agent.
|
||||
VERCEL_SECRET_ID=$(onecli secrets list | jq -r '.data[] | select(.name | test("(?i)vercel")) | .id' | head -1)
|
||||
for agent in $(onecli agents list | jq -r '.data[].id'); do
|
||||
CURRENT=$(onecli agents secrets --id "$agent" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,$VERCEL_SECRET_ID" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$agent" --secret-ids "$MERGED"
|
||||
done
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,7 +57,50 @@ Debug level shows:
|
||||
|
||||
## Common Issues
|
||||
|
||||
### 1. "Claude Code process exited with code 1"
|
||||
### 1. "No adapter for channel type" / Messages silently lost (null platformMsgId)
|
||||
|
||||
**Symptom:** The bot stops replying. `logs/nanoclaw.error.log` shows repeated:
|
||||
```
|
||||
WARN No adapter for channel type channelType="telegram"
|
||||
WARN No adapter for channel type channelType="signal"
|
||||
```
|
||||
The main log shows "Message delivered" entries with `platformMsgId=undefined` — meaning the delivery poll ran, found no adapter, and permanently marked the message as delivered without sending it.
|
||||
|
||||
**Root cause: two NanoClaw service instances running simultaneously.**
|
||||
|
||||
When a second service instance (often `nanoclaw-v2-<id>.service` running alongside `nanoclaw.service`) is active with a stale binary, it has no channel adapters registered. Its delivery poll races against the working instance and wins — permanently marking outbound messages as delivered without ever sending them.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
# Check for duplicate running instances
|
||||
ps aux | grep 'nanoclaw/dist/index.js' | grep -v grep
|
||||
|
||||
# Check which services are active
|
||||
systemctl --user list-units 'nanoclaw*' --all
|
||||
|
||||
# Confirm channel adapters registered by the current process
|
||||
grep "Channel adapter started" logs/nanoclaw.log | tail -10
|
||||
```
|
||||
|
||||
**Fix:**
|
||||
1. Identify which service has the correct binary and EnvironmentFile (the one showing `signal`, `telegram`, `cli` all started in the log).
|
||||
2. Stop and disable the stale duplicate service:
|
||||
```bash
|
||||
systemctl --user stop nanoclaw.service # or whichever is the old one
|
||||
systemctl --user disable nanoclaw.service
|
||||
```
|
||||
3. If the remaining service unit is missing `EnvironmentFile`, add it:
|
||||
```bash
|
||||
# Edit the service unit — add this line under [Service]:
|
||||
# EnvironmentFile=/home/[user]/nanoclaw/.env
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user restart nanoclaw-v2-<id>.service
|
||||
```
|
||||
4. Verify only one instance runs: `ps aux | grep nanoclaw/dist/index.js | grep -v grep`
|
||||
|
||||
**Note:** Messages that were marked delivered with a null `platform_message_id` cannot be automatically retried — they are permanently lost. The user must resend their message.
|
||||
|
||||
### 2. "Claude Code process exited with code 1"
|
||||
|
||||
**Check the container log file** in `groups/{folder}/logs/container-*.log`
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -259,6 +262,41 @@ Tell the user:
|
||||
- To manage secrets: `onecli secrets list`, or open ${ONECLI_URL}
|
||||
- To add rate limits or policies: `onecli rules create --help`
|
||||
|
||||
## Granting secrets to agents (safe merge)
|
||||
|
||||
`set-secrets` **replaces** the agent's entire secret list — it never appends. Always read the current list first and merge before calling it. This pattern is canonical across all skills that assign secrets:
|
||||
|
||||
```bash
|
||||
AGENT_ID=$(onecli agents list | jq -r '.data[] | select(.identifier=="<agentGroupId>") | .id')
|
||||
CURRENT=$(onecli agents secrets --id "$AGENT_ID" | jq -r '[.data[]] | join(",")')
|
||||
MERGED=$(printf '%s' "$CURRENT,<new-secret-id>" | tr ',' '\n' | sort -u | paste -sd ',' -)
|
||||
onecli agents set-secrets --id "$AGENT_ID" --secret-ids "$MERGED"
|
||||
onecli agents secrets --id "$AGENT_ID"
|
||||
```
|
||||
|
||||
- `<agentGroupId>` — the `agentGroupId` field in `groups/<folder>/container.json`
|
||||
- `<new-secret-id>` — the `id` from `onecli secrets list`
|
||||
- Multiple new secrets: append them comma-separated before the `printf` step
|
||||
|
||||
### git over HTTPS
|
||||
|
||||
OneCLI's proxy injects credentials proactively — `injections_applied=1` appears in `docker logs onecli` even when git sends no auth header. However, OneCLI sets `SSL_CERT_FILE` for Node/Python/Deno but not `GIT_SSL_CAINFO`. Without it, git rejects the OneCLI MITM certificate.
|
||||
|
||||
**Auth format matters**: GitHub's git smart HTTP protocol (`github.com`) requires `Basic` auth, not `Bearer`. GitHub's REST API (`api.github.com`) accepts `Bearer`. These must be configured as separate secrets with different formats — see `/add-github` for the full setup.
|
||||
|
||||
If an agent uses `git` or `gh`, add to `data/v2-sessions/<agent-group-id>/.claude-shared/settings.json`:
|
||||
|
||||
```json
|
||||
"GIT_SSL_CAINFO": "/tmp/onecli-combined-ca.pem",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GIT_CONFIG_COUNT": "1",
|
||||
"GIT_CONFIG_KEY_0": "credential.helper",
|
||||
"GIT_CONFIG_VALUE_0": "",
|
||||
"GH_TOKEN": "ghp_onecli_proxy_replaces_this"
|
||||
```
|
||||
|
||||
**Debugging injection**: `docker logs onecli 2>&1 | grep "github.com"` shows every request with `injections_applied=N` and the HTTP status. If `injections_applied=1` but status is still 401, the injected credential value is wrong or uses the wrong auth format for that endpoint.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf ${ONECLI_URL}/health`. Start it with `onecli start` if needed.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
staged=$(git diff --cached --name-only --diff-filter=ACM -- 'src/**/*.ts')
|
||||
pnpm run format:fix
|
||||
if [ -n "$staged" ]; then
|
||||
echo "$staged" | xargs git add
|
||||
fi
|
||||
|
||||
+32
-3
@@ -2,12 +2,41 @@
|
||||
|
||||
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
|
||||
|
||||
## [Unreleased]
|
||||
- **`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
|
||||
|
||||
- **Per-group model and effort overrides.** Agent groups can now run a specific Claude model and effort level, set via `ncl groups config update --model <model> --effort <level>`. Defaults to the host-configured model when unset.
|
||||
- **Claude Code 2.1.128.** Container claude-code bumped from 2.1.116 to 2.1.128.
|
||||
- CLI help text improvements for `ncl groups config` and `ncl groups restart`.
|
||||
|
||||
## [2.0.48] - 2026-05-09
|
||||
|
||||
- **Container config moved to DB.** Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, skills) now lives in the `container_configs` table instead of `groups/<folder>/container.json`. Existing filesystem configs are backfilled automatically on startup. Managed via `ncl groups config get/update` and `config add-mcp-server/remove-mcp-server/add-package/remove-package`.
|
||||
- **Explicit restart with on-wake messages.** Config CLI operations no longer auto-kill containers. New `ncl groups restart` command with `--rebuild` and `--message` flags. On-wake messages (`on_wake` column on `messages_in`) are only picked up by a fresh container's first poll, preventing dying containers from stealing them during the SIGTERM grace period. Self-mod approval handlers (`install_packages`, `add_mcp_server`) use the same race-free mechanism.
|
||||
- **Per-group CLI scope.** New `cli_scope` setting on container config (`disabled` / `group` / `global`, default `group`). Controls what the agent can access via `ncl` from inside the container. `disabled` excludes CLI instructions from CLAUDE.md and blocks all requests. `group` (default) restricts to own-group resources with auto-filled args. `global` gives unrestricted access (set automatically for owner agent groups). Includes post-handler result filtering to prevent cross-group data leaks and blocks `cli_scope` escalation from group-scoped agents.
|
||||
|
||||
## [2.0.45] - 2026-05-08
|
||||
|
||||
- **Admin CLI (`ncl`).** New `ncl` command for querying and modifying the central DB — agent groups, messaging groups, wirings, users, roles, members, destinations, sessions, approvals, and dropped messages. Host-side transport via Unix socket; container-side transport via session DB. Write operations from inside containers go through the approval flow. `list` supports column filtering and `--limit`. Run `ncl help` for usage.
|
||||
- **v1 → v2 migration.** Run `bash migrate-v2.sh` from the v2 checkout. Finds your v1 install (sibling directory or `NANOCLAW_V1_PATH`), merges `.env`, seeds the v2 DB from `registered_groups`, copies group folders (`CLAUDE.md` → `CLAUDE.local.md`), copies session data with conversation continuity, ports scheduled tasks, interactively selects and installs channels (clack multiselect), copies container skills, builds the agent container, and offers a service switchover to test. Hands off to Claude (`/migrate-from-v1`) for owner seeding, access policy, CLAUDE.md cleanup, and fork customization porting. See [docs/migration-dev.md](docs/migration-dev.md) and [docs/v1-to-v2-changes.md](docs/v1-to-v2-changes.md).
|
||||
- **Migration fixes.** `1b-db` now resolves Discord DMs as `discord:@me:<id>` (previously skipped any v1 chat that wasn't a guild channel — a blocker for personal-bot installs). `1c-groups` skips symlinks instead of following them (a single broken `.claude-shared.md → /app/CLAUDE.md` no longer aborts the whole copy). When `1b-db` reuses an auto-created `messaging_group` with no wired agents, its `unknown_sender_policy` is now reconciled to the migration's `public` default.
|
||||
|
||||
## [2.0.0] - 2026-04-22
|
||||
|
||||
|
||||
@@ -72,15 +72,44 @@ For ad-hoc queries from skills or scripts, use the in-tree wrapper rather than t
|
||||
| `src/onecli-approvals.ts` | OneCLI credentialed-action approval bridge |
|
||||
| `src/user-dm.ts` | Cold-DM resolution + `user_dms` cache |
|
||||
| `src/group-init.ts` | Per-agent-group filesystem scaffold (CLAUDE.md, skills, agent-runner-src overlay) |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/db/container-configs.ts` | CRUD for `container_configs` table (per-group container runtime config) |
|
||||
| `src/backfill-container-configs.ts` | Migrates legacy `container.json` files into the DB on startup |
|
||||
| `src/container-restart.ts` | Kill + on-wake respawn for agent group containers |
|
||||
| `src/db/` | DB layer — agent_groups, messaging_groups, sessions, container_configs, user_roles, user_dms, pending_*, migrations |
|
||||
| `src/channels/` | Channel adapter infra (registry, Chat SDK bridge); specific channel adapters are skill-installed from the `channels` branch |
|
||||
| `src/providers/` | Host-side provider container-config (`claude` baked in; `opencode` etc. installed from the `providers` branch) |
|
||||
| `container/agent-runner/src/` | Agent-runner: poll loop, formatter, provider abstraction, MCP tools, destinations |
|
||||
| `container/skills/` | Container skills mounted into every agent session |
|
||||
| `container/skills/` | Container skills mounted into every agent session (`onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`) |
|
||||
| `groups/<folder>/` | Per-agent-group filesystem (CLAUDE.md, skills, per-group `agent-runner-src/` overlay) |
|
||||
| `scripts/init-first-agent.ts` | Bootstrap the first DM-wired agent (used by `/init-first-agent` skill) |
|
||||
| `migrate-v2.sh` + `setup/migrate-v2/` | v1→v2 migration. Standalone script: `bash migrate-v2.sh`. Seeds DB, copies groups/sessions, installs channels, builds container, offers service switchover, then hands off to `/migrate-from-v1` skill for owner setup and CLAUDE.md cleanup. See [docs/migration-dev.md](docs/migration-dev.md). |
|
||||
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
`ncl` queries and modifies the central DB — agent groups, messaging groups, wirings, users, roles, and more. On the host it connects via Unix socket (`src/cli/socket-server.ts`); inside containers it uses the session DB transport (`container/agent-runner/src/cli/ncl.ts`).
|
||||
|
||||
```
|
||||
ncl <resource> <verb> [<id>] [--flags]
|
||||
ncl <resource> help
|
||||
ncl help
|
||||
```
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| messaging-groups | list, get, create, update, delete | A single chat/channel on one platform |
|
||||
| wirings | list, get, create, update, delete | Links a messaging group to an agent group (session mode, triggers) |
|
||||
| users | list, get, create, update | Platform identities (`<channel>:<handle>`) |
|
||||
| roles | list, grant, revoke | Owner / admin privileges (global or scoped to an agent group) |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| user-dms | list | Cold-DM cache (read-only) |
|
||||
| dropped-messages | list | Messages from unregistered senders (read-only) |
|
||||
| approvals | list, get | Pending approval requests (read-only) |
|
||||
|
||||
Key files: `src/cli/dispatch.ts` (dispatcher + approval handler), `src/cli/crud.ts` (generic CRUD registration), `src/cli/resources/` (per-resource definitions).
|
||||
|
||||
## Channels and Providers (skill-installed)
|
||||
|
||||
Trunk does not ship any specific channel adapter or non-default agent provider. The codebase is the registry/infra; the actual adapters and providers live on long-lived sibling branches and get copied in by skills:
|
||||
@@ -94,13 +123,35 @@ Each `/add-<name>` skill is idempotent: `git fetch origin <branch>` → copy mod
|
||||
|
||||
One tier of agent self-modification today:
|
||||
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config only (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only) and restarts the container. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
1. **`install_packages` / `add_mcp_server`** — changes to the per-agent-group container config in the DB (apt/npm deps, wire an existing MCP server). Single admin approval per request; on approve, the handler in `src/modules/self-mod/apply.ts` rebuilds the image when needed (`install_packages` only), writes an `on_wake` message, kills the container, and respawns via `onExit` callback. The on-wake message is only picked up by the fresh container's first poll — dying containers can never steal it. `container/agent-runner/src/mcp-tools/self-mod.ts`.
|
||||
|
||||
A second tier (direct source-level self-edits via a draft/activate flow) is planned but not yet implemented.
|
||||
|
||||
## Container Config
|
||||
|
||||
Per-agent-group container runtime config (provider, model, packages, MCP servers, mounts, etc.) lives in the `container_configs` table in the central DB. Materialized to `groups/<folder>/container.json` at spawn time so the container runner can read it. Managed via `ncl groups config get/update` and the self-mod MCP tools.
|
||||
|
||||
**`cli_scope`** — controls what the agent can do with `ncl` from inside the container:
|
||||
|
||||
| Value | Behavior |
|
||||
|-------|----------|
|
||||
| `disabled` | Agent never learns about ncl (instructions excluded from CLAUDE.md). Host dispatch rejects any `cli_request`. |
|
||||
| `group` (default) | Agent can access `groups`, `sessions`, `destinations`, `members` only, scoped to its own agent group. `--id` and group args are auto-filled. Cross-group access rejected. `cli_scope` changes blocked. |
|
||||
| `global` | Unrestricted. Set automatically for owner agent groups via `init-first-agent`. |
|
||||
|
||||
Key files: `src/db/container-configs.ts`, `src/container-config.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts` (instructions exclusion).
|
||||
|
||||
## Container Restart
|
||||
|
||||
`ncl groups restart --id <group-id> [--rebuild] [--message <text>]`. Kills running containers; if `--message` is provided, writes an `on_wake` message and respawns via `onExit` callback. Without `--message`, containers come back on the next user message. From inside a container, `--id` is auto-filled and only the calling session is restarted.
|
||||
|
||||
The `on_wake` column on `messages_in` ensures wake messages are only picked up by a fresh container's first poll iteration. This prevents the race where a dying container (still in its SIGTERM grace period) could steal the message. `killContainer` accepts an optional `onExit` callback that fires after the process exits, guaranteeing the old container is gone before the new one spawns.
|
||||
|
||||
Key files: `src/container-restart.ts`, `src/container-runner.ts` (`killContainer`), `container/agent-runner/src/db/messages-in.ts` (`getPendingMessages`).
|
||||
|
||||
## Secrets / Credentials / OneCLI
|
||||
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
API keys, OAuth tokens, and auth credentials are managed by the OneCLI gateway. Secrets are injected into per-agent containers at request time — none are passed in env vars or through chat context. The container agent sees this via the `onecli-gateway` container skill (`container/skills/onecli-gateway/SKILL.md`), which teaches it how the proxy works, how to handle auth errors, and to never ask for raw credentials. Host-side wiring: `src/onecli-approvals.ts`, `ensureAgent()` in `container-runner.ts`. Run `onecli --help`.
|
||||
|
||||
### Gotcha: auto-created agents start in `selective` secret mode
|
||||
|
||||
@@ -144,7 +195,7 @@ Four types of skills. See [CONTRIBUTING.md](CONTRIBUTING.md) for the full taxono
|
||||
- **Channel/provider install skills** — copy the relevant module(s) in from the `channels` or `providers` branch, wire imports, install pinned deps (e.g. `/add-discord`, `/add-slack`, `/add-whatsapp`, `/add-opencode`).
|
||||
- **Utility skills** — ship code files alongside `SKILL.md` (e.g. `/claw`).
|
||||
- **Operational skills** — instruction-only workflows (`/setup`, `/debug`, `/customize`, `/init-first-agent`, `/manage-channels`, `/init-onecli`, `/update-nanoclaw`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
- **Container skills** — loaded inside agent containers at runtime (`container/skills/`: `onecli-gateway`, `welcome`, `self-customize`, `agent-browser`, `slack-formatting`).
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
|
||||
+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.
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# ncl — NanoClaw CLI launcher.
|
||||
#
|
||||
# Resolves the project root from this script's location, cd's there so the
|
||||
# host-resolved DATA_DIR matches the running host, and execs the TS entry
|
||||
# via tsx. Symlink this file into a directory on your PATH (or alias `ncl`
|
||||
# to its full path) to invoke from anywhere:
|
||||
#
|
||||
# ln -s "$(pwd)/bin/ncl" /usr/local/bin/ncl
|
||||
# # or
|
||||
# alias ncl="$(pwd)/bin/ncl"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT="${BASH_SOURCE[0]}"
|
||||
# Resolve symlinks so PROJECT_ROOT points at the real checkout.
|
||||
while [ -h "$SCRIPT" ]; do
|
||||
DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
SCRIPT="$(readlink "$SCRIPT")"
|
||||
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
|
||||
done
|
||||
SCRIPT_DIR="$(cd -P "$(dirname "$SCRIPT")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
exec pnpm exec tsx src/cli/client.ts "$@"
|
||||
+13
-2
@@ -19,7 +19,7 @@ ARG INSTALL_CJK_FONTS=false
|
||||
# Pin CLI versions for reproducibility. Bump deliberately — unpinned installs
|
||||
# mean every rebuild silently picks up the latest and can break in lockstep
|
||||
# across all users.
|
||||
ARG CLAUDE_CODE_VERSION=2.1.116
|
||||
ARG CLAUDE_CODE_VERSION=2.1.154
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG VERCEL_VERSION=52.2.1
|
||||
ARG BUN_VERSION=1.3.12
|
||||
@@ -91,7 +91,13 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \
|
||||
# the SDK fails at spawn time with "native binary not found".
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
RUN corepack enable
|
||||
# Pin pnpm to match the host (package.json packageManager). pnpm 11 stopped
|
||||
# honoring `only-built-dependencies[]=` in .npmrc for global installs, which
|
||||
# silently skips claude-code's native-binary postinstall and agent-browser's
|
||||
# bin chmod — the agent then crashes at runtime with "native binary not
|
||||
# installed". Keep this in lockstep with package.json's `packageManager`.
|
||||
ARG PNPM_VERSION=10.33.0
|
||||
RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
echo "only-built-dependencies[]=agent-browser" > /root/.npmrc && \
|
||||
@@ -104,6 +110,11 @@ RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
RUN --mount=type=cache,target=/root/.cache/pnpm \
|
||||
pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}"
|
||||
|
||||
# ---- ncl CLI wrapper ----------------------------------------------------------
|
||||
# Actual script lives in the mounted source at /app/src/cli/ncl.ts.
|
||||
RUN printf '#!/bin/sh\nexec bun /app/src/cli/ncl.ts "$@"\n' > /usr/local/bin/ncl && \
|
||||
chmod +x /usr/local/bin/ncl
|
||||
|
||||
# ---- Entrypoint --------------------------------------------------------------
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
"": {
|
||||
"name": "nanoclaw-agent-runner",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.116",
|
||||
"@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.116", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.116", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.116", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.116" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-5NKpgaOZkzNCGCvLxJZUVGimf5IcYmpQ2x2XrR9ilK+2UkWrnnwcUfIWo8bBz9e7lSYcUf9XleGigq2eOOF7aw=="],
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.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.116", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mG19ovtXCpETmd5KmTU1JO2iIHZBG09IP8DmgZjLA3wLmTzpgn9Au9veRaeJeXb1EqiHiFZU+z+mNB79+w5v9g=="],
|
||||
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.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.116", "", { "os": "darwin", "cpu": "x64" }, "sha512-qC25N0HRM8IXbM4Qi4svH9f51Y6DciDvjLV+oNYnxkdPgDG8p/+b7vQirN7qPxytIQb2TPdoFgUeCsSe7lrQyw=="],
|
||||
"@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.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-MQIcJhhPM+RPJ7kMQdOQarkJ2FlJqOiu953c08YyJOoWdHykd3DIiHws3mf1Mwl/dfFeIyshOVpNND3hyIy5Dg=="],
|
||||
"@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.116", "", { "os": "linux", "cpu": "arm64" }, "sha512-Dg/T3NkSp35ODiwdhj0KquvC6Xu+DMbyWFNkfepA3bz4oF2SVSgyOPYwVmfoJerzEUnYDldP4YhOxRrhbt0vXA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.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.116", "", { "os": "linux", "cpu": "x64" }, "sha512-Bww1fzQB+vcF0tRhmCAlwSsN4wR2HgX7pBT9AWuwzJj6DKsVC23N54Ea80lsnM7dTUtUTrGYMTwVUHTWqfYnfQ=="],
|
||||
"@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.116", "", { "os": "linux", "cpu": "x64" }, "sha512-LMYxUMa1nK4N9BPRJdcGBAvl9rjTI4ZHo+kfAKrJ3MlfB6VFF1tRIubwsWOaOtkuNazMdAYovsZJg4bdzOBBTQ=="],
|
||||
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.154", "", { "os": "linux", "cpu": "x64" }, "sha512-zA7S8Lm6O4QBsUpbhiOht8BgiXHOBBFUIo8ZLK6r5wAatK3Q44syWVxICeyCnR6wqfnkf3cugCw27ycS6vVgaA=="],
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.116", "", { "os": "win32", "cpu": "arm64" }, "sha512-h0YO1vkTIeUtffQhONrYbNC1pXmk1yjb1xxMEw7bAwucqtFoFpLDWe+q4+RhxaQr8ZOj6LtRE/U3dzPWHOlshA=="],
|
||||
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.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.116", "", { "os": "win32", "cpu": "x64" }, "sha512-3lllmtDFHgpW0ZM3iNvxsEjblrgRzF9Qm1lxTOtunP3hIn+pA/IkWMtKlN1ixxWiaBguLVQkJ90V6JHsvJJIvw=="],
|
||||
"@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.116",
|
||||
"@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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* ncl — NanoClaw CLI client (container edition).
|
||||
*
|
||||
* Same interface as the host-side `bin/ncl`. Detects that it's inside a
|
||||
* container (the session DBs exist at /workspace/) and uses a DB transport
|
||||
* instead of the Unix socket transport.
|
||||
*
|
||||
* Writes a cli_request system message to outbound.db, polls inbound.db
|
||||
* for the response. Self-contained — no imports from agent-runner.
|
||||
*/
|
||||
import { Database } from 'bun:sqlite';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frame types (mirrors src/cli/frame.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RequestFrame = {
|
||||
id: string;
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ResponseFrame =
|
||||
| { id: string; ok: true; data: unknown }
|
||||
| { id: string; ok: false; error: { code: string; message: string } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INBOUND_DB = '/workspace/inbound.db';
|
||||
const OUTBOUND_DB = '/workspace/outbound.db';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DB transport
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateId(): string {
|
||||
return `cli-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a cli_request to outbound.db.
|
||||
*
|
||||
* Uses BEGIN IMMEDIATE to acquire a write lock before reading max(seq),
|
||||
* preventing seq collisions with concurrent agent-runner writes.
|
||||
*/
|
||||
function writeRequest(req: RequestFrame): void {
|
||||
const db = new Database(OUTBOUND_DB);
|
||||
db.exec('PRAGMA journal_mode = DELETE');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
try {
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
const maxOut = (db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get() as { m: number }).m;
|
||||
const maxIn = (inDb.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get() as { m: number }).m;
|
||||
const max = Math.max(maxOut, maxIn);
|
||||
const nextSeq = max % 2 === 0 ? max + 1 : max + 2;
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO messages_out (id, seq, timestamp, kind, content)
|
||||
VALUES ($id, $seq, datetime('now'), 'system', $content)`,
|
||||
).run({
|
||||
$id: req.id,
|
||||
$seq: nextSeq,
|
||||
$content: JSON.stringify({
|
||||
action: 'cli_request',
|
||||
requestId: req.id,
|
||||
command: req.command,
|
||||
args: req.args,
|
||||
}),
|
||||
});
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
inDb.close();
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll inbound.db for a cli_response matching our requestId.
|
||||
* Opens a fresh connection each poll (mmap_size=0) for cross-mount visibility.
|
||||
*/
|
||||
function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | null {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const inDb = new Database(INBOUND_DB, { readonly: true });
|
||||
inDb.exec('PRAGMA busy_timeout = 5000');
|
||||
inDb.exec('PRAGMA mmap_size = 0');
|
||||
|
||||
try {
|
||||
const row = inDb
|
||||
.prepare("SELECT id, content FROM messages_in WHERE status = 'pending' AND content LIKE ?")
|
||||
.get(`%"requestId":"${requestId}"%`) as { id: string; content: string } | null;
|
||||
|
||||
if (row) {
|
||||
// Mark as completed via processing_ack so agent-runner skips it
|
||||
const outDb = new Database(OUTBOUND_DB);
|
||||
outDb.exec('PRAGMA journal_mode = DELETE');
|
||||
outDb.exec('PRAGMA busy_timeout = 5000');
|
||||
outDb
|
||||
.prepare(
|
||||
"INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))",
|
||||
)
|
||||
.run(row.id);
|
||||
outDb.close();
|
||||
|
||||
const parsed = JSON.parse(row.content);
|
||||
return parsed.frame as ResponseFrame;
|
||||
}
|
||||
} finally {
|
||||
inDb.close();
|
||||
}
|
||||
|
||||
Bun.sleepSync(500);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Arg parsing (mirrors host-side client.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgv(argv: string[]): {
|
||||
command: string;
|
||||
args: Record<string, unknown>;
|
||||
json: boolean;
|
||||
} {
|
||||
const positional: string[] = [];
|
||||
const args: Record<string, unknown> = {};
|
||||
let json = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--json') {
|
||||
json = true;
|
||||
continue;
|
||||
}
|
||||
if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) {
|
||||
args[key] = true;
|
||||
} else {
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
positional.push(a);
|
||||
}
|
||||
|
||||
if (positional.length === 0) {
|
||||
process.stderr.write('ncl: missing command\n');
|
||||
printUsage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Join all positionals with dashes. The dispatcher trims the last
|
||||
// segment as a target ID if the full name isn't a registered command.
|
||||
const command = positional.join('-');
|
||||
|
||||
return { command, args, json };
|
||||
}
|
||||
|
||||
function printUsage(): void {
|
||||
process.stdout.write(
|
||||
['Usage: ncl <command> [--key value ...] [--json]', '', 'Run `ncl help` to list available commands.', ''].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatting (mirrors src/cli/format.ts on the host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatHuman(resp: ResponseFrame): string {
|
||||
if (!resp.ok) {
|
||||
return `error (${resp.error.code}): ${resp.error.message}\n`;
|
||||
}
|
||||
|
||||
const data = resp.data;
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
return JSON.stringify(data, null, 2) + '\n';
|
||||
}
|
||||
|
||||
const isFlat = data.every(
|
||||
(r) =>
|
||||
typeof r === 'object' &&
|
||||
r !== null &&
|
||||
!Array.isArray(r) &&
|
||||
Object.values(r as Record<string, unknown>).every((v) => typeof v !== 'object' || v === null),
|
||||
);
|
||||
|
||||
if (!isFlat) return JSON.stringify(data, null, 2) + '\n';
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const widths = keys.map((k) =>
|
||||
Math.max(k.length, ...data.map((r) => String((r as Record<string, unknown>)[k] ?? '').length)),
|
||||
);
|
||||
|
||||
const header = keys.map((k, i) => k.padEnd(widths[i])).join(' ');
|
||||
const sep = widths.map((w) => '-'.repeat(w)).join(' ');
|
||||
const rows = data.map((r) =>
|
||||
keys
|
||||
.map((k, i) => String((r as Record<string, unknown>)[k] ?? '').padEnd(widths[i]))
|
||||
.join(' '),
|
||||
);
|
||||
|
||||
return [header, sep, ...rows, ''].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
|
||||
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { command, args, json } = parseArgv(argv);
|
||||
const requestId = generateId();
|
||||
const req: RequestFrame = { id: requestId, command, args };
|
||||
|
||||
writeRequest(req);
|
||||
|
||||
const resp = pollResponse(requestId, 30_000);
|
||||
|
||||
if (!resp) {
|
||||
process.stderr.write('ncl: command timed out after 30s\n');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
if (json) {
|
||||
process.stdout.write(JSON.stringify(resp, null, 2) + '\n');
|
||||
} else {
|
||||
const output = formatHuman(resp);
|
||||
if (!resp.ok) {
|
||||
process.stderr.write(output);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(output);
|
||||
}
|
||||
@@ -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'));
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface RunnerConfig {
|
||||
agentGroupId: string;
|
||||
maxMessagesPerPrompt: number;
|
||||
mcpServers: Record<string, { command: string; args: string[]; env: Record<string, string> }>;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_MESSAGES = 10;
|
||||
@@ -43,6 +45,8 @@ export function loadConfig(): RunnerConfig {
|
||||
agentGroupId: (raw.agentGroupId as string) || '',
|
||||
maxMessagesPerPrompt: (raw.maxMessagesPerPrompt as number) || DEFAULT_MAX_MESSAGES,
|
||||
mcpServers: (raw.mcpServers as RunnerConfig['mcpServers']) || {},
|
||||
model: (raw.model as string) || undefined,
|
||||
effort: (raw.effort as string) || undefined,
|
||||
};
|
||||
|
||||
return _config;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Per-batch context the poll loop publishes for downstream consumers
|
||||
* (MCP tools, etc.) that don't sit on the poll-loop's call stack.
|
||||
*
|
||||
* Today the only field is `inReplyTo` — the id of the first inbound
|
||||
* message in the batch the agent is currently processing. MCP tools like
|
||||
* `send_message` and `send_file` read this and stamp it onto the outbound
|
||||
* row so the host's a2a return-path routing can correlate replies back to
|
||||
* the originating session.
|
||||
*
|
||||
* This is module-level state on purpose: the agent-runner is single-process
|
||||
* and processes one batch at a time. Poll-loop calls `setCurrentInReplyTo`
|
||||
* before invoking the provider and `clearCurrentInReplyTo` after the batch
|
||||
* completes (or errors out).
|
||||
*/
|
||||
let currentInReplyTo: string | null = null;
|
||||
|
||||
export function setCurrentInReplyTo(id: string | null): void {
|
||||
currentInReplyTo = id;
|
||||
}
|
||||
|
||||
export function clearCurrentInReplyTo(): void {
|
||||
currentInReplyTo = null;
|
||||
}
|
||||
|
||||
export function getCurrentInReplyTo(): string | null {
|
||||
return currentInReplyTo;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,8 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } {
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
content TEXT NOT NULL,
|
||||
on_wake INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE delivered (
|
||||
message_out_id TEXT PRIMARY KEY,
|
||||
|
||||
@@ -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;
|
||||
@@ -49,20 +62,22 @@ function getMaxMessagesPerPrompt(): number {
|
||||
* sees the prior context it missed. Host's countDueMessages gates waking on
|
||||
* trigger=1 separately (see src/db/session-db.ts).
|
||||
*/
|
||||
export function getPendingMessages(): MessageInRow[] {
|
||||
export function getPendingMessages(isFirstPoll = false): MessageInRow[] {
|
||||
const inbound = openInboundDb();
|
||||
const outbound = getOutboundDb();
|
||||
|
||||
try {
|
||||
const onWakeFilter = hasOnWakeColumn(inbound) ? 'AND (on_wake = 0 OR ?1 = 1)' : '';
|
||||
const pending = inbound
|
||||
.prepare(
|
||||
`SELECT * FROM messages_in
|
||||
WHERE status = 'pending'
|
||||
AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))
|
||||
${onWakeFilter}
|
||||
ORDER BY seq DESC
|
||||
LIMIT ?`,
|
||||
LIMIT ?2`,
|
||||
)
|
||||
.all(getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
.all(isFirstPoll ? 1 : 0, getMaxMessagesPerPrompt()) as MessageInRow[];
|
||||
|
||||
if (pending.length === 0) return [];
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
import { closeSessionDb, getInboundDb, initTestSessionDb } from './db/connection.js';
|
||||
import { buildSystemPromptAddendum } from './destinations.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function seedDestination(name: string, displayName: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, displayName, channelType, platformId);
|
||||
}
|
||||
|
||||
describe('buildSystemPromptAddendum — multi-destination routing guidance', () => {
|
||||
it('includes default-routing nudge when there are >1 destinations', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
seedDestination('whatsapp-mg-17780', 'whatsapp-mg-17780', 'whatsapp', 'phone-2@s.whatsapp.net');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('from="name"');
|
||||
expect(prompt).toContain('`casa`');
|
||||
expect(prompt).toContain('`whatsapp-mg-17780`');
|
||||
});
|
||||
|
||||
it('describes message wrapping for a single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
|
||||
it('handles the no-destination case without crashing', () => {
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('no configured destinations');
|
||||
expect(prompt).not.toContain('default to addressing');
|
||||
});
|
||||
|
||||
it('includes default-routing and wrapping instructions for single destination', () => {
|
||||
seedDestination('casa', 'Casa', 'whatsapp', 'group-1@g.us');
|
||||
|
||||
const prompt = buildSystemPromptAddendum('Casa');
|
||||
|
||||
expect(prompt).toContain('Wrap each delivered message');
|
||||
expect(prompt).toContain('<message to="name">');
|
||||
expect(prompt).toContain('default to addressing the destination it came `from`');
|
||||
expect(prompt).toContain('`casa`');
|
||||
});
|
||||
});
|
||||
@@ -115,13 +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(
|
||||
'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 {
|
||||
|
||||
@@ -91,6 +91,8 @@ async function main(): Promise<void> {
|
||||
mcpServers,
|
||||
env: { ...process.env },
|
||||
additionalDirectories: additionalDirectories.length > 0 ? additionalDirectories : undefined,
|
||||
model: config.model,
|
||||
effort: config.effort,
|
||||
});
|
||||
|
||||
await runPollLoop({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb, getOutboundDb } from './db/connection.js';
|
||||
import { getUndeliveredMessages } from './db/messages-out.js';
|
||||
import { getPendingMessages } from './db/messages-in.js';
|
||||
import { getContinuation, setContinuation } from './db/session-state.js';
|
||||
import { MockProvider } from './providers/mock.js';
|
||||
import { runPollLoop } from './poll-loop.js';
|
||||
|
||||
@@ -112,6 +113,125 @@ describe('poll loop integration', () => {
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('bare text produces no outbound messages (scratchpad only)', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hello' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
// Agent responds with bare text — no <message to="..."> wrapping
|
||||
const provider = new MockProvider({}, () => 'I am thinking about this...');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
// Wait long enough for the poll loop to process
|
||||
await sleep(1000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('unknown destination is dropped, valid destination is sent', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="nonexistent">dropped</message><message to="discord-test">delivered</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
// Only the valid destination should produce output
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('delivered');
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('multiple <message> blocks each produce an outbound message', async () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-test', 'Slack Test', 'channel', 'slack', 'chan-2', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'broadcast' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<message to="discord-test">for discord</message><message to="slack-test">for slack</message>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length >= 2, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(2);
|
||||
const discord = out.find((m) => m.platform_id === 'chan-1');
|
||||
const slack = out.find((m) => m.platform_id === 'chan-2');
|
||||
expect(discord).toBeDefined();
|
||||
expect(JSON.parse(discord!.content).text).toBe('for discord');
|
||||
expect(slack).toBeDefined();
|
||||
expect(JSON.parse(slack!.content).text).toBe('for slack');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('sends null thread_id when no prior inbound from destination', async () => {
|
||||
// Seed a second destination that has NO inbound messages
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('slack-new', 'Slack New', 'channel', 'slack', 'chan-new', NULL)`,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Only insert a message from discord — slack-new has never sent anything
|
||||
insertMessage('m1', { sender: 'Alice', text: 'tell slack' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'discord-thread' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="slack-new">hello slack</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-new');
|
||||
expect(out[0].thread_id).toBeNull();
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('resolves most recent thread_id when destination has multiple inbound messages', async () => {
|
||||
// Two messages from same destination, different threads
|
||||
insertMessage('m-old', { sender: 'Alice', text: 'old' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-old' });
|
||||
insertMessage('m-new', { sender: 'Alice', text: 'new' }, { platformId: 'chan-1', channelType: 'discord', threadId: 'thread-new' });
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">reply</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].thread_id).toBe('thread-new');
|
||||
expect(out[0].in_reply_to).toBe('m-new');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('should process messages arriving after loop starts', async () => {
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">Processed</message>');
|
||||
const controller = new AbortController();
|
||||
@@ -129,6 +249,52 @@ describe('poll loop integration', () => {
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('internal tags between message blocks are stripped from scratchpad', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'hi' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new MockProvider(
|
||||
{},
|
||||
() => '<internal>thinking about this...</internal><message to="discord-test">answer</message><internal>done thinking</internal>',
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('answer');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
it('handles mixed task + chat batch with correct origin metadata', async () => {
|
||||
// Seed destination for routing lookup
|
||||
insertMessage('m-chat', { sender: 'Alice', text: 'check this' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
// Task with same routing — simulates a scheduled task in a channel session
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('t-task', 'task', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ prompt: 'daily check' }));
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">done</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].platform_id).toBe('chan-1');
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Helper: run poll loop until aborted or timeout
|
||||
@@ -157,3 +323,142 @@ async function waitFor(condition: () => boolean, timeoutMs: number): Promise<voi
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe('poll loop — provider error recovery', () => {
|
||||
it('writes error to outbound and continues loop on provider throw', async () => {
|
||||
insertMessage('m1', { sender: 'Alice', text: 'trigger error' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new ThrowingProvider('API rate limit exceeded');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||
expect(JSON.parse(out[0].content).text).toContain('API rate limit exceeded');
|
||||
|
||||
// Input message should be marked completed despite the error
|
||||
const pending = getPendingMessages();
|
||||
expect(pending).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — stale session recovery', () => {
|
||||
it('clears continuation when provider reports session invalid', async () => {
|
||||
// Pre-seed a continuation so the local variable in runPollLoop is set.
|
||||
// Without this, the `if (continuation && isSessionInvalid)` check skips.
|
||||
setContinuation('mock', 'pre-existing-session');
|
||||
|
||||
insertMessage('m1', { sender: 'Alice', text: 'stale session' }, { platformId: 'chan-1', channelType: 'discord' });
|
||||
|
||||
const provider = new InvalidSessionProvider();
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider as unknown as MockProvider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
// Error was written to outbound
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toContain('Error:');
|
||||
|
||||
// Continuation was cleared (isSessionInvalid returned true)
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
describe('poll loop — /clear command', () => {
|
||||
it('clears session, writes confirmation, skips query', async () => {
|
||||
// Seed a continuation so we can verify it gets cleared
|
||||
setContinuation('mock', 'existing-session-id');
|
||||
expect(getContinuation('mock')).toBe('existing-session-id');
|
||||
|
||||
// Insert a /clear command
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES ('m-clear', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`,
|
||||
)
|
||||
.run(JSON.stringify({ text: '/clear' }));
|
||||
|
||||
const provider = new MockProvider({}, () => '<message to="discord-test">should not run</message>');
|
||||
const controller = new AbortController();
|
||||
const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 2000);
|
||||
|
||||
await waitFor(() => getUndeliveredMessages().length > 0, 2000);
|
||||
controller.abort();
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(JSON.parse(out[0].content).text).toBe('Session cleared.');
|
||||
|
||||
// Continuation was cleared
|
||||
expect(getContinuation('mock')).toBeUndefined();
|
||||
|
||||
// Command message was completed
|
||||
const pending = getPendingMessages();
|
||||
expect(pending).toHaveLength(0);
|
||||
|
||||
await loopPromise.catch(() => {});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider that throws on every query, simulating API failures.
|
||||
*/
|
||||
class ThrowingProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
private errorMessage: string;
|
||||
|
||||
constructor(errorMessage: string) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
const errorMessage = this.errorMessage;
|
||||
return {
|
||||
push() {},
|
||||
end() {},
|
||||
abort() {},
|
||||
events: (async function* () {
|
||||
throw new Error(errorMessage);
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that throws with an error that triggers isSessionInvalid.
|
||||
* First emits an init event (setting continuation), then throws.
|
||||
*/
|
||||
class InvalidSessionProvider {
|
||||
readonly supportsNativeSlashCommands = false;
|
||||
|
||||
isSessionInvalid(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
query(_input: { prompt: string; cwd: string }) {
|
||||
return {
|
||||
push() {},
|
||||
end() {},
|
||||
abort() {},
|
||||
events: (async function* () {
|
||||
yield { type: 'init' as const, continuation: 'doomed-session' };
|
||||
throw new Error('session not found');
|
||||
})(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
## Admin CLI (`ncl`)
|
||||
|
||||
The `ncl` command is available at `/usr/local/bin/ncl`. It lets you query and modify NanoClaw's central configuration.
|
||||
|
||||
### Usage
|
||||
|
||||
```
|
||||
ncl <resource> <verb> [--flags]
|
||||
ncl <resource> help
|
||||
ncl help
|
||||
```
|
||||
|
||||
### Scope
|
||||
|
||||
Your CLI access may be scoped. Run `ncl help` to see which resources are available and whether args are auto-filled. Under `group` scope (the default), `--id` and group-related args are auto-filled to your agent group — you don't need to pass them.
|
||||
|
||||
### Resources
|
||||
|
||||
Run `ncl help` for the full list. Common resources:
|
||||
|
||||
| Resource | Verbs | What it is |
|
||||
|----------|-------|------------|
|
||||
| groups | list, get, create, update, delete, restart, config get/update, config add-mcp-server/remove-mcp-server, config add-package/remove-package | Agent groups (workspace, personality, container config) |
|
||||
| sessions | list, get | Active sessions (read-only) |
|
||||
| destinations | list, add, remove | Where an agent group can send messages |
|
||||
| members | list, add, remove | Unprivileged access gate for an agent group |
|
||||
|
||||
Additional resources (available under `global` scope only): messaging-groups, wirings, users, roles, user-dms, dropped-messages, approvals.
|
||||
|
||||
### When to use
|
||||
|
||||
- **Looking up your own config** — `ncl groups get` or `ncl groups config get` to see your container config.
|
||||
- **Restarting your container** — `ncl groups restart` (with optional `--rebuild` and `--message`).
|
||||
- **Checking who's in your group** — `ncl members list`.
|
||||
- **Seeing your destinations** — `ncl destinations list`.
|
||||
- **Answering questions about the system** — query `ncl` rather than guessing.
|
||||
|
||||
### Access rules
|
||||
|
||||
Read commands (list, get) are open. Write commands (create, update, delete, restart, config update, add, remove) require admin approval — the request is held until an admin approves it.
|
||||
|
||||
### Approval flow
|
||||
|
||||
Write commands require admin approval. Here's what happens:
|
||||
|
||||
1. You run the command (e.g. `ncl groups config update --model claude-sonnet-4-5-20250514`).
|
||||
2. The command returns immediately with an `approval-pending` response — it has **not** been executed yet.
|
||||
3. An admin or owner gets a notification showing exactly what you requested, with approve/reject options.
|
||||
4. Once the admin responds:
|
||||
- **Approved:** the command executes and the result is delivered back to you as a system message in this conversation.
|
||||
- **Rejected:** you get a system message saying the request was rejected.
|
||||
|
||||
You don't need to poll or retry — the result arrives automatically.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Read commands (no approval needed)
|
||||
ncl groups get
|
||||
ncl groups config get
|
||||
ncl sessions list
|
||||
ncl destinations list
|
||||
ncl members list
|
||||
|
||||
# Write commands (approval required)
|
||||
ncl groups restart
|
||||
ncl groups restart --rebuild --message "Config updated."
|
||||
ncl groups config update --model claude-sonnet-4-5-20250514
|
||||
ncl groups config add-mcp-server --name rss --command npx --args '["some-rss-mcp"]'
|
||||
ncl groups config add-package --npm some-package
|
||||
ncl members add --user telegram:jane
|
||||
```
|
||||
|
||||
### Important
|
||||
|
||||
Config changes via `ncl groups config update` do not take effect until `ncl groups restart`. Run `ncl groups config help` for details.
|
||||
|
||||
### Tips
|
||||
|
||||
- Use `ncl <resource> help` to see all available fields, types, enums, and which fields are auto-filled.
|
||||
- Flags use `--hyphen-case` (e.g. `--agent-group-id`), mapped to `underscore_case` DB columns automatically.
|
||||
- `list` supports filtering by any non-auto column. Default limit is 200 rows; override with `--limit N`.
|
||||
- Write commands return `approval-pending` immediately — don't treat this as an error. Wait for the system message with the result.
|
||||
@@ -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`)
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Tests for the core MCP tools' interaction with the per-batch routing
|
||||
* context. The agent-runner sets a current `inReplyTo` at the top of each
|
||||
* batch in poll-loop, and outbound writes from MCP tools (send_message,
|
||||
* send_file) must pick it up so a2a return-path routing on the host can
|
||||
* correlate replies back to the originating session.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||
|
||||
import { initTestSessionDb, closeSessionDb, getInboundDb } from '../db/connection.js';
|
||||
import { getUndeliveredMessages } from '../db/messages-out.js';
|
||||
import { setCurrentInReplyTo, clearCurrentInReplyTo } from '../current-batch.js';
|
||||
import { sendMessage } from './core.js';
|
||||
|
||||
beforeEach(() => {
|
||||
initTestSessionDb();
|
||||
// Seed a peer agent destination
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES ('peer', 'Peer', 'agent', NULL, NULL, 'ag-peer')`,
|
||||
)
|
||||
.run();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearCurrentInReplyTo();
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
describe('send_message MCP tool — in_reply_to plumbing', () => {
|
||||
it('stamps current batch in_reply_to on outbound rows', async () => {
|
||||
setCurrentInReplyTo('inbound-msg-1');
|
||||
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBe('inbound-msg-1');
|
||||
});
|
||||
|
||||
it('writes null when no batch is active', async () => {
|
||||
// No setCurrentInReplyTo before this call — simulates ad-hoc / out-of-batch invocation.
|
||||
await sendMessage.handler({ to: 'peer', text: 'hello' });
|
||||
|
||||
const out = getUndeliveredMessages();
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].in_reply_to).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { getCurrentInReplyTo } from '../current-batch.js';
|
||||
import { findByName, getAllDestinations } from '../destinations.js';
|
||||
import { getMessageIdBySeq, getRoutingBySeq, writeMessageOut } from '../db/messages-out.js';
|
||||
import { getSessionRouting } from '../db/session-routing.js';
|
||||
@@ -50,9 +51,7 @@ function destinationList(): string {
|
||||
*/
|
||||
function resolveRouting(
|
||||
to: string | undefined,
|
||||
):
|
||||
| { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string }
|
||||
| { error: string } {
|
||||
): { channel_type: string; platform_id: string; thread_id: string | null; resolvedName: string } | { error: string } {
|
||||
if (!to) {
|
||||
// Default: reply to whatever thread/channel this session is bound to.
|
||||
const session = getSessionRouting();
|
||||
@@ -82,9 +81,7 @@ function resolveRouting(
|
||||
// preserve the thread_id so replies land in the correct thread.
|
||||
const session = getSessionRouting();
|
||||
const threadId =
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId
|
||||
? session.thread_id
|
||||
: null;
|
||||
session.channel_type === dest.channelType && session.platform_id === dest.platformId ? session.thread_id : null;
|
||||
return {
|
||||
channel_type: dest.channelType!,
|
||||
platform_id: dest.platformId!,
|
||||
@@ -98,12 +95,14 @@ function resolveRouting(
|
||||
export const sendMessage: McpToolDefinition = {
|
||||
tool: {
|
||||
name: 'send_message',
|
||||
description:
|
||||
'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
description: 'Send a message to a named destination. If you have only one destination, you can omit `to`.',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
to: { type: 'string', description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.' },
|
||||
to: {
|
||||
type: 'string',
|
||||
description: 'Destination name (e.g., "family", "worker-1"). Optional if you have only one destination.',
|
||||
},
|
||||
text: { type: 'string', description: 'Message content' },
|
||||
},
|
||||
required: ['text'],
|
||||
@@ -119,6 +118,7 @@ export const sendMessage: McpToolDefinition = {
|
||||
const id = generateId();
|
||||
const seq = writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
@@ -165,6 +165,7 @@ export const sendFile: McpToolDefinition = {
|
||||
|
||||
writeMessageOut({
|
||||
id,
|
||||
in_reply_to: getCurrentInReplyTo(),
|
||||
kind: 'chat',
|
||||
platform_id: routing.platform_id,
|
||||
channel_type: routing.channel_type,
|
||||
|
||||
@@ -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(() => {
|
||||
@@ -14,13 +15,18 @@ afterEach(() => {
|
||||
closeSessionDb();
|
||||
});
|
||||
|
||||
function insertMessage(id: string, kind: string, content: object, opts?: { processAfter?: string; trigger?: 0 | 1 }) {
|
||||
function insertMessage(
|
||||
id: string,
|
||||
kind: string,
|
||||
content: object,
|
||||
opts?: { processAfter?: string; trigger?: 0 | 1; onWake?: 0 | 1 },
|
||||
) {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, process_after, trigger, on_wake, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, JSON.stringify(content));
|
||||
.run(id, kind, opts?.processAfter ?? null, opts?.trigger ?? 1, opts?.onWake ?? 0, JSON.stringify(content));
|
||||
}
|
||||
|
||||
describe('formatter', () => {
|
||||
@@ -32,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"');
|
||||
});
|
||||
@@ -131,6 +139,58 @@ describe('accumulate gate (trigger column)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('on_wake filtering', () => {
|
||||
it('first poll returns on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('subsequent polls skip on_wake=1 messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normal messages returned regardless of isFirstPoll', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'hello' });
|
||||
expect(getPendingMessages(true)).toHaveLength(1);
|
||||
|
||||
// Reset: mark completed so we can re-test with a fresh message
|
||||
markCompleted(['m1']);
|
||||
insertMessage('m2', 'chat', { sender: 'A', text: 'hello again' });
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('mixed batch: first poll returns both normal and on_wake messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(true);
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages.map((m) => m.id).sort()).toEqual(['m1', 'm2']);
|
||||
});
|
||||
|
||||
it('mixed batch: subsequent poll returns only normal messages', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'A', text: 'user msg' });
|
||||
insertMessage('m2', 'chat', { sender: 'system', text: 'Resuming.' }, { onWake: 1 });
|
||||
const messages = getPendingMessages(false);
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].id).toBe('m1');
|
||||
});
|
||||
|
||||
it('on_wake defaults to 0 for inserts without explicit value', () => {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, content)
|
||||
VALUES ('m1', 'chat', datetime('now'), 'pending', '{"text":"hi"}')`,
|
||||
)
|
||||
.run();
|
||||
// Should be returned even on non-first poll (on_wake=0)
|
||||
expect(getPendingMessages(false)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('routing', () => {
|
||||
it('should extract routing from messages', () => {
|
||||
getInboundDb()
|
||||
@@ -149,6 +209,76 @@ describe('routing', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('origin metadata (from= attribute)', () => {
|
||||
function seedDestination(name: string, channelType: string, platformId: string): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id)
|
||||
VALUES (?, ?, 'channel', ?, ?, NULL)`,
|
||||
)
|
||||
.run(name, name, channelType, platformId);
|
||||
}
|
||||
|
||||
function insertWithRouting(id: string, kind: string, content: object, channelType: string | null, platformId: string | null): void {
|
||||
getInboundDb()
|
||||
.prepare(
|
||||
`INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content)
|
||||
VALUES (?, ?, datetime('now'), 'pending', ?, ?, ?)`,
|
||||
)
|
||||
.run(id, kind, platformId, channelType, JSON.stringify(content));
|
||||
}
|
||||
|
||||
it('chat message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
|
||||
it('chat message falls back to raw routing when no destination matches', () => {
|
||||
insertWithRouting('m1', 'chat', { sender: 'Alice', text: 'hi' }, 'telegram', 'chat-999');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('from="unknown:telegram:chat-999"');
|
||||
});
|
||||
|
||||
it('chat message omits from= when routing is null', () => {
|
||||
insertMessage('m1', 'chat', { sender: 'Alice', text: 'hi' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('task message includes from= when destination matches', () => {
|
||||
seedDestination('slack-ops', 'slack', 'C-OPS');
|
||||
insertWithRouting('t1', 'task', { prompt: 'check status' }, 'slack', 'C-OPS');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).toContain('from="slack-ops"');
|
||||
});
|
||||
|
||||
it('task message omits from= when routing is null', () => {
|
||||
insertMessage('t1', 'task', { prompt: 'check status' });
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<task');
|
||||
expect(prompt).not.toContain('from=');
|
||||
});
|
||||
|
||||
it('webhook message includes from= when destination matches', () => {
|
||||
seedDestination('github-ch', 'github', 'repo-1');
|
||||
insertWithRouting('w1', 'webhook', { source: 'github', event: 'push', payload: {} }, 'github', 'repo-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<webhook');
|
||||
expect(prompt).toContain('from="github-ch"');
|
||||
});
|
||||
|
||||
it('system message includes from= when destination matches', () => {
|
||||
seedDestination('discord-main', 'discord', 'chan-1');
|
||||
insertWithRouting('s1', 'system', { action: 'test', status: 'ok', result: null }, 'discord', 'chan-1');
|
||||
const prompt = formatMessages(getPendingMessages());
|
||||
expect(prompt).toContain('<system_response');
|
||||
expect(prompt).toContain('from="discord-main"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mock provider', () => {
|
||||
it('should produce init + result events', async () => {
|
||||
const provider = new MockProvider({}, (prompt) => `Echo: ${prompt}`);
|
||||
@@ -248,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
import { findByName, type DestinationEntry } from './destinations.js';
|
||||
import { findByName, getAllDestinations, type DestinationEntry } from './destinations.js';
|
||||
import { getPendingMessages, markProcessing, markCompleted, type MessageInRow } from './db/messages-in.js';
|
||||
import { writeMessageOut } from './db/messages-out.js';
|
||||
import { getInboundDb, touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
|
||||
import { clearContinuation, migrateLegacyContinuation, setContinuation } from './db/session-state.js';
|
||||
import { clearCurrentInReplyTo, setCurrentInReplyTo } from './current-batch.js';
|
||||
import {
|
||||
clearContinuation,
|
||||
migrateLegacyContinuation,
|
||||
setContinuation,
|
||||
} from './db/session-state.js';
|
||||
import { formatMessages, extractRouting, categorizeMessage, isClearCommand, isRunnerCommand, stripInternalTags, type RoutingContext } from './formatter.js';
|
||||
formatMessages,
|
||||
extractRouting,
|
||||
categorizeMessage,
|
||||
isClearCommand,
|
||||
isRunnerCommand,
|
||||
stripInternalTags,
|
||||
type RoutingContext,
|
||||
} from './formatter.js';
|
||||
import { 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}`);
|
||||
}
|
||||
@@ -53,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}`);
|
||||
}
|
||||
@@ -62,9 +105,11 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
clearStaleProcessingAcks();
|
||||
|
||||
let pollCount = 0;
|
||||
let isFirstPoll = true;
|
||||
while (true) {
|
||||
// Skip system messages — they're responses for MCP tools (e.g., ask_user_question)
|
||||
const messages = getPendingMessages().filter((m) => m.kind !== 'system');
|
||||
const messages = getPendingMessages(isFirstPoll).filter((m) => m.kind !== 'system');
|
||||
isFirstPoll = false;
|
||||
pollCount++;
|
||||
|
||||
// Periodic heartbeat so we know the loop is alive
|
||||
@@ -117,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);
|
||||
}
|
||||
|
||||
@@ -170,6 +228,9 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
// Process the query while concurrently polling for new messages
|
||||
const skippedSet = new Set(skipped);
|
||||
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
|
||||
// Publish the batch's in_reply_to so MCP tools (send_message, send_file)
|
||||
// can stamp it on outbound rows — needed for a2a return-path routing.
|
||||
setCurrentInReplyTo(routing.inReplyTo);
|
||||
try {
|
||||
const result = await processQuery(query, routing, processingIds, config.providerName);
|
||||
if (result.continuation && result.continuation !== continuation) {
|
||||
@@ -198,6 +259,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
|
||||
thread_id: routing.threadId,
|
||||
content: JSON.stringify({ text: `Error: ${errMsg}` }),
|
||||
});
|
||||
} finally {
|
||||
clearCurrentInReplyTo();
|
||||
}
|
||||
|
||||
// Ensure completed even if processQuery ended without a result event
|
||||
@@ -253,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
|
||||
@@ -265,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;
|
||||
@@ -326,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) {
|
||||
@@ -335,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;
|
||||
}
|
||||
@@ -364,7 +455,18 @@ async function processQuery(
|
||||
// at all — either way the turn is finished.
|
||||
markCompleted(initialBatchIds);
|
||||
if (event.text) {
|
||||
dispatchResultText(event.text, routing);
|
||||
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>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -385,7 +487,9 @@ function handleEvent(event: ProviderEvent, _routing: RoutingContext): void {
|
||||
log(`Result: ${event.text ? event.text.slice(0, 200) : '(empty)'}`);
|
||||
break;
|
||||
case 'error':
|
||||
log(`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`);
|
||||
log(
|
||||
`Error: ${event.message} (retryable: ${event.retryable}${event.classification ? `, ${event.classification}` : ''})`,
|
||||
);
|
||||
break;
|
||||
case 'progress':
|
||||
log(`Progress: ${event.message}`);
|
||||
@@ -401,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;
|
||||
@@ -436,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 ──
|
||||
|
||||
/**
|
||||
@@ -257,11 +335,15 @@ export class ClaudeProvider implements AgentProvider {
|
||||
private mcpServers: Record<string, McpServerConfig>;
|
||||
private env: Record<string, string | undefined>;
|
||||
private additionalDirectories?: string[];
|
||||
private model?: string;
|
||||
private effort?: string;
|
||||
|
||||
constructor(options: ProviderOptions = {}) {
|
||||
this.assistantName = options.assistantName;
|
||||
this.mcpServers = options.mcpServers ?? {};
|
||||
this.additionalDirectories = options.additionalDirectories;
|
||||
this.model = options.model;
|
||||
this.effort = options.effort;
|
||||
this.env = {
|
||||
...(options.env ?? {}),
|
||||
CLAUDE_CODE_AUTO_COMPACT_WINDOW,
|
||||
@@ -273,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);
|
||||
@@ -293,9 +410,12 @@ export class ClaudeProvider implements AgentProvider {
|
||||
],
|
||||
disallowedTools: SDK_DISALLOWED_TOOLS,
|
||||
env: this.env,
|
||||
model: this.model,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
effort: this.effort as any,
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
settingSources: ['project', 'user'],
|
||||
settingSources: ['project', 'user', 'local'],
|
||||
mcpServers: this.mcpServers,
|
||||
hooks: {
|
||||
PreToolUse: [{ hooks: [preToolUseHook] }],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,6 +40,16 @@ export interface ProviderOptions {
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
env?: Record<string, string | undefined>;
|
||||
additionalDirectories?: string[];
|
||||
/**
|
||||
* Model alias (`sonnet`, `opus`, `haiku`) or full model ID. Passed through
|
||||
* to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
model?: string;
|
||||
/**
|
||||
* Reasoning effort (`'low' | 'medium' | 'high' | 'xhigh' | 'max'`). Passed
|
||||
* through to the underlying SDK. If omitted, the SDK default is used.
|
||||
*/
|
||||
effort?: string;
|
||||
}
|
||||
|
||||
export interface QueryInput {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
name: onecli-gateway
|
||||
description: >-
|
||||
OneCLI Gateway: transparent HTTPS proxy that injects stored credentials
|
||||
into outbound calls. You MUST use this skill when the user asks you to
|
||||
read emails, check calendar, access GitHub repos, create issues, check
|
||||
Stripe payments, or interact with ANY external service or API. Do NOT
|
||||
use browser extensions or OAuth CLI tools. Make HTTP requests directly;
|
||||
the gateway injects credentials automatically.
|
||||
compatibility: Requires HTTPS_PROXY set in environment (automatic when launched via `onecli run`)
|
||||
metadata:
|
||||
author: onecli
|
||||
version: "0.5.0"
|
||||
---
|
||||
|
||||
# OneCLI Gateway
|
||||
|
||||
Your outbound HTTPS traffic is transparently proxied through the OneCLI
|
||||
gateway, which injects stored credentials at the proxy boundary. You never
|
||||
see or handle credential values directly.
|
||||
|
||||
## How to Access External Services
|
||||
|
||||
You have direct HTTP access to external APIs. OAuth apps (Gmail, GitHub,
|
||||
Google Calendar, Google Drive, etc.) and API key services are all available
|
||||
through the gateway. Just make the request directly; the gateway injects
|
||||
credentials if the app is connected. If not, it returns an error with a
|
||||
connect URL you can present to the user.
|
||||
|
||||
## Making Requests
|
||||
|
||||
Call the real API URL. The gateway intercepts the request and injects
|
||||
credentials automatically.
|
||||
|
||||
```bash
|
||||
curl -s "https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=5"
|
||||
curl -s "https://api.github.com/user/repos?per_page=10"
|
||||
curl -s "https://api.stripe.com/v1/charges?limit=5"
|
||||
```
|
||||
|
||||
Standard HTTP clients (curl, fetch, requests, axios, Go net/http, git) all
|
||||
honor the `HTTPS_PROXY` environment variable automatically. You do not need
|
||||
to set any auth headers.
|
||||
|
||||
## Credential Stubs for MCP Servers
|
||||
|
||||
Some MCP servers need local credential files to start. Stubs for connected
|
||||
apps are pre-written automatically. Files containing `"onecli-managed"`
|
||||
values are managed by OneCLI — do NOT modify or delete them.
|
||||
|
||||
If an MCP server won't start due to missing credentials, create stubs
|
||||
**before** starting it. Use `"onecli-managed"` as the placeholder for all
|
||||
secret values, with file permissions `0600`. See the guide at:
|
||||
https://www.onecli.sh/docs/guides/credential-stubs/general-app
|
||||
|
||||
## When a Request Fails
|
||||
|
||||
If you get a 401, 403, or a gateway error (e.g., `app_not_connected`):
|
||||
|
||||
**Step 1 — Show the user a connect link.** Use the `connect_url` from the
|
||||
error response:
|
||||
|
||||
> To connect [service], open this link:
|
||||
> [connect_url from the error response]
|
||||
|
||||
If there is no `connect_url` in the error, tell the user to open the
|
||||
OneCLI dashboard and connect the service there.
|
||||
|
||||
**Step 2 — Retry after the user connects.** Let the user know you will
|
||||
retry once they have connected. When they confirm, retry the original
|
||||
request. If the retry still fails, ask if they need help with the setup.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Never** say "I don't have access to X" without first making the HTTP
|
||||
request through the proxy.
|
||||
- **Never** use browser extensions, gcloud, or manual auth flows. The
|
||||
gateway handles credentials for you.
|
||||
- **Never** ask the user for API keys or tokens directly. Direct them to
|
||||
connect the service in the OneCLI dashboard.
|
||||
- **Never** suggest the user open Gmail/Calendar/GitHub in their browser
|
||||
when they ask you to read or interact with those services. You have API
|
||||
access. Use it.
|
||||
- If the gateway returns a policy error (403 with a JSON body), respect
|
||||
the block. Do not retry or circumvent it.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Credentials & External Services
|
||||
|
||||
Your HTTP requests go through the OneCLI proxy, which injects real credentials automatically. Just call any API directly (Gmail, GitHub, Slack, etc.) — the proxy adds auth before it reaches the service.
|
||||
|
||||
Use any method: curl, Python, a CLI tool, whatever fits. If a tool checks for credentials locally, pass any placeholder value — the proxy replaces it with real credentials at request time.
|
||||
|
||||
If you get a `401`/`403`/`app_not_connected`, the error response contains a `connect_url` — you MUST show it to the user as a bare URL on its own line (no angle brackets, no markdown link syntax) so they can click to connect. Run `/onecli-gateway` for the full error-handling flow. Never ask the user for API keys or tokens.
|
||||
@@ -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.
|
||||
|
||||
|
||||
+29
-1
@@ -10,7 +10,7 @@ Access layer: `src/db/`. Authoritative schema reference: `src/db/schema.ts` (com
|
||||
|
||||
### 1.1 `agent_groups`
|
||||
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md`, skills, and `container.json`. Container config lives on disk, not in the DB.
|
||||
Agent workspaces. Each maps 1:1 to a `groups/<folder>/` directory containing `CLAUDE.md` and skills. Container config lives in `container_configs` (see §1.x below); a `container.json` file is materialized at spawn time for the container runner to read.
|
||||
|
||||
```sql
|
||||
CREATE TABLE agent_groups (
|
||||
@@ -294,6 +294,32 @@ CREATE TABLE schema_version (
|
||||
);
|
||||
```
|
||||
|
||||
### 1.15 `container_configs`
|
||||
|
||||
Per-agent-group container runtime config. Source of truth for provider, model, packages, MCP servers, mounts, CLI scope, etc. Materialized to `groups/<folder>/container.json` at spawn time.
|
||||
|
||||
```sql
|
||||
CREATE TABLE container_configs (
|
||||
agent_group_id TEXT PRIMARY KEY REFERENCES agent_groups(id) ON DELETE CASCADE,
|
||||
provider TEXT,
|
||||
model TEXT,
|
||||
effort TEXT,
|
||||
image_tag TEXT,
|
||||
assistant_name TEXT,
|
||||
max_messages_per_prompt INTEGER,
|
||||
skills TEXT NOT NULL DEFAULT '"all"',
|
||||
mcp_servers TEXT NOT NULL DEFAULT '{}',
|
||||
packages_apt TEXT NOT NULL DEFAULT '[]',
|
||||
packages_npm TEXT NOT NULL DEFAULT '[]',
|
||||
additional_mounts TEXT NOT NULL DEFAULT '[]',
|
||||
cli_scope TEXT NOT NULL DEFAULT 'group', -- disabled | group | global
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
- **Readers:** `src/container-config.ts`, `src/container-runner.ts`, `src/cli/dispatch.ts` (scope enforcement), `src/claude-md-compose.ts`
|
||||
- **Writers:** `src/db/container-configs.ts`, `src/modules/self-mod/apply.ts`, `src/backfill-container-configs.ts`
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration system
|
||||
@@ -313,6 +339,8 @@ Migrations live in `src/db/migrations/`, one file per migration. Runner: `runMig
|
||||
| 007 | `007-pending-approvals-title-options.ts` | `ALTER TABLE pending_approvals` add `title`, `options_json` (retrofits DBs created between 003 and 007) |
|
||||
| 008 | `008-dropped-messages.ts` | `unregistered_senders` |
|
||||
| 009 | `009-drop-pending-credentials.ts` | Drop the defunct `pending_credentials` table |
|
||||
| 014 | `014-container-configs.ts` | `container_configs` — per-agent-group container runtime config |
|
||||
| 015 | `015-cli-scope.ts` | `ALTER TABLE container_configs ADD COLUMN cli_scope` |
|
||||
|
||||
Numbers 005 and 006 are intentionally absent — migrations were renumbered during early development.
|
||||
|
||||
|
||||
+16
-13
@@ -33,19 +33,22 @@ Every message landing in the session: user chat, scheduled task, recurring task,
|
||||
|
||||
```sql
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL -- JSON; shape depends on kind
|
||||
id TEXT PRIMARY KEY,
|
||||
seq INTEGER UNIQUE, -- EVEN only (host assigns) — see §3
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending', -- pending|completed|failed|paused
|
||||
process_after TEXT,
|
||||
recurrence TEXT, -- cron expr for recurring
|
||||
series_id TEXT, -- groups occurrences of a recurring task
|
||||
tries INTEGER DEFAULT 0,
|
||||
trigger INTEGER NOT NULL DEFAULT 1, -- 0 = context only (don't wake), 1 = wake agent
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL, -- JSON; shape depends on kind
|
||||
source_session_id TEXT, -- agent-to-agent return path
|
||||
on_wake INTEGER NOT NULL DEFAULT 0 -- 1 = only deliver on container's first poll
|
||||
);
|
||||
CREATE INDEX idx_messages_in_series ON messages_in(series_id);
|
||||
```
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
+6
-2
@@ -1,10 +1,13 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "2.0.33",
|
||||
"version": "2.0.72",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"ncl": "bin/ncl"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
@@ -16,6 +19,7 @@
|
||||
"prepare": "husky",
|
||||
"setup": "tsx setup/index.ts",
|
||||
"setup:auto": "tsx setup/auto.ts",
|
||||
"ncl": "tsx src/cli/client.ts",
|
||||
"chat": "tsx scripts/chat.ts",
|
||||
"auth": "tsx src/whatsapp-auth.ts",
|
||||
"lint": "eslint src/",
|
||||
@@ -26,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"@clack/core": "^1.2.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@onecli-sh/sdk": "^0.3.1",
|
||||
"@onecli-sh/sdk": "^0.5.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"chat": "^4.24.0",
|
||||
"cron-parser": "5.5.0",
|
||||
|
||||
Generated
+5
-5
@@ -15,8 +15,8 @@ importers:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0
|
||||
'@onecli-sh/sdk':
|
||||
specifier: ^0.3.1
|
||||
version: 0.3.1
|
||||
specifier: ^0.5.0
|
||||
version: 0.5.0
|
||||
better-sqlite3:
|
||||
specifier: 11.10.0
|
||||
version: 11.10.0
|
||||
@@ -303,8 +303,8 @@ packages:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
|
||||
'@onecli-sh/sdk@0.3.1':
|
||||
resolution: {integrity: sha512-oMSa4DUCVS52vec41nFOg3XdCBTbMVEZdCFCsaUd9sRXVorCPWd3VyZq4giXsmk4g09DA/zLjsnrY7l6G94Ulg==}
|
||||
'@onecli-sh/sdk@0.5.0':
|
||||
resolution: {integrity: sha512-oe5Yx9o98v6N1PgzcCR7nULHHqcqKWNJIDOHGOSNX+l20mLlZpFUqfKPeFmsojBNRQMoqbvZQKUlFMp6gVuYBA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@oxc-project/types@0.124.0':
|
||||
@@ -1665,7 +1665,7 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.10.1
|
||||
optional: true
|
||||
|
||||
'@onecli-sh/sdk@0.3.1': {}
|
||||
'@onecli-sh/sdk@0.5.0': {}
|
||||
|
||||
'@oxc-project/types@0.124.0': {}
|
||||
|
||||
|
||||
@@ -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="141k tokens, 71% of context window">
|
||||
<title>141k tokens, 71% of context window</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="90" height="20" role="img" aria-label="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">141k</text>
|
||||
<text x="71" y="14">141k</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 |
+2
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* nc — chat with your NanoClaw agent from the terminal.
|
||||
* ncl — chat with your NanoClaw agent from the terminal.
|
||||
*
|
||||
* Usage:
|
||||
* pnpm run chat <message...>
|
||||
@@ -36,7 +36,7 @@ function main(): void {
|
||||
const e = err as NodeJS.ErrnoException;
|
||||
if (e.code === 'ENOENT' || e.code === 'ECONNREFUSED') {
|
||||
console.error(`NanoClaw daemon not reachable at ${socketPath()}.`);
|
||||
console.error('Start the service (launchctl/systemd) before running nc.');
|
||||
console.error('Start the service (launchctl/systemd) before running ncl.');
|
||||
} else {
|
||||
console.error('CLI socket error:', err);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { normalizeName } from '../src/modules/agent-to-agent/db/agent-destinatio
|
||||
import { addMember } from '../src/modules/permissions/db/agent-group-members.js';
|
||||
import { getUserRoles, grantRole } from '../src/modules/permissions/db/user-roles.js';
|
||||
import { upsertUser } from '../src/modules/permissions/db/users.js';
|
||||
import { updateContainerConfigScalars } from '../src/db/container-configs.js';
|
||||
import { initGroupFilesystem } from '../src/group-init.js';
|
||||
import { namespacedPlatformId } from '../src/platform-id.js';
|
||||
import type { AgentGroup, MessagingGroup } from '../src/types.js';
|
||||
@@ -231,6 +232,8 @@ async function main(): Promise<void> {
|
||||
granted_at: now,
|
||||
});
|
||||
}
|
||||
// Owner's agent group gets global CLI access
|
||||
updateContainerConfigScalars(ag.id, { cli_scope: 'global' });
|
||||
} else if (args.role === 'admin') {
|
||||
const alreadyAdmin = existingRoles.some(
|
||||
(r) => r.role === 'admin' && r.agent_group_id === ag.id,
|
||||
|
||||
+49
-7
@@ -39,7 +39,7 @@ import { runTelegramChannel } from './channels/telegram.js';
|
||||
import { runWhatsAppChannel } from './channels/whatsapp.js';
|
||||
import { pingCliAgent, type PingResult } from './lib/agent-ping.js';
|
||||
import { brightSelect } from './lib/bright-select.js';
|
||||
import { offerClaudeAssist } from './lib/claude-assist.js';
|
||||
import { offerClaudeOnFailure } from './lib/claude-handoff.js';
|
||||
import {
|
||||
applyToEnv,
|
||||
parseFlags,
|
||||
@@ -416,7 +416,7 @@ async function main(): Promise<void> {
|
||||
} else {
|
||||
phEmit('first_chat_failed', { reason: ping });
|
||||
renderPingFailureNote(ping);
|
||||
await offerClaudeAssist({
|
||||
await offerClaudeOnFailure({
|
||||
stepName: 'cli-agent',
|
||||
msg:
|
||||
ping === 'socket_error'
|
||||
@@ -468,7 +468,7 @@ async function main(): Promise<void> {
|
||||
} else if (channelChoice === 'imessage') {
|
||||
result = await runIMessageChannel(displayName!);
|
||||
} else if (channelChoice === 'other') {
|
||||
await askOtherChannelName();
|
||||
result = await askOtherChannelName();
|
||||
} else {
|
||||
p.log.info(
|
||||
brandBody(
|
||||
@@ -528,7 +528,7 @@ async function main(): Promise<void> {
|
||||
service_running: res.terminal?.fields.SERVICE === 'running',
|
||||
has_credentials: res.terminal?.fields.CREDENTIALS === 'configured',
|
||||
});
|
||||
await offerClaudeAssist({
|
||||
await offerClaudeOnFailure({
|
||||
stepName: 'verify',
|
||||
msg: summary || 'Verification completed with unresolved issues.',
|
||||
hint: `Terminal block: ${JSON.stringify(res.terminal?.fields ?? {})}`,
|
||||
@@ -740,12 +740,38 @@ async function runAuthStep(): Promise<void> {
|
||||
label: 'Paste an Anthropic API key',
|
||||
hint: 'pay-per-use via console.anthropic.com',
|
||||
},
|
||||
{
|
||||
value: 'skip',
|
||||
label: "Skip — I'll connect later",
|
||||
hint: 'not recommended — Claude helps debug setup issues',
|
||||
},
|
||||
],
|
||||
}),
|
||||
) as 'subscription' | 'oauth' | 'api';
|
||||
) as 'subscription' | 'oauth' | 'api' | 'skip';
|
||||
setupLog.userInput('auth_method', method);
|
||||
phEmit('auth_method_chosen', { method });
|
||||
|
||||
if (method === 'skip') {
|
||||
const confirmed = ensureAnswer(
|
||||
await p.confirm({
|
||||
message:
|
||||
"Skip Claude sign-in? The agent won't be able to run until you connect, and we won't be able to help debug setup errors.",
|
||||
initialValue: false,
|
||||
}),
|
||||
);
|
||||
if (!confirmed) {
|
||||
// Loop back to the auth picker so they can choose a real method.
|
||||
return runAuthStep();
|
||||
}
|
||||
setupLog.step('auth', 'skipped', 0, { REASON: 'user-skipped' });
|
||||
p.log.warn(
|
||||
brandBody(
|
||||
'Claude sign-in skipped. Re-run setup or run `bash nanoclaw.sh` to finish later.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'subscription') {
|
||||
await runSubscriptionAuth();
|
||||
} else {
|
||||
@@ -1099,10 +1125,26 @@ async function askChannelChoice(): Promise<ChannelChoice> {
|
||||
return choice;
|
||||
}
|
||||
|
||||
async function askOtherChannelName(): Promise<void> {
|
||||
async function askOtherChannelName(): Promise<void | typeof BACK_TO_CHANNEL_SELECTION> {
|
||||
const action = ensureAnswer(
|
||||
await brightSelect<'type' | 'back'>({
|
||||
message: 'Which channel would you like to install?',
|
||||
options: [
|
||||
{
|
||||
value: 'type',
|
||||
label: 'Type the channel name',
|
||||
hint: 'e.g. matrix, github, linear, webex',
|
||||
},
|
||||
{ value: 'back', label: '← Back to channel selection' },
|
||||
],
|
||||
initialValue: 'type',
|
||||
}),
|
||||
);
|
||||
if (action === 'back') return BACK_TO_CHANNEL_SELECTION;
|
||||
|
||||
const answer = ensureAnswer(
|
||||
await p.text({
|
||||
message: 'Which channel would you like to install?',
|
||||
message: 'Channel name',
|
||||
placeholder: 'e.g. matrix, github, linear, webex',
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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"',
|
||||
@@ -317,9 +318,9 @@ async function collectSlackUserId(): Promise<string> {
|
||||
[
|
||||
"To get your Slack member ID:",
|
||||
'',
|
||||
' 1. In Slack, click your profile picture (top right)',
|
||||
' 1. In Slack, click your profile picture (bottom left)',
|
||||
' 2. Click "Profile"',
|
||||
' 3. Click the three dots (⋯) → "Copy member ID"',
|
||||
' 3. Click the three dots (⋮) → "Copy member ID"',
|
||||
].join('\n'),
|
||||
'Find your Slack user ID',
|
||||
);
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface AssistContext {
|
||||
* rather than us stuffing contents into the prompt. Keys are step names as
|
||||
* they appear in fail() calls; values are repo-relative paths.
|
||||
*/
|
||||
const STEP_FILES: Record<string, string[]> = {
|
||||
export const STEP_FILES: Record<string, string[]> = {
|
||||
bootstrap: ['setup.sh', 'setup/install-node.sh', 'nanoclaw.sh'],
|
||||
environment: ['setup/environment.ts'],
|
||||
container: [
|
||||
@@ -81,7 +81,7 @@ const STEP_FILES: Record<string, string[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||
export const BIG_PICTURE_FILES = ['README.md', 'setup/auto.ts'];
|
||||
|
||||
/**
|
||||
* Returns `true` if the user ran a Claude-suggested fix command; callers
|
||||
@@ -150,7 +150,7 @@ function isClaudeAuthenticated(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
export async function ensureClaudeReady(projectRoot: string): Promise<boolean> {
|
||||
if (!isClaudeInstalled()) {
|
||||
const install = ensureAnswer(
|
||||
await p.confirm({
|
||||
|
||||
@@ -23,10 +23,19 @@
|
||||
* attempting to parse it as a real answer.
|
||||
*/
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import {
|
||||
type AssistContext,
|
||||
BIG_PICTURE_FILES,
|
||||
ensureClaudeReady,
|
||||
offerClaudeAssist,
|
||||
STEP_FILES,
|
||||
} from './claude-assist.js';
|
||||
import { ensureAnswer } from './runner.js';
|
||||
import { brandBody, note } from './theme.js';
|
||||
|
||||
export interface HandoffContext {
|
||||
@@ -194,3 +203,110 @@ function buildSystemPrompt(ctx: HandoffContext): string {
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatcher: checks NANOCLAW_SETUP_ASSIST_MODE and delegates to either
|
||||
* the interactive failure handoff (default) or the non-interactive assist.
|
||||
*
|
||||
* Drop-in replacement for `offerClaudeAssist` at failure call sites.
|
||||
*/
|
||||
export async function offerClaudeOnFailure(
|
||||
ctx: AssistContext,
|
||||
projectRoot: string = process.cwd(),
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SETUP_ASSIST_MODE === 'true' || process.env.NANOCLAW_SETUP_ASSIST_MODE === '1') {
|
||||
return offerClaudeAssist(ctx, projectRoot);
|
||||
}
|
||||
return offerFailureHandoff(ctx, projectRoot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive Claude handoff for setup failures. Same role as
|
||||
* `offerClaudeAssist` but spawns an interactive session instead of
|
||||
* parsing a structured REASON/COMMAND response.
|
||||
*
|
||||
* Returns `true` if Claude was launched (the user may have fixed
|
||||
* things during the session), `false` if skipped/declined/unavailable.
|
||||
*/
|
||||
async function offerFailureHandoff(
|
||||
ctx: AssistContext,
|
||||
projectRoot: string,
|
||||
): Promise<boolean> {
|
||||
if (process.env.NANOCLAW_SKIP_CLAUDE_ASSIST === '1') return false;
|
||||
if (!(await ensureClaudeReady(projectRoot))) return false;
|
||||
|
||||
const want = ensureAnswer(
|
||||
await p.confirm({
|
||||
message: 'Want to debug this with Claude?',
|
||||
initialValue: true,
|
||||
}),
|
||||
);
|
||||
if (!want) return false;
|
||||
|
||||
const systemPrompt = buildFailureSystemPrompt(ctx, projectRoot);
|
||||
|
||||
note(
|
||||
[
|
||||
"Launching Claude to help debug this failure.",
|
||||
"It has the context of what went wrong.",
|
||||
"",
|
||||
k.dim("Type /exit (or press Ctrl-D) when you're ready to come back to setup."),
|
||||
].join('\n'),
|
||||
'Handing off to Claude',
|
||||
);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const child = spawn(
|
||||
'claude',
|
||||
[
|
||||
'--append-system-prompt',
|
||||
systemPrompt,
|
||||
'--permission-mode',
|
||||
'acceptEdits',
|
||||
],
|
||||
{ stdio: 'inherit' },
|
||||
);
|
||||
child.on('close', () => {
|
||||
p.log.success(brandBody("Back from Claude. Let's continue."));
|
||||
resolve(true);
|
||||
});
|
||||
child.on('error', () => {
|
||||
p.log.error("Couldn't launch Claude. Continuing without handoff.");
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildFailureSystemPrompt(ctx: AssistContext, projectRoot: string): string {
|
||||
const stepRefs = STEP_FILES[ctx.stepName] ?? [];
|
||||
const references = [
|
||||
...BIG_PICTURE_FILES,
|
||||
...stepRefs,
|
||||
'logs/setup.log',
|
||||
ctx.rawLogPath
|
||||
? path.relative(projectRoot, ctx.rawLogPath)
|
||||
: 'logs/setup-steps/',
|
||||
].filter((v, i, a) => a.indexOf(v) === i);
|
||||
|
||||
const lines: string[] = [
|
||||
"The user is running NanoClaw's interactive setup flow and hit a failure.",
|
||||
'',
|
||||
`Failed step: ${ctx.stepName}`,
|
||||
`Error: ${ctx.msg}`,
|
||||
];
|
||||
|
||||
if (ctx.hint) lines.push(`Hint: ${ctx.hint}`);
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
'Your job: help them diagnose and fix this issue. Read the referenced files',
|
||||
'and logs to understand what went wrong, then help them fix it. You can read',
|
||||
'files, run commands, check logs, and explain what happened. Be concise.',
|
||||
"When they're ready to resume setup, tell them to type /exit.",
|
||||
'',
|
||||
'Relevant files (read as needed with the Read tool):',
|
||||
);
|
||||
for (const f of references) lines.push(` - ${f}`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ import * as p from '@clack/prompts';
|
||||
import k from 'kleur';
|
||||
|
||||
import * as setupLog from '../logs.js';
|
||||
import { offerClaudeAssist } from './claude-assist.js';
|
||||
import { offerClaudeOnFailure } from './claude-handoff.js';
|
||||
import { emit as phEmit } from './diagnostics.js';
|
||||
import { brandBody, fitToWidth, fmtDuration } from './theme.js';
|
||||
|
||||
@@ -367,7 +367,7 @@ export async function fail(
|
||||
if (hint) p.log.message(k.dim(hint));
|
||||
p.log.message(k.dim('Logs: logs/setup.log · Raw: logs/setup-steps/'));
|
||||
|
||||
const ranFix = await offerClaudeAssist({ stepName, msg, hint, rawLogPath });
|
||||
const ranFix = await offerClaudeOnFailure({ stepName, msg, hint, rawLogPath });
|
||||
|
||||
// If the user just ran a Claude-suggested fix, offer to resume the flow
|
||||
// at the step that failed instead of aborting. We re-exec via spawnSync
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
@@ -123,6 +123,15 @@ export const CONFIG: Entry[] = [
|
||||
surface: 'flag',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
key: 'assistMode',
|
||||
envVar: 'NANOCLAW_SETUP_ASSIST_MODE',
|
||||
label: 'Assist mode',
|
||||
help: 'Use non-interactive Claude assist on failure instead of interactive handoff.',
|
||||
surface: 'flag',
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── name derivation ───────────────────────────────────────────────────
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user