Merge branch 'main' into fix/stale-session-recovery

This commit is contained in:
gavrielc
2026-03-30 10:59:57 +03:00
committed by GitHub
58 changed files with 2631 additions and 1010 deletions

View File

@@ -0,0 +1,289 @@
---
name: add-emacs
description: Add Emacs as a channel. Opens an interactive chat buffer and org-mode integration so you can talk to NanoClaw from within Emacs (Doom, Spacemacs, or vanilla). Uses a local HTTP bridge — no bot token or external service needed.
---
# Add Emacs Channel
This skill adds Emacs support to NanoClaw, then walks through interactive setup.
Works with Doom Emacs, Spacemacs, and vanilla Emacs 27.1+.
## What you can do with this
- **Ask while coding** — open the chat buffer (`C-c n c` / `SPC N c`), ask about a function or error without leaving Emacs
- **Code review** — select a region and send it with `nanoclaw-org-send`; the response appears as a child heading inline in your org file
- **Meeting notes** — send an org agenda entry; get a summary or action item list back as a child node
- **Draft writing** — send org prose; receive revisions or continuations in place
- **Research capture** — ask a question directly in your org notes; the answer lands exactly where you need it
- **Schedule tasks** — ask Andy to set a reminder or create a scheduled NanoClaw task (e.g. "remind me tomorrow to review the PR")
## Phase 1: Pre-flight
### Check if already applied
Check if `src/channels/emacs.ts` exists:
```bash
test -f src/channels/emacs.ts && echo "already applied" || echo "not applied"
```
If it exists, skip to Phase 3 (Setup). The code changes are already in place.
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
git remote -v
```
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/emacs
git merge upstream/skill/emacs
```
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This adds:
- `src/channels/emacs.ts``EmacsBridgeChannel` HTTP server (port 8766)
- `src/channels/emacs.test.ts` — unit tests
- `emacs/nanoclaw.el` — Emacs Lisp package (`nanoclaw-chat`, `nanoclaw-org-send`)
- `import './emacs.js'` appended to `src/channels/index.ts`
If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides.
### Validate code changes
```bash
npm run build
npx vitest run src/channels/emacs.test.ts
```
Build must be clean and tests must pass before proceeding.
## Phase 3: Setup
### Configure environment (optional)
The channel works out of the box with defaults. Add to `.env` only if you need non-defaults:
```bash
EMACS_CHANNEL_PORT=8766 # default — change if 8766 is already in use
EMACS_AUTH_TOKEN=<random> # optional — locks the endpoint to Emacs only
```
If you change or add values, sync to the container environment:
```bash
mkdir -p data/env && cp .env data/env/env
```
### Configure Emacs
The `nanoclaw.el` package requires only Emacs 27.1+ built-in libraries (`url`, `json`, `org`) — no package manager setup needed.
AskUserQuestion: Which Emacs distribution are you using?
- **Doom Emacs** - config.el with map! keybindings
- **Spacemacs** - dotspacemacs/user-config in ~/.spacemacs
- **Vanilla Emacs / other** - init.el with global-set-key
**Doom Emacs** — add to `~/.config/doom/config.el` (or `~/.doom.d/config.el`):
```elisp
;; NanoClaw — personal AI assistant channel
(load (expand-file-name "~/src/nanoclaw/emacs/nanoclaw.el"))
(map! :leader
:prefix ("N" . "NanoClaw")
:desc "Chat buffer" "c" #'nanoclaw-chat
:desc "Send org" "o" #'nanoclaw-org-send)
```
Then reload: `M-x doom/reload`
**Spacemacs** — add to `dotspacemacs/user-config` in `~/.spacemacs`:
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
(spacemacs/set-leader-keys "aNc" #'nanoclaw-chat)
(spacemacs/set-leader-keys "aNo" #'nanoclaw-org-send)
```
Then reload: `M-x dotspacemacs/sync-configuration-layers` or restart Emacs.
**Vanilla Emacs** — add to `~/.emacs.d/init.el` (or `~/.emacs`):
```elisp
;; NanoClaw — personal AI assistant channel
(load-file "~/src/nanoclaw/emacs/nanoclaw.el")
(global-set-key (kbd "C-c n c") #'nanoclaw-chat)
(global-set-key (kbd "C-c n o") #'nanoclaw-org-send)
```
Then reload: `M-x eval-buffer` or restart Emacs.
If `EMACS_AUTH_TOKEN` was set, also add (any distribution):
```elisp
(setq nanoclaw-auth-token "<your-token>")
```
If `EMACS_CHANNEL_PORT` was changed from the default, also add:
```elisp
(setq nanoclaw-port <your-port>)
```
### Restart NanoClaw
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
## Phase 4: Verify
### Test the HTTP endpoint
```bash
curl -s "http://localhost:8766/api/messages?since=0"
```
Expected: `{"messages":[]}`
If you set `EMACS_AUTH_TOKEN`:
```bash
curl -s -H "Authorization: Bearer <token>" "http://localhost:8766/api/messages?since=0"
```
### Test from Emacs
Tell the user:
> 1. Open the chat buffer with your keybinding (`SPC N c`, `SPC a N c`, or `C-c n c`)
> 2. Type a message and press `RET`
> 3. A response from Andy should appear within a few seconds
>
> For org-mode: open any `.org` file, position the cursor on a heading, and use `SPC N o` / `SPC a N o` / `C-c n o`
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
Look for `Emacs channel listening` at startup and `Emacs message received` when a message is sent.
## Troubleshooting
### Port already in use
```
Error: listen EADDRINUSE: address already in use :::8766
```
Either a stale NanoClaw process is running, or 8766 is taken by another app.
Find and kill the stale process:
```bash
lsof -ti :8766 | xargs kill -9
```
Or change the port in `.env` (`EMACS_CHANNEL_PORT=8767`) and update `nanoclaw-port` in Emacs config.
### No response from agent
Check:
1. NanoClaw is running: `launchctl list | grep nanoclaw` (macOS) or `systemctl --user status nanoclaw` (Linux)
2. Emacs group is registered: `sqlite3 store/messages.db "SELECT * FROM registered_groups WHERE jid = 'emacs:default'"`
3. Logs show activity: `tail -50 logs/nanoclaw.log`
If the group is not registered, it will be created automatically on the next NanoClaw restart.
### Auth token mismatch (401 Unauthorized)
Verify the token in Emacs matches `.env`:
```elisp
;; M-x describe-variable RET nanoclaw-auth-token RET
```
Must exactly match `EMACS_AUTH_TOKEN` in `.env`.
### nanoclaw.el not loading
Check the path is correct:
```bash
ls ~/src/nanoclaw/emacs/nanoclaw.el
```
If NanoClaw is cloned elsewhere, update the `load`/`load-file` path in your Emacs config.
## After Setup
If running `npm run dev` while the service is active:
```bash
# macOS:
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
npm run dev
# When done testing:
launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# Linux:
# systemctl --user stop nanoclaw
# npm run dev
# systemctl --user start nanoclaw
```
## Agent Formatting
The Emacs bridge converts markdown → org-mode automatically. Agents should
output standard markdown — **not** org-mode syntax. The conversion handles:
| Markdown | Org-mode |
|----------|----------|
| `**bold**` | `*bold*` |
| `*italic*` | `/italic/` |
| `~~text~~` | `+text+` |
| `` `code` `` | `~code~` |
| ` ```lang ` | `#+begin_src lang` |
If an agent outputs org-mode directly, bold/italic/etc. will be double-converted
and render incorrectly.
## Removal
To remove the Emacs channel:
1. Delete `src/channels/emacs.ts`, `src/channels/emacs.test.ts`, and `emacs/nanoclaw.el`
2. Remove `import './emacs.js'` from `src/channels/index.ts`
3. Remove the NanoClaw block from your Emacs config file
4. Remove Emacs registration from SQLite: `sqlite3 store/messages.db "DELETE FROM registered_groups WHERE jid = 'emacs:default'"`
5. Remove `EMACS_CHANNEL_PORT` and `EMACS_AUTH_TOKEN` from `.env` if set
6. Rebuild: `npm run build && launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `npm run build && systemctl --user restart nanoclaw` (Linux)

View File

@@ -0,0 +1,133 @@
---
name: add-macos-statusbar
description: Add a macOS menu bar status indicator for NanoClaw. Shows a bolt icon with a green/red dot indicating whether NanoClaw is running, with Start, Stop, and Restart controls. macOS only.
---
# Add macOS Menu Bar Status Indicator
Adds a persistent menu bar icon that shows NanoClaw's running status and lets the user
start, stop, or restart the service — similar to how Docker Desktop appears in the menu bar.
**macOS only.** Requires Xcode Command Line Tools (`swiftc`).
## Phase 1: Pre-flight
### Check platform
If not on macOS, stop and tell the user:
> This skill is macOS only. The menu bar status indicator uses AppKit and requires `swiftc` (Xcode Command Line Tools).
### Check for swiftc
```bash
which swiftc
```
If not found, tell the user:
> Xcode Command Line Tools are required. Install them by running:
>
> ```bash
> xcode-select --install
> ```
>
> Then re-run `/add-macos-statusbar`.
### Check if already installed
```bash
launchctl list | grep com.nanoclaw.statusbar
```
If it returns a PID (not `-`), tell the user it's already installed and skip to Phase 3 (Verify).
## Phase 2: Compile and Install
### Compile the Swift binary
The source lives in the skill directory. Compile it into `dist/`:
```bash
mkdir -p dist
swiftc -O -o dist/statusbar "${CLAUDE_SKILL_DIR}/add/src/statusbar.swift"
```
This produces a small native binary at `dist/statusbar`.
On macOS Sequoia or later, clear the quarantine attribute so the binary can run:
```bash
xattr -cr dist/statusbar
```
### Create the launchd plist
Determine the absolute project root and home directory:
```bash
pwd
echo $HOME
```
Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values
for `{PROJECT_ROOT}` and `{HOME}`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.nanoclaw.statusbar</string>
<key>ProgramArguments</key>
<array>
<string>{PROJECT_ROOT}/dist/statusbar</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{HOME}</string>
</dict>
<key>StandardOutPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.log</string>
<key>StandardErrorPath</key>
<string>{PROJECT_ROOT}/logs/statusbar.error.log</string>
</dict>
</plist>
```
### Load the service
```bash
launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
```
## Phase 3: Verify
```bash
launchctl list | grep com.nanoclaw.statusbar
```
The first column should show a PID (not `-`).
Tell the user:
> The bolt icon should now appear in your macOS menu bar. Click it to see NanoClaw's status and control the service.
>
> - **Green dot** — NanoClaw is running
> - **Red dot** — NanoClaw is stopped
>
> Use **Restart** after making code changes, and **View Logs** to open the log file directly.
## Removal
```bash
launchctl unload ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
rm dist/statusbar
```

View File

@@ -0,0 +1,147 @@
import AppKit
class StatusBarController: NSObject {
private var statusItem: NSStatusItem!
private var isRunning = false
private var timer: Timer?
private let plistPath = "\(NSHomeDirectory())/Library/LaunchAgents/com.nanoclaw.plist"
/// Derive the NanoClaw project root from the binary location.
/// The binary is compiled to {project}/dist/statusbar, so the parent of
/// the parent directory is the project root.
private static let projectRoot: String = {
let binary = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath()
return binary.deletingLastPathComponent().deletingLastPathComponent().path
}()
override init() {
super.init()
setupStatusItem()
isRunning = checkRunning()
updateMenu()
// Poll every 5 seconds to reflect external state changes
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
guard let self else { return }
let current = self.checkRunning()
if current != self.isRunning {
self.isRunning = current
self.updateMenu()
}
}
}
private func setupStatusItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
if let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "NanoClaw") {
image.isTemplate = true
button.image = image
} else {
button.title = ""
}
button.toolTip = "NanoClaw"
}
}
private func checkRunning() -> Bool {
let task = Process()
task.launchPath = "/bin/launchctl"
task.arguments = ["list", "com.nanoclaw"]
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = Pipe()
guard (try? task.run()) != nil else { return false }
task.waitUntilExit()
if task.terminationStatus != 0 { return false }
let output = String(data: pipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
// launchctl list output: "PID\tExitCode\tLabel" "-" means not running
let pid = output.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: "\t").first ?? "-"
return pid != "-"
}
private func updateMenu() {
let menu = NSMenu()
// Status row with colored dot
let statusItem = NSMenuItem()
let dot = ""
let dotColor: NSColor = isRunning ? .systemGreen : .systemRed
let attr = NSMutableAttributedString(string: dot, attributes: [.foregroundColor: dotColor])
let label = isRunning ? "NanoClaw is running" : "NanoClaw is stopped"
attr.append(NSAttributedString(string: label, attributes: [.foregroundColor: NSColor.labelColor]))
statusItem.attributedTitle = attr
statusItem.isEnabled = false
menu.addItem(statusItem)
menu.addItem(NSMenuItem.separator())
if isRunning {
let stop = NSMenuItem(title: "Stop", action: #selector(stopService), keyEquivalent: "")
stop.target = self
menu.addItem(stop)
let restart = NSMenuItem(title: "Restart", action: #selector(restartService), keyEquivalent: "r")
restart.target = self
menu.addItem(restart)
} else {
let start = NSMenuItem(title: "Start", action: #selector(startService), keyEquivalent: "")
start.target = self
menu.addItem(start)
}
menu.addItem(NSMenuItem.separator())
let logs = NSMenuItem(title: "View Logs", action: #selector(viewLogs), keyEquivalent: "")
logs.target = self
menu.addItem(logs)
self.statusItem.menu = menu
}
@objc private func startService() {
run("/bin/launchctl", ["load", plistPath])
refresh(after: 2)
}
@objc private func stopService() {
run("/bin/launchctl", ["unload", plistPath])
refresh(after: 2)
}
@objc private func restartService() {
let uid = getuid()
run("/bin/launchctl", ["kickstart", "-k", "gui/\(uid)/com.nanoclaw"])
refresh(after: 3)
}
@objc private func viewLogs() {
let logPath = "\(StatusBarController.projectRoot)/logs/nanoclaw.log"
NSWorkspace.shared.open(URL(fileURLWithPath: logPath))
}
private func refresh(after seconds: Double) {
DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in
guard let self else { return }
self.isRunning = self.checkRunning()
self.updateMenu()
}
}
@discardableResult
private func run(_ path: String, _ args: [String]) -> Int32 {
let task = Process()
task.launchPath = path
task.arguments = args
task.standardOutput = Pipe()
task.standardError = Pipe()
try? task.run()
task.waitUntilExit()
return task.terminationStatus
}
}
let app = NSApplication.shared
app.setActivationPolicy(.accessory)
let controller = StatusBarController()
app.run()

View File

@@ -1,18 +1,20 @@
---
name: add-ollama-tool
description: Add Ollama MCP server so the container agent can call local models and manage the Ollama model library.
description: Add Ollama MCP server so the container agent can call local models and optionally manage the Ollama model library.
---
# Add Ollama Integration
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can also manage the model library directly.
This skill adds a stdio-based MCP server that exposes local Ollama models as tools for the container agent. Claude remains the orchestrator but can offload work to local models, and can optionally manage the model library directly.
Tools added:
- `ollama_list_models` — list installed models with name, size, family, and last modified date
Core tools (always available):
- `ollama_list_models` — list installed Ollama models with name, size, and family
- `ollama_generate` — send a prompt to a specified model and return the response
- `ollama_pull_model` — pull (download) a model from the Ollama registry by name
Management tools (opt-in via `OLLAMA_ADMIN_TOOLS=true`):
- `ollama_pull_model` — pull (download) a model from the Ollama registry
- `ollama_delete_model` — delete a locally installed model to free disk space
- `ollama_show_model` — show model details: modelfile, parameters, template, and architecture info
- `ollama_show_model` — show model details: modelfile, parameters, and architecture info
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
## Phase 1: Pre-flight
@@ -93,6 +95,23 @@ Build must be clean before proceeding.
## Phase 3: Configure
### Enable model management tools (optional)
Ask the user:
> Would you like the agent to be able to **manage Ollama models** (pull, delete, inspect, list running)?
>
> - **Yes** — adds tools to pull new models, delete old ones, show model info, and check what's loaded in memory
> - **No** — the agent can only list installed models and generate responses (you manage models yourself on the host)
If the user wants management tools, add to `.env`:
```bash
OLLAMA_ADMIN_TOOLS=true
```
If they decline (or don't answer), do not add the variable — management tools will be disabled by default.
### Set Ollama host (optional)
By default, the MCP server connects to `http://host.docker.internal:11434` (Docker Desktop) with a fallback to `localhost`. To use a custom Ollama host, add to `.env`:
@@ -118,7 +137,9 @@ Tell the user:
>
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
### Test model management
### Test model management (if enabled)
If `OLLAMA_ADMIN_TOOLS=true` was set, tell the user:
> Send a message like: "pull the gemma3:1b model" or "which ollama models are currently loaded in memory?"
>
@@ -141,8 +162,8 @@ tail -f logs/nanoclaw.log | grep -i ollama
Look for:
- `[OLLAMA] >>> Generating` — generation started
- `[OLLAMA] <<< Done` — generation completed
- `[OLLAMA] Pulling model:` — pull in progress
- `[OLLAMA] Deleted:` — model removed
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
- `[OLLAMA] Deleted:` — model removed (management tools)
## Troubleshooting
@@ -166,3 +187,7 @@ The agent may not know about the tools. Try being explicit: "use the ollama_gene
### `ollama_pull_model` times out on large models
Large models (7B+) can take several minutes. The tool uses `stream: false` so it blocks until complete — this is intentional. For very large pulls, use the host CLI directly: `ollama pull <model>`
### Management tools not showing up
Ensure `OLLAMA_ADMIN_TOOLS=true` is set in `.env` and the service was restarted after adding it.

View File

@@ -202,14 +202,6 @@ launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
# systemctl --user start nanoclaw
```
## Agent Swarms (Teams)
After completing the Telegram setup, use `AskUserQuestion`:
AskUserQuestion: Would you like to add Agent Swarm support? Without it, Agent Teams still work — they just operate behind the scenes. With Swarm support, each subagent appears as a different bot in the Telegram group so you can see who's saying what and have interactive team sessions.
If they say yes, invoke the `/add-telegram-swarm` skill.
## Removal
To remove Telegram integration:

View File

@@ -40,7 +40,7 @@ Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to
If they chose pairing code:
AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890)
AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.)
## Phase 2: Apply Code Changes
@@ -308,7 +308,7 @@ rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone <phone
```
Enter the code **immediately** when it appears. Also ensure:
1. Phone number includes country code without `+` (e.g., `1234567890`)
1. Phone number is digits only — country code + number, no `+` prefix (e.g., `14155551234` where `1` is country code, `4155551234` is the number)
2. Phone has internet access
3. WhatsApp is updated to the latest version

View File

@@ -0,0 +1,137 @@
---
name: channel-formatting
description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill.
---
# Channel Formatting
This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's
responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or
Telegram.
| Channel | Transformation |
|---------|---------------|
| WhatsApp | `**bold**``*bold*`, `*italic*``_italic_`, headings → bold, links → `text (url)` |
| Telegram | same as WhatsApp, but `[text](url)` links are preserved (Markdown v1 renders them natively) |
| Slack | same as WhatsApp, but links become `<url\|text>` |
| Discord | passthrough (Discord already renders Markdown) |
| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill |
Code blocks (fenced and inline) are always protected — their content is never transformed.
## Phase 1: Pre-flight
### Check if already applied
```bash
test -f src/text-styles.ts && echo "already applied" || echo "not yet applied"
```
If `already applied`, skip to Phase 3 (Verify).
## Phase 2: Apply Code Changes
### Ensure the upstream remote
```bash
git remote -v
```
If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing,
add it:
```bash
git remote add upstream https://github.com/qwibitai/nanoclaw.git
```
### Merge the skill branch
```bash
git fetch upstream skill/channel-formatting
git merge upstream/skill/channel-formatting
```
If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming
version and continuing:
```bash
git checkout --theirs package-lock.json
git add package-lock.json
git merge --continue
```
For any other conflict, read the conflicted file and reconcile both sides manually.
This merge adds:
- `src/text-styles.ts``parseTextStyles(text, channel)` for marker substitution and
`parseSignalStyles(text)` for Signal native rich text
- `src/router.ts``formatOutbound` gains an optional `channel` parameter; when provided
it calls `parseTextStyles` after stripping `<internal>` tags
- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound`
- `src/formatting.test.ts` — test coverage for both functions across all channels
### Validate
```bash
npm install
npm run build
npx vitest run src/formatting.test.ts
```
All 73 tests should pass and the build should be clean before continuing.
## Phase 3: Verify
### Rebuild and restart
```bash
npm run build
launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
# Linux: systemctl --user restart nanoclaw
```
### Spot-check formatting
Send a message through any registered WhatsApp or Telegram chat that will trigger a
response from Claude. Ask something that will produce formatted output, such as:
> Summarise the three main advantages of TypeScript using bullet points and **bold** headings.
Confirm that the response arrives with native bold (`*text*`) rather than raw double
asterisks.
### Check logs if needed
```bash
tail -f logs/nanoclaw.log
```
## Signal Skill Integration
If you have the Signal skill installed, `src/channels/signal.ts` can import
`parseSignalStyles` from the newly present `src/text-styles.ts`:
```typescript
import { parseSignalStyles, SignalTextStyle } from '../text-styles.js';
```
`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where
`textStyle` is an array of `{ style, start, length }` objects suitable for the
`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`).
## Removal
```bash
# Remove the new file
rm src/text-styles.ts
# Revert router.ts to remove the channel param
git diff upstream/main src/router.ts # review changes
git checkout upstream/main -- src/router.ts
# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText)
# (edit manually or: git checkout upstream/main -- src/index.ts)
npm run build
```

View File

@@ -121,8 +121,48 @@ def find_group(groups: list[dict], query: str) -> dict | None:
return None
def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -> None:
cmd = [runtime, "run", "-i", "--rm", image]
def build_mounts(folder: str, is_main: bool) -> list[tuple[str, str, bool]]:
"""Return list of (host_path, container_path, readonly) tuples."""
groups_dir = NANOCLAW_DIR / "groups"
data_dir = NANOCLAW_DIR / "data"
sessions_dir = data_dir / "sessions" / folder
ipc_dir = data_dir / "ipc" / folder
# Ensure required dirs exist
group_dir = groups_dir / folder
group_dir.mkdir(parents=True, exist_ok=True)
(sessions_dir / ".claude").mkdir(parents=True, exist_ok=True)
for sub in ("messages", "tasks", "input"):
(ipc_dir / sub).mkdir(parents=True, exist_ok=True)
agent_runner_src = sessions_dir / "agent-runner-src"
project_agent_runner = NANOCLAW_DIR / "container" / "agent-runner" / "src"
if not agent_runner_src.exists() and project_agent_runner.exists():
import shutil
shutil.copytree(project_agent_runner, agent_runner_src)
mounts: list[tuple[str, str, bool]] = []
if is_main:
mounts.append((str(NANOCLAW_DIR), "/workspace/project", True))
mounts.append((str(group_dir), "/workspace/group", False))
mounts.append((str(sessions_dir / ".claude"), "/home/node/.claude", False))
mounts.append((str(ipc_dir), "/workspace/ipc", False))
if agent_runner_src.exists():
mounts.append((str(agent_runner_src), "/app/src", False))
return mounts
def run_container(runtime: str, image: str, payload: dict,
folder: str | None = None, is_main: bool = False,
timeout: int = 300) -> None:
cmd = [runtime, "run", "-i", "--rm"]
if folder:
for host, container, readonly in build_mounts(folder, is_main):
if readonly:
cmd += ["--mount", f"type=bind,source={host},target={container},readonly"]
else:
cmd += ["-v", f"{host}:{container}"]
cmd.append(image)
dbg(f"cmd: {' '.join(cmd)}")
# Show payload sans secrets
@@ -167,7 +207,12 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
dbg("output sentinel found, terminating container")
done.set()
try:
proc.kill()
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
dbg("graceful stop timed out, force killing container")
proc.kill()
except ProcessLookupError:
pass
return
@@ -197,6 +242,8 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
stdout,
re.DOTALL,
)
success = False
if match:
try:
data = json.loads(match.group(1))
@@ -206,6 +253,7 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
session_id = data.get("newSessionId") or data.get("sessionId")
if session_id:
print(f"\n[session: {session_id}]", file=sys.stderr)
success = True
else:
print(f"[{status}] {data.get('result', '')}", file=sys.stderr)
sys.exit(1)
@@ -215,6 +263,9 @@ def run_container(runtime: str, image: str, payload: dict, timeout: int = 300) -
# No structured output — print raw stdout
print(stdout)
if success:
return
if proc.returncode not in (0, None):
sys.exit(proc.returncode)
@@ -273,6 +324,7 @@ def main():
# Resolve group → jid
jid = args.jid
group_name = None
group_folder = None
is_main = False
if args.group:
@@ -281,6 +333,7 @@ def main():
sys.exit(f"error: group '{args.group}' not found. Run --list-groups to see options.")
jid = g["jid"]
group_name = g["name"]
group_folder = g["folder"]
is_main = g["is_main"]
elif not jid:
# Default: main group
@@ -288,6 +341,7 @@ def main():
if mains:
jid = mains[0]["jid"]
group_name = mains[0]["name"]
group_folder = mains[0]["folder"]
is_main = True
else:
sys.exit("error: no group specified and no main group found. Use -g or -j.")
@@ -311,7 +365,9 @@ def main():
payload["resumeAt"] = "latest"
print(f"[{group_name or jid}] running via {runtime}...", file=sys.stderr)
run_container(runtime, args.image, payload, timeout=args.timeout)
run_container(runtime, args.image, payload,
folder=group_folder, is_main=is_main,
timeout=args.timeout)
if __name__ == "__main__":

View File

@@ -0,0 +1,276 @@
---
name: init-onecli
description: Install and initialize OneCLI Agent Vault. Migrates existing .env credentials to the vault. Use after /update-nanoclaw brings in OneCLI as a breaking change, or for first-time OneCLI setup.
---
# Initialize OneCLI Agent Vault
This skill installs OneCLI, configures the Agent Vault gateway, and migrates any existing `.env` credentials into it. Run this after `/update-nanoclaw` introduces OneCLI as a breaking change, or any time OneCLI needs to be set up from scratch.
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. pasting a token).
## Phase 1: Pre-flight
### Check if OneCLI is already working
```bash
onecli version 2>/dev/null
```
If the command succeeds, OneCLI is installed. Check if the gateway is reachable:
```bash
curl -sf http://127.0.0.1:10254/health
```
If both succeed, check for an Anthropic secret:
```bash
onecli secrets list
```
If an Anthropic secret exists, tell the user OneCLI is already configured and working. Use AskUserQuestion:
1. **Keep current setup** — description: "OneCLI is installed and has credentials configured. Nothing to do."
2. **Reconfigure** — description: "Start fresh — reinstall OneCLI and re-register credentials."
If they choose to keep, skip to Phase 5 (Verify). If they choose to reconfigure, continue.
### Check for native credential proxy
```bash
grep "credential-proxy" src/index.ts 2>/dev/null
```
If `startCredentialProxy` is imported, the native credential proxy skill is active. Tell the user: "You're currently using the native credential proxy (`.env`-based). This skill will switch you to OneCLI's Agent Vault, which adds per-agent policies and rate limits. Your `.env` credentials will be migrated to the vault."
Use AskUserQuestion:
1. **Continue** — description: "Switch to OneCLI Agent Vault."
2. **Cancel** — description: "Keep the native credential proxy."
If they cancel, stop.
### Check the codebase expects OneCLI
```bash
grep "@onecli-sh/sdk" package.json
```
If `@onecli-sh/sdk` is NOT in package.json, the codebase hasn't been updated to use OneCLI yet. Tell the user to run `/update-nanoclaw` first to get the OneCLI integration, then retry `/init-onecli`. Stop here.
## Phase 2: Install OneCLI
### Install the gateway and CLI
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify: `onecli version`
If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH:
```bash
export PATH="$HOME/.local/bin:$PATH"
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Re-verify with `onecli version`.
### Configure the CLI
Point the CLI at the local OneCLI instance:
```bash
onecli config set api-host http://127.0.0.1:10254
```
### Set ONECLI_URL in .env
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
### Wait for gateway readiness
The gateway may take a moment to start after installation. Poll for up to 15 seconds:
```bash
for i in $(seq 1 15); do
curl -sf http://127.0.0.1:10254/health && break
sleep 1
done
```
If it never becomes healthy, check if the gateway process is running:
```bash
ps aux | grep -i onecli | grep -v grep
```
If it's not running, try starting it manually: `onecli start`. If that fails, show the error and stop — the user needs to debug their OneCLI installation.
## Phase 3: Migrate existing credentials
### Scan .env for credentials to migrate
Read the `.env` file and look for these credential variables:
| .env variable | OneCLI secret type | Host pattern |
|---|---|---|
| `ANTHROPIC_API_KEY` | `anthropic` | `api.anthropic.com` |
| `CLAUDE_CODE_OAUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
| `ANTHROPIC_AUTH_TOKEN` | `anthropic` | `api.anthropic.com` |
Read `.env`:
```bash
cat .env
```
Parse the file for any of the credential variables listed above.
### If credentials found in .env
For each credential found, migrate it to OneCLI:
**Anthropic API key** (`ANTHROPIC_API_KEY=sk-ant-...`):
```bash
onecli secrets create --name Anthropic --type anthropic --value <key> --host-pattern api.anthropic.com
```
**Claude OAuth token** (`CLAUDE_CODE_OAUTH_TOKEN=...` or `ANTHROPIC_AUTH_TOKEN=...`):
```bash
onecli secrets create --name Anthropic --type anthropic --value <token> --host-pattern api.anthropic.com
```
After successful migration, remove the credential lines from `.env`. Use the Edit tool to remove only the credential variable lines (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_AUTH_TOKEN`). Keep all other `.env` entries intact (e.g. `ONECLI_URL`, `TELEGRAM_BOT_TOKEN`, channel tokens).
Verify the secret was registered:
```bash
onecli secrets list
```
Tell the user: "Migrated your Anthropic credentials from `.env` to the OneCLI Agent Vault. The raw keys have been removed from `.env` — they're now managed by OneCLI and will be injected at request time without entering containers."
### Offer to migrate other container-facing credentials
After handling Anthropic credentials (whether migrated or freshly registered), scan `.env` again for remaining credential variables that containers use for outbound API calls.
**Important:** Only migrate credentials that containers use via outbound HTTPS. Channel tokens (`TELEGRAM_BOT_TOKEN`, `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `DISCORD_BOT_TOKEN`) are used by the NanoClaw host process to connect to messaging platforms — they must stay in `.env`.
Known container-facing credentials:
| .env variable | Secret name | Host pattern |
|---|---|---|
| `OPENAI_API_KEY` | `OpenAI` | `api.openai.com` |
| `PARALLEL_API_KEY` | `Parallel` | `api.parallel.ai` |
If any of these are found with non-empty values, present them to the user:
AskUserQuestion (multiSelect): "These credentials are used by container agents for outbound API calls. Moving them to the vault means agents never see the raw keys, and you can apply rate limits and policies."
- One option per credential found (e.g., "OPENAI_API_KEY" — description: "Used by voice transcription and other OpenAI integrations inside containers")
- **Skip — keep them in .env** — description: "Leave these in .env for now. You can move them later."
For each credential the user selects:
```bash
onecli secrets create --name <SecretName> --type api_key --value <value> --host-pattern <host>
```
If there are credential variables not in the table above that look container-facing (i.e. not a channel token), ask the user: "Is `<VARIABLE_NAME>` used by agents inside containers? If so, what API host does it authenticate against? (e.g., `api.example.com`)" — then migrate accordingly.
After migration, remove the migrated lines from `.env` using the Edit tool. Keep channel tokens and any credentials the user chose not to migrate.
Verify all secrets were registered:
```bash
onecli secrets list
```
### If no credentials found in .env
No migration needed. Proceed to register credentials fresh.
Check if OneCLI already has an Anthropic secret:
```bash
onecli secrets list
```
If an Anthropic secret already exists, skip to Phase 4.
Otherwise, register credentials using the same flow as `/setup`:
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
#### Subscription path
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
Once they have the token, AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
#### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
#### After either path
Ask them to let you know when done.
**If the user's response happens to contain a token or key** (starts with `sk-ant-` or looks like a token): handle it gracefully — run the `onecli secrets create` command with that value on their behalf.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
## Phase 4: Build and restart
```bash
npm run build
```
If build fails, diagnose and fix. Common issue: `@onecli-sh/sdk` not installed — run `npm install` first.
Restart the service:
- macOS (launchd): `launchctl kickstart -k gui/$(id -u)/com.nanoclaw`
- Linux (systemd): `systemctl --user restart nanoclaw`
- WSL/manual: stop and re-run `bash start-nanoclaw.sh`
## Phase 5: Verify
Check logs for successful OneCLI integration:
```bash
tail -30 logs/nanoclaw.log | grep -i "onecli\|gateway"
```
Expected: `OneCLI gateway config applied` messages when containers start.
If the service is running and a channel is configured, tell the user to send a test message to verify the agent responds.
Tell the user:
- OneCLI Agent Vault is now managing credentials
- Agents never see raw API keys — credentials are injected at the gateway level
- To manage secrets: `onecli secrets list`, or open http://127.0.0.1:10254
- To add rate limits or policies: `onecli rules create --help`
## Troubleshooting
**"OneCLI gateway not reachable" in logs:** The gateway isn't running. Check with `curl -sf http://127.0.0.1:10254/health`. Start it with `onecli start` if needed.
**Container gets no credentials:** Verify `ONECLI_URL` is set in `.env` and the gateway has an Anthropic secret (`onecli secrets list`).
**Old .env credentials still present:** This skill should have removed them. Double-check `.env` for `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, or `ANTHROPIC_AUTH_TOKEN` and remove them manually if still present.
**Port 10254 already in use:** Another OneCLI instance may be running. Check with `lsof -i :10254` and kill the old process, or configure a different port.

View File

@@ -9,7 +9,7 @@ Run setup steps automatically. Only pause when user action is required (channel
**Principle:** When something is broken or missing, fix it. Don't tell the user to go fix it themselves unless it genuinely requires their manual action (e.g. authenticating a channel, pasting a secret token). If a dependency is missing, install it. If a service won't start, diagnose and repair. Ask the user for permission when needed, then do the work.
**UX Note:** Use `AskUserQuestion` for all user-facing questions.
**UX Note:** Use `AskUserQuestion` for multiple-choice questions only (e.g. "Docker or Apple Container?", "which channels?"). Do NOT use it when free-text input is needed (e.g. phone numbers, tokens, paths) — just ask the question in plain text and wait for the user's reply.
## 0. Git & Fork Setup
@@ -50,7 +50,7 @@ Already configured. Continue.
**Verify:** `git remote -v` should show `origin` → user's repo, `upstream``qwibitai/nanoclaw.git`.
## 1. Bootstrap (Node.js + Dependencies + OneCLI)
## 1. Bootstrap (Node.js + Dependencies)
Run `bash setup.sh` and parse the status block.
@@ -62,34 +62,6 @@ Run `bash setup.sh` and parse the status block.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
After bootstrap succeeds, install OneCLI and its CLI tool:
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
```bash
export PATH="$HOME/.local/bin:$PATH"
# Persist for future sessions (append to shell profile if not already present)
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Then re-verify with `onecli version`.
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
```bash
onecli config set api-host http://127.0.0.1:10254
```
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
## 2. Check Environment
Run `npx tsx setup/index.ts --step environment` and parse the status block.
@@ -98,6 +70,13 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
- If HAS_REGISTERED_GROUPS=true → note existing config, offer to skip or reconfigure
- Record APPLE_CONTAINER and DOCKER values for step 3
## 2a. Timezone
Run `npx tsx setup/index.ts --step timezone` and parse the status block.
- If NEEDS_USER_INPUT=true → The system timezone could not be autodetected (e.g. POSIX-style TZ like `IST-2`). AskUserQuestion: "What is your timezone?" with common options (America/New_York, Europe/London, Asia/Jerusalem, Asia/Tokyo) and an "Other" escape. Then re-run: `npx tsx setup/index.ts --step timezone -- --tz <their-answer>`.
- If STATUS=success → Timezone is configured. Note RESOLVED_TZ for reference.
## 3. Container Runtime
### 3a. Choose runtime
@@ -105,7 +84,10 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
- PLATFORM=linux → Docker (only option)
- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
- PLATFORM=macos + APPLE_CONTAINER=installed → AskUserQuestion with two options:
1. **Docker (recommended)** — description: "Cross-platform, better credential management, well-tested."
2. **Apple Container (experimental)** — description: "Native macOS runtime. Requires advanced setup."
If Apple Container, run `/convert-to-apple-container` now, then skip to 3c.
- PLATFORM=macos + APPLE_CONTAINER=not_found → Docker
### 3a-docker. Install Docker
@@ -140,9 +122,39 @@ Run `npx tsx setup/index.ts --step container -- --runtime <chosen>` and parse th
**If TEST_OK=false but BUILD_OK=true:** The image built but won't run. Check logs — common cause is runtime not fully started. Wait a moment and retry the test.
## 4. Anthropic Credentials via OneCLI
## 4. Credential System
NanoClaw uses OneCLI to manage credentials — API keys are never stored in `.env` or exposed to containers. The OneCLI gateway injects them at request time.
The credential system depends on the container runtime chosen in step 3.
### 4a. Docker → OneCLI
Install OneCLI and its CLI tool:
```bash
curl -fsSL onecli.sh/install | sh
curl -fsSL onecli.sh/cli/install | sh
```
Verify both installed: `onecli version`. If the command is not found, the CLI was likely installed to `~/.local/bin/`. Add it to PATH for the current session and persist it:
```bash
export PATH="$HOME/.local/bin:$PATH"
# Persist for future sessions (append to shell profile if not already present)
grep -q '.local/bin' ~/.bashrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
grep -q '.local/bin' ~/.zshrc 2>/dev/null || echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
```
Then re-verify with `onecli version`.
Point the CLI at the local OneCLI instance (it defaults to the cloud service otherwise):
```bash
onecli config set api-host http://127.0.0.1:10254
```
Ensure `.env` has the OneCLI URL (create the file if it doesn't exist):
```bash
grep -q 'ONECLI_URL' .env 2>/dev/null || echo 'ONECLI_URL=http://127.0.0.1:10254' >> .env
```
Check if a secret already exists:
```bash
@@ -156,16 +168,20 @@ AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. You'll run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
### Subscription path
#### Subscription path
Tell the user to run `claude setup-token` in another terminal and copy the token it outputs. Do NOT collect the token in chat.
Tell the user:
Once they have the token, they register it with OneCLI. AskUserQuestion with two options:
> Run `claude setup-token` in another terminal. It will output a token — copy it but don't paste it here.
Then stop and wait for the user to confirm they have the token. Do NOT proceed until they respond.
Once they confirm, they register it with OneCLI. AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI. Use type 'anthropic' and paste your token as the value."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_TOKEN --host-pattern api.anthropic.com`"
### API key path
#### API key path
Tell the user to get an API key from https://console.anthropic.com/settings/keys if they don't have one.
@@ -174,7 +190,7 @@ Then AskUserQuestion with two options:
1. **Dashboard** — description: "Best if you have a browser on this machine. Open http://127.0.0.1:10254 and add the secret in the UI."
2. **CLI** — description: "Best for remote/headless servers. Run: `onecli secrets create --name Anthropic --type anthropic --value YOUR_KEY --host-pattern api.anthropic.com`"
### After either path
#### After either path
Ask them to let you know when done.
@@ -182,6 +198,31 @@ Ask them to let you know when done.
**After user confirms:** verify with `onecli secrets list` that an Anthropic secret exists. If not, ask again.
### 4b. Apple Container → Native Credential Proxy
Apple Container is not compatible with OneCLI. The credential proxy code is already included in the apple-container branch — do NOT invoke `/use-native-credential-proxy` (it would conflict with already-applied code).
Instead, just configure the credentials in `.env`:
AskUserQuestion: Do you want to use your **Claude subscription** (Pro/Max) or an **Anthropic API key**?
1. **Claude subscription (Pro/Max)** — description: "Uses your existing Claude Pro or Max subscription. Run `claude setup-token` in another terminal to get your token."
2. **Anthropic API key** — description: "Pay-per-use API key from console.anthropic.com."
For subscription: tell the user to run `claude setup-token` in another terminal. Stop and wait for the user to confirm they have completed this step successfully before proceeding.
Once confirmed, add the token to `.env`:
```bash
echo 'CLAUDE_CODE_OAUTH_TOKEN=<their-token>' >> .env
```
For API key: add to `.env`:
```bash
echo 'ANTHROPIC_API_KEY=<their-key>' >> .env
```
Verify the proxy starts: `npm run dev` should show "Credential proxy listening" in the logs.
## 5. Set Up Channels
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
@@ -258,7 +299,7 @@ Run `npx tsx setup/index.ts --step verify` and parse the status block.
**If STATUS=failed, fix each:**
- SERVICE=stopped → `npm run build`, then restart: `launchctl kickstart -k gui/$(id -u)/com.nanoclaw` (macOS) or `systemctl --user restart nanoclaw` (Linux) or `bash start-nanoclaw.sh` (WSL nohup)
- SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4 (check `onecli secrets list` for Anthropic secret)
- CREDENTIALS=missing → re-run step 4 (Docker: check `onecli secrets list`; Apple Container: check `.env` for credentials)
- CHANNEL_AUTH shows `not_found` for any channel → re-invoke that channel's skill (e.g. `/add-telegram`)
- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
@@ -267,7 +308,7 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
## Troubleshooting
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), OneCLI not running (check `curl http://127.0.0.1:10254/api/health`), missing channel credentials (re-invoke channel skill).
**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), credential system not running (Docker: check `curl http://127.0.0.1:10254/api/health`; Apple Container: check `.env` credentials), missing channel credentials (re-invoke channel skill).
**Container agent fails ("Claude Code process exited with code 1"):** Ensure the container runtime is running — `open -a Docker` (macOS Docker), `container system start` (Apple Container), or `sudo systemctl start docker` (Linux). Check container logs in `groups/main/logs/container-*.log`.
@@ -280,4 +321,5 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/
## 9. Diagnostics
Send diagnostics data by following `.claude/skills/setup/diagnostics.md`.
1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`.
2. Follow every step in that file before completing setup.

View File

@@ -237,4 +237,5 @@ Tell the user:
## Diagnostics
Send diagnostics data by following `.claude/skills/update-nanoclaw/diagnostics.md`.
1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`.
2. Follow every step in that file before finishing.

View File

@@ -64,6 +64,16 @@ This merges in:
If the merge reports conflicts beyond `package-lock.json`, resolve them by reading the conflicted files and understanding the intent of both sides.
### Update main group CLAUDE.md
Replace the OneCLI auth reference with the native proxy:
In `groups/main/CLAUDE.md`, replace:
> OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
with:
> The native credential proxy manages credentials (including Anthropic auth) via `.env` — see `src/credential-proxy.ts`.
### Validate code changes
```bash

View File

@@ -8,12 +8,8 @@
import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import pino from 'pino';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } }
});
import { logger } from '../../../src/logger.js';
interface SkillResult {
success: boolean;

View File

@@ -1 +0,0 @@
OLLAMA_HOST=

View File

@@ -7,6 +7,7 @@ on:
jobs:
bump-version:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1

View File

@@ -1,160 +0,0 @@
name: Merge-forward skill branches
on:
push:
branches: [main]
permissions:
contents: write
issues: write
jobs:
merge-forward:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge main into each skill branch
id: merge
run: |
FAILED=""
SUCCEEDED=""
# List all remote skill branches
SKILL_BRANCHES=$(git branch -r --list 'origin/skill/*' | sed 's|origin/||' | xargs)
if [ -z "$SKILL_BRANCHES" ]; then
echo "No skill branches found."
exit 0
fi
for BRANCH in $SKILL_BRANCHES; do
SKILL_NAME=$(echo "$BRANCH" | sed 's|skill/||')
echo ""
echo "=== Processing $BRANCH ==="
# Checkout the skill branch
git checkout -B "$BRANCH" "origin/$BRANCH"
# Attempt merge
if ! git merge main --no-edit; then
echo "::warning::Merge conflict in $BRANCH"
git merge --abort
FAILED="$FAILED $SKILL_NAME"
continue
fi
# Check if there's anything new to push
if git diff --quiet "origin/$BRANCH"; then
echo "$BRANCH is already up to date with main."
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
continue
fi
# Install deps and validate
npm ci
if ! npm run build; then
echo "::warning::Build failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
if ! npm test 2>/dev/null; then
echo "::warning::Tests failed for $BRANCH"
git reset --hard "origin/$BRANCH"
FAILED="$FAILED $SKILL_NAME"
continue
fi
# Push the updated branch
git push origin "$BRANCH"
SUCCEEDED="$SUCCEEDED $SKILL_NAME"
echo "$BRANCH merged and pushed successfully."
done
echo ""
echo "=== Results ==="
echo "Succeeded: $SUCCEEDED"
echo "Failed: $FAILED"
# Export for issue creation
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
echo "succeeded=$SUCCEEDED" >> "$GITHUB_OUTPUT"
- name: Open issue for failed merges
if: steps.merge.outputs.failed != ''
uses: actions/github-script@v7
with:
script: |
const failed = '${{ steps.merge.outputs.failed }}'.trim().split(/\s+/);
const sha = context.sha.substring(0, 7);
const body = [
`The merge-forward workflow failed to merge \`main\` (${sha}) into the following skill branches:`,
'',
...failed.map(s => `- \`skill/${s}\`: merge conflict, build failure, or test failure`),
'',
'Please resolve manually:',
'```bash',
...failed.map(s => [
`git checkout skill/${s}`,
`git merge main`,
`# resolve conflicts, then: git push`,
''
]).flat(),
'```',
'',
`Triggered by push to main: ${context.sha}`
].join('\n');
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Merge-forward failed for ${failed.length} skill branch(es) after ${sha}`,
body,
labels: ['skill-maintenance']
});
- name: Notify channel forks
if: always()
uses: actions/github-script@v7
with:
github-token: ${{ secrets.FORK_DISPATCH_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const forks = [
'nanoclaw-whatsapp',
'nanoclaw-telegram',
'nanoclaw-discord',
'nanoclaw-slack',
'nanoclaw-gmail',
'nanoclaw-docker-sandboxes',
];
const sha = context.sha.substring(0, 7);
for (const repo of forks) {
try {
await github.rest.repos.createDispatchEvent({
owner: 'qwibitai',
repo,
event_type: 'upstream-main-updated',
client_payload: { sha: context.sha },
});
console.log(`Notified ${repo}`);
} catch (e) {
console.log(`Failed to notify ${repo}: ${e.message}`);
}
}

View File

@@ -8,6 +8,7 @@ on:
jobs:
update-tokens:
if: github.repository == 'qwibitai/nanoclaw'
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1

View File

@@ -2,7 +2,143 @@
All notable changes to NanoClaw will be documented in this file.
## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0)
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved).
- **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669)
## [1.2.35] - 2026-03-26
- [BREAKING] OneCLI Agent Vault replaces the built-in credential proxy. Existing `.env` credentials must be migrated to the vault. Run `/init-onecli` to install OneCLI and migrate credentials.
## [1.2.21] - 2026-03-22
- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again)
## [1.2.20] - 2026-03-21
- Added ESLint configuration with error-handling rules
## [1.2.19] - 2026-03-19
- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag)
## [1.2.18] - 2026-03-19
- User prompt content no longer logged on container errors — only input metadata
- Added Japanese README translation
## [1.2.17] - 2026-03-18
- Added `/capabilities` and `/status` container-agent skills
## [1.2.16] - 2026-03-18
- Tasks snapshot now refreshes immediately after IPC task mutations
## [1.2.15] - 2026-03-16
- Fixed remote-control prompt auto-accept to prevent immediate exit
- Added `KillMode=process` so remote-control survives service restarts
## [1.2.14] - 2026-03-14
- Added `/remote-control` command for host-level Claude Code access from within containers
## [1.2.13] - 2026-03-14
**Breaking:** Skills are now git branches, channels are separate fork repos.
- Skills live as `skill/*` git branches merged via `git merge`
- Added Docker Sandboxes support
- Fixed setup registration to use correct CLI commands
## [1.2.12] - 2026-03-08
- Added `/compact` skill for manual context compaction
- Enhanced container environment isolation via credential proxy
## [1.2.11] - 2026-03-08
- Added PDF reader, image vision, and WhatsApp reactions skills
- Fixed task container to close promptly when agent uses IPC-only messaging
## [1.2.10] - 2026-03-06
- Added `LIMIT` to unbounded message history queries for better performance
## [1.2.9] - 2026-03-06
- Agent prompts now include timezone context for accurate time references
## [1.2.8] - 2026-03-06
- Fixed misleading `send_message` tool description for scheduled tasks
## [1.2.7] - 2026-03-06
- Added `/add-ollama` skill for local model inference
- Added `update_task` tool and return task ID from `schedule_task`
## [1.2.6] - 2026-03-04
- Updated `claude-agent-sdk` to 0.2.68
## [1.2.5] - 2026-03-04
- CI formatting fix
## [1.2.4] - 2026-03-04
- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback
## [1.2.3] - 2026-03-04
- Added sender allowlist for per-chat access control
## [1.2.2] - 2026-03-04
- Added `/use-local-whisper` skill for local voice transcription
- Atomic task claims prevent scheduled tasks from executing twice
## [1.2.1] - 2026-03-02
- Version bump (no functional changes)
## [1.2.0] - 2026-03-02
**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add.
- Channel registry: channels self-register at startup via `registerChannel()` factory pattern
- `isMain` flag replaces folder-name-based main group detection
- `ENABLED_CHANNELS` removed — channels detected by credential presence
- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval
## [1.1.6] - 2026-03-01
- Added CJK font support for Chromium screenshots
## [1.1.5] - 2026-03-01
- Fixed wrapped WhatsApp message normalization
## [1.1.4] - 2026-03-01
- Added third-party model support
- Added `/update-nanoclaw` skill for syncing with upstream
## [1.1.3] - 2026-02-25
- Added `/add-slack` skill
- Restructured Gmail skill for new architecture
## [1.1.2] - 2026-02-24
- Improved error handling for WhatsApp Web version fetch
## [1.1.1] - 2026-02-24
- Added Qodo skills and codebase intelligence
- Fixed WhatsApp 405 connection failures
## [1.1.0] - 2026-02-23
- Added `/update` skill to pull upstream changes from within Claude Code
- Enhanced container environment isolation via credential proxy

View File

@@ -40,6 +40,7 @@ Four types of skills exist in NanoClaw. See [CONTRIBUTING.md](CONTRIBUTING.md) f
| `/customize` | Adding channels, integrations, changing behavior |
| `/debug` | Container issues, logs, troubleshooting |
| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install |
| `/init-onecli` | Install OneCLI Agent Vault and migrate `.env` credentials to it |
| `/qodo-pr-resolver` | Fetch and fix Qodo PR review issues interactively or in batch |
| `/get-qodo-rules` | Load org- and repo-level coding rules from Qodo before code tasks |

View File

@@ -13,3 +13,6 @@ Thanks to everyone who has contributed to NanoClaw!
- [baijunjie](https://github.com/baijunjie) — BaiJunjie
- [Michaelliv](https://github.com/Michaelliv) — Michael
- [kk17](https://github.com/kk17) — Kyle Zhike Chen
- [flobo3](https://github.com/flobo3) — Flo
- [edwinwzhe](https://github.com/edwinwzhe) — Edwin He
- [scottgl9](https://github.com/scottgl9) — Scott Glover

View File

@@ -8,6 +8,7 @@
<p align="center">
<a href="https://nanoclaw.dev">nanoclaw.dev</a>&nbsp;&nbsp;
<a href="https://docs.nanoclaw.dev">docs</a>&nbsp;&nbsp;
<a href="README_zh.md">中文</a>&nbsp;&nbsp;
<a href="README_ja.md">日本語</a>&nbsp;&nbsp;
<a href="https://discord.gg/VDdww8qS42"><img src="https://img.shields.io/discord/1470188214710046894?label=Discord&logo=discord&v=2" alt="Discord" valign="middle"></a>&nbsp;&nbsp;
@@ -71,6 +72,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
- **Scheduled tasks** - Recurring jobs that run Claude and can message you back
- **Web access** - Search and fetch content from the Web
- **Container isolation** - Agents are sandboxed in Docker (macOS/Linux), [Docker Sandboxes](docs/docker-sandboxes.md) (micro VM isolation), or Apple Container (macOS)
- **Credential security** - Agents never hold raw API keys. Outbound requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects credentials at request time and enforces per-agent policies and rate limits.
- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
@@ -121,7 +123,7 @@ Skills we'd like to see:
## Requirements
- macOS or Linux
- macOS, Linux, or Windows (via WSL2)
- Node.js 20+
- [Claude Code](https://claude.ai/download)
- [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux)
@@ -134,7 +136,7 @@ Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Respon
Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
For the full architecture details, see [docs/SPEC.md](docs/SPEC.md).
For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture).
Key files:
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
@@ -153,13 +155,13 @@ Key files:
Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM.
**Can I run this on Linux?**
**Can I run this on Linux or Windows?**
Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`.
Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`.
**Is this secure?**
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model.
Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. Credentials never enter the container — outbound API requests route through [OneCLI's Agent Vault](https://github.com/onecli/onecli), which injects authentication at the proxy level and supports rate limits and access policies. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model.
**Why no configuration files?**
@@ -203,7 +205,7 @@ Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes.
See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site.
## License

View File

@@ -1,7 +1,7 @@
# NanoClaw Agent Container
# Runs Claude Agent SDK in isolated Linux VM with browser automation
FROM node:24-slim
FROM node:22-slim
# Install system dependencies for Chromium
RUN apt-get update && apt-get install -y \

View File

@@ -16,6 +16,7 @@
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
@@ -27,6 +28,7 @@ interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
interface ContainerOutput {
@@ -469,6 +471,55 @@ async function runQuery(
return { newSessionId, lastAssistantUuid, closedDuringQuery };
}
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile('bash', [scriptPath], {
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
}, (error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
});
});
}
async function main(): Promise<void> {
let containerInput: ContainerInput;
@@ -510,6 +561,26 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n');
}
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined;
try {

View File

@@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'),
script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'),
},
async (args) => {
// Validate schedule_value before writing IPC
@@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
type: 'schedule_task',
taskId,
prompt: args.prompt,
script: args.script || undefined,
schedule_type: args.schedule_type,
schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group',
@@ -255,6 +257,7 @@ server.tool(
prompt: z.string().optional().describe('New prompt for the task'),
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'),
},
async (args) => {
// Validate schedule_value if provided
@@ -288,6 +291,7 @@ server.tool(
timestamp: new Date().toISOString(),
};
if (args.prompt !== undefined) data.prompt = args.prompt;
if (args.script !== undefined) data.script = args.script;
if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;

View File

@@ -0,0 +1,81 @@
# Branch & Fork Maintenance Guidelines
## Structure
**`qwibitai/nanoclaw`** (upstream) — core engine with skill definitions (`.claude/skills/`). No channel code on `main`.
**Channel forks** (`nanoclaw-whatsapp`, `nanoclaw-telegram`, `nanoclaw-slack`, etc.) — each fork = upstream + one channel's code applied. Users clone upstream, then merge a fork into their clone to add a channel.
**`skill/*` and `feat/*` branches on upstream** — add features unrelated to channels (e.g. `skill/compact`, `skill/apple-container`). Users merge these into their clone to add capabilities. Channel-specific skill branches that duplicate the forks (e.g. `skill/whatsapp`, `skill/telegram`) are legacy.
## How users add capabilities
```
user clones upstream main
├── merges nanoclaw-whatsapp fork → adds WhatsApp
├── merges skill/compact branch → adds /compact command
└── merges skill/apple-container → switches to Apple Container
```
## Merge directions
```
upstream main ──→ channel forks (forward merge to keep forks caught up)
upstream main ──→ skill branches (forward merge to keep branches caught up)
```
Forks and skill branches carry applied code changes. Users merge them into their own clones/forks to add capabilities. They are never merged back into upstream `main`.
## Forward merge procedure
```bash
# In your local nanoclaw checkout
git checkout main && git pull
# For a fork:
git fetch nanoclaw-whatsapp
git checkout -B whatsapp-merge nanoclaw-whatsapp/main
git merge main
# Resolve conflicts (see below)
# Remove upstream-only workflows (re-added by every merge since main has them):
git rm .github/workflows/bump-version.yml .github/workflows/update-tokens.yml 2>/dev/null
git push nanoclaw-whatsapp HEAD:main
git checkout main && git branch -D whatsapp-merge
# For a skill branch:
git checkout -B skill/compact origin/skill/compact
git merge main
# Resolve conflicts (see below)
git push origin skill/compact
git checkout main && git branch -D skill/compact
```
## Conflict resolution
The same files conflict every time:
| File | Resolution |
|------|------------|
| `package.json` | Take main's version + keep fork/branch-specific deps |
| `package-lock.json` | `git checkout main -- package-lock.json && npm install` |
| `.env.example` | Combine: main's entries + fork/branch-specific entries |
| `repo-tokens/badge.svg` | Take main's version (auto-generated) |
Source code changes (e.g. `src/types.ts`, `src/index.ts`) usually auto-merge cleanly, but can conflict if both sides modify the same lines. **Always build and test after every forward merge** — auto-merged code can be silently wrong (e.g. referencing a renamed function or using a removed parameter) even when git reports no conflicts.
## When to merge forward
After any main change that touches shared files (`package.json`, `src/index.ts`, `CLAUDE.md`, etc.). Small frequent merges = trivial conflicts. Large infrequent merges = painful.
## Fork setup
When creating a new channel fork:
1. Fork `nanoclaw` to `nanoclaw-{channel}`
2. Remove upstream-only workflows: `bump-version.yml`, `update-tokens.yml`
3. Add channel code, deps, env vars
4. Forward-merge main immediately to establish a clean baseline
## Dependencies
Forks and branches add their own deps on top of upstream's. When upstream adds or removes a dependency, verify that forks/branches still build after the next forward merge — transitive dependency changes can break downstream code.

View File

@@ -11,6 +11,34 @@ Both timers fire at the same time, so containers always exit via hard SIGKILL (c
### 3. Cursor advanced before agent succeeds
`processGroupMessages` advances `lastAgentTimestamp` before the agent runs. If the container times out, retries find no messages (cursor already past them). Messages are permanently lost on timeout.
### 4. Kubernetes image garbage collection deletes nanoclaw-agent image
**Symptoms**: `Container exited with code 125: pull access denied for nanoclaw-agent` — the container image disappears overnight or after a few hours, even though you just built it.
**Cause**: If your container runtime has Kubernetes enabled (Rancher Desktop enables it by default), the kubelet runs image garbage collection when disk usage exceeds 85%. NanoClaw containers are ephemeral (run and exit), so `nanoclaw-agent:latest` is never protected by a running container. The kubelet sees it as unused and deletes it — often overnight when no messages are being processed. Other images (docker-compose services) survive because they have long-running containers referencing them.
**Fix**: Disable Kubernetes if you don't need it:
```bash
# Rancher Desktop
rdctl set --kubernetes-enabled=false
# Then rebuild the container image
./container/build.sh
```
**Diagnosis**: Check the k3s log for image GC activity:
```bash
grep -i "nanoclaw" ~/Library/Logs/rancher-desktop/k3s.log
# Look for: "Removing image to free bytes" with the nanoclaw-agent image ID
```
Check NanoClaw logs for image status:
```bash
grep -E "image found|image NOT found|image missing" logs/nanoclaw.log
```
If you need Kubernetes enabled, set `CONTAINER_IMAGE` to an image stored in a registry that the kubelet won't GC, or raise the GC thresholds.
## Quick Status Check
```bash
@@ -19,16 +47,16 @@ launchctl list | grep nanoclaw
# Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed)
# 2. Any running containers?
container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 3. Any stopped/orphaned containers?
container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw
# 4. Recent errors in service log?
grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20
# 5. Is WhatsApp connected? (look for last connection event)
grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5
# 5. Are channels connected? (look for last connection event)
grep -E 'Connected|Connection closed|connection.*close|channel.*ready' logs/nanoclaw.log | tail -5
# 6. Are groups loaded?
grep 'groupCount' logs/nanoclaw.log | tail -3
@@ -77,7 +105,7 @@ grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10
## Agent Not Responding
```bash
# Check if messages are being received from WhatsApp
# Check if messages are being received from channels
grep 'New messages' logs/nanoclaw.log | tail -10
# Check if messages are being processed (container spawned)
@@ -107,10 +135,10 @@ sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups;
# Test-run a container to check mounts (dry run)
# Replace <group-folder> with the group's folder name
container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/
```
## WhatsApp Auth Issues
## Channel Auth Issues
```bash
# Check if QR code was requested (means auth expired)

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# NanoClaw Documentation
The official documentation is at **[docs.nanoclaw.dev](https://docs.nanoclaw.dev)**.
The files in this directory are original design documents and developer references. For the most current and accurate information, use the documentation site.
| This directory | Documentation site |
|---|---|
| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) |
| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) |
| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) |
| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) |
| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) |
| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) |
| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) |

View File

@@ -22,9 +22,9 @@ The entire codebase should be something you can read and understand. One Node.js
Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac.
### Built for One User
### Built for the Individual User
This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration.
This isn't a framework or a platform. It's software that fits each user's exact needs. You fork the repo, add the channels you want (WhatsApp, Telegram, Discord, Slack, Gmail), and end up with clean code that does exactly what you need.
### Customization = Code Changes
@@ -44,41 +44,31 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp.
## RFS (Request for Skills)
Skills we'd love contributors to build:
Skills we'd like to see contributed:
### Communication Channels
Skills to add or switch to different messaging platforms:
- `/add-telegram` - Add Telegram as an input channel
- `/add-slack` - Add Slack as an input channel
- `/add-discord` - Add Discord as an input channel
- `/add-sms` - Add SMS via Twilio or similar
- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely
- `/add-signal` - Add Signal as a channel
- `/add-matrix` - Add Matrix integration
### Container Runtime
The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container:
- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only)
### Platform Support
- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion)
- `/setup-windows` - Windows support via WSL2 + Docker
> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list.
---
## Vision
A personal Claude assistant accessible via WhatsApp, with minimal custom code.
A personal Claude assistant accessible via messaging, with minimal custom code.
**Core components:**
- **Claude Agent SDK** as the core agent
- **Containers** for isolated agent execution (Linux VMs)
- **WhatsApp** as the primary I/O channel
- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need
- **Persistent memory** per conversation and globally
- **Scheduled tasks** that run Claude and can message back
- **Web access** for search and browsing
- **Browser automation** via agent-browser
**Implementation approach:**
- Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers)
- Use existing tools (channel libraries, Claude Agent SDK, MCP servers)
- Minimal glue code
- File-based systems where possible (CLAUDE.md for memory, folders for groups)
@@ -87,7 +77,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
## Architecture Decisions
### Message Routing
- A router listens to WhatsApp and routes messages based on configuration
- A router listens to connected channels and routes messages based on configuration
- Only messages from registered groups are processed
- Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var
- Unregistered groups are ignored completely
@@ -136,10 +126,11 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
## Integration Points
### WhatsApp
- Using baileys library for WhatsApp Web connection
### Channels
- WhatsApp (baileys), Telegram (grammy), Discord (discord.js), Slack (@slack/bolt), Gmail (googleapis)
- Each channel lives in a separate fork repo and is added via skills (e.g., `/add-whatsapp`, `/add-telegram`)
- Messages stored in SQLite, polled by router
- QR code authentication during setup
- Channels self-register at startup — unconfigured channels are skipped with a warning
### Scheduler
- Built-in scheduler runs on the host, spawns containers for task execution
@@ -170,12 +161,12 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code.
- Each user gets a custom setup matching their exact needs
### Skills
- `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services
- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes)
- `/update` - Pull upstream changes, merge with customizations, run migrations
- `/setup` - Install dependencies, configure channels, start services
- `/customize` - General-purpose skill for adding capabilities
- `/update-nanoclaw` - Pull upstream changes, merge with customizations
### Deployment
- Runs on local Mac via launchd
- Runs on macOS (launchd), Linux (systemd), or Windows (WSL2)
- Single Node.js process handles everything
---

View File

@@ -7,7 +7,7 @@
| Main group | Trusted | Private self-chat, admin control |
| Non-main groups | Untrusted | Other users may be malicious |
| Container agents | Sandboxed | Isolated execution environment |
| WhatsApp messages | User input | Potential prompt injection |
| Incoming messages | User input | Potential prompt injection |
## Security Boundaries
@@ -64,20 +64,22 @@ Messages and task operations are verified against group identity:
| View all tasks | ✓ | Own only |
| Manage other groups | ✓ | ✗ |
### 5. Credential Isolation (Credential Proxy)
### 5. Credential Isolation (OneCLI Agent Vault)
Real API credentials **never enter containers**. Instead, the host runs an HTTP credential proxy that injects authentication headers transparently.
Real API credentials **never enter containers**. NanoClaw uses [OneCLI's Agent Vault](https://github.com/onecli/onecli) to proxy outbound requests and inject credentials at the gateway level.
**How it works:**
1. Host starts a credential proxy on `CREDENTIAL_PROXY_PORT` (default: 3001)
2. Containers receive `ANTHROPIC_BASE_URL=http://host.docker.internal:<port>` and `ANTHROPIC_API_KEY=placeholder`
3. The SDK sends API requests to the proxy with the placeholder key
4. The proxy strips placeholder auth, injects real credentials (`x-api-key` or `Authorization: Bearer`), and forwards to `api.anthropic.com`
5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc`
1. Credentials are registered once with `onecli secrets create`, stored and managed by OneCLI
2. When NanoClaw spawns a container, it calls `applyContainerConfig()` to route outbound HTTPS through the OneCLI gateway
3. The gateway matches requests by host and path, injects the real credential, and forwards
4. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc`
**Per-agent policies:**
Each NanoClaw group gets its own OneCLI agent identity. This allows different credential policies per group (e.g. your sales agent vs. support agent). OneCLI supports rate limits, and time-bound access and approval flows are on the roadmap.
**NOT Mounted:**
- WhatsApp session (`store/auth/`) - host only
- Mount allowlist - external, never mounted
- Channel auth sessions (`store/auth/`) host only
- Mount allowlist external, never mounted
- Any credentials matching blocked patterns
- `.env` is shadowed with `/dev/null` in the project root mount
@@ -97,7 +99,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP
```
┌──────────────────────────────────────────────────────────────────┐
│ UNTRUSTED ZONE │
WhatsApp Messages (potentially malicious) │
Incoming Messages (potentially malicious)
└────────────────────────────────┬─────────────────────────────────┘
▼ Trigger check, input escaping
@@ -107,7 +109,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP
│ • IPC authorization │
│ • Mount validation (external allowlist) │
│ • Container lifecycle │
│ • Credential proxy (injects auth headers)
│ • OneCLI Agent Vault (injects credentials, enforces policies)
└────────────────────────────────┬─────────────────────────────────┘
▼ Explicit mounts only, no secrets
@@ -116,7 +118,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP
│ • Agent execution │
│ • Bash commands (sandboxed) │
│ • File operations (limited to mounts) │
│ • API calls routed through credential proxy
│ • API calls routed through OneCLI Agent Vault
│ • No real credentials in environment or filesystem │
└──────────────────────────────────────────────────────────────────┘
```

View File

@@ -74,3 +74,42 @@ No `##` headings. No `[links](url)`. No `**double stars**`.
### Discord channels (folder starts with `discord_`)
Standard Markdown works: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency

View File

@@ -77,6 +77,10 @@ Standard Markdown: `**bold**`, `*italic*`, `[links](url)`, `# headings`.
This is the **main channel**, which has elevated privileges.
## Authentication
Anthropic credentials must be either an API key from console.anthropic.com (`ANTHROPIC_API_KEY`) or a long-lived OAuth token from `claude setup-token` (`CLAUDE_CODE_OAUTH_TOKEN`). Short-lived tokens from the system keychain or `~/.claude/.credentials.json` expire within hours and can cause recurring container 401s. The `/setup` skill walks through this. OneCLI manages credentials (including Anthropic auth) — run `onecli --help`.
## Container Mounts
Main has read-only access to the project and read-write access to its group folder:
@@ -262,3 +266,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory.
---
## Task Scripts
For any recurring task, use `schedule_task`. Frequent agent invocations — especially multiple times a day — consume API credits and can risk account restrictions. If a simple check can determine whether action is needed, add a `script` — it runs first, and the agent is only called when the check passes. This keeps invocations to a minimum.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency

721
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "1.2.23",
"version": "1.2.42",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
@@ -22,18 +22,13 @@
},
"dependencies": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
"better-sqlite3": "11.10.0",
"cron-parser": "5.5.0"
},
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.35.0",
"eslint-plugin-no-catch-all": "^1.1.0",
"globals": "^15.12.0",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="39.9k tokens, 20% of context window">
<title>39.9k tokens, 20% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="42.4k tokens, 21% of context window">
<title>42.4k tokens, 21% 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"/>
@@ -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="74" y="15" fill="#010101" fill-opacity=".3">39.9k</text>
<text x="74" y="14">39.9k</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">42.4k</text>
<text x="74" y="14">42.4k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -9,6 +9,7 @@ const STEPS: Record<
string,
() => Promise<{ run: (args: string[]) => Promise<void> }>
> = {
timezone: () => import('./timezone.js'),
environment: () => import('./environment.js'),
container: () => import('./container.js'),
groups: () => import('./groups.js'),

View File

@@ -10,21 +10,23 @@ import { logger } from '../src/logger.js';
import { isRoot } from './platform.js';
import { emitStatus } from './status.js';
function parseArgs(args: string[]): { empty: boolean; json: string } {
function parseArgs(args: string[]): { empty: boolean; json: string; force: boolean } {
let empty = false;
let json = '';
let force = false;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--empty') empty = true;
if (args[i] === '--force') force = true;
if (args[i] === '--json' && args[i + 1]) {
json = args[i + 1];
i++;
}
}
return { empty, json };
return { empty, json, force };
}
export async function run(args: string[]): Promise<void> {
const { empty, json } = parseArgs(args);
const { empty, json, force } = parseArgs(args);
const homeDir = os.homedir();
const configDir = path.join(homeDir, '.config', 'nanoclaw');
const configFile = path.join(configDir, 'mount-allowlist.json');
@@ -37,6 +39,21 @@ export async function run(args: string[]): Promise<void> {
fs.mkdirSync(configDir, { recursive: true });
if (fs.existsSync(configFile) && !force) {
logger.info(
{ configFile },
'Mount allowlist already exists — skipping (use --force to overwrite)',
);
emitStatus('CONFIGURE_MOUNTS', {
PATH: configFile,
ALLOWED_ROOTS: 0,
NON_MAIN_READ_ONLY: 'unknown',
STATUS: 'skipped',
LOG: 'logs/setup.log',
});
return;
}
let allowedRoots = 0;
let nonMainReadOnly = 'true';

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
@@ -6,7 +9,7 @@ import Database from 'better-sqlite3';
* Tests for the register step.
*
* Verifies: parameterized SQL (no injection), file templating,
* apostrophe in names, .env updates.
* apostrophe in names, .env updates, CLAUDE.md template copy.
*/
function createTestDb(): Database.Database {
@@ -255,3 +258,207 @@ describe('file templating', () => {
expect(envContent).toContain('ASSISTANT_NAME="Nova"');
});
});
describe('CLAUDE.md template copy', () => {
let tmpDir: string;
let groupsDir: string;
// Replicates register.ts template copy + name update logic
function simulateRegister(
folder: string,
isMain: boolean,
assistantName = 'Andy',
): void {
const folderDir = path.join(groupsDir, folder);
fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true });
// Template copy — never overwrite existing (register.ts lines 119-135)
const dest = path.join(folderDir, 'CLAUDE.md');
if (!fs.existsSync(dest)) {
const templatePath = isMain
? path.join(groupsDir, 'main', 'CLAUDE.md')
: path.join(groupsDir, 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, dest);
}
}
// Name update across all groups (register.ts lines 140-165)
if (assistantName !== 'Andy') {
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
let content = fs.readFileSync(mdFile, 'utf-8');
content = content.replace(/^# Andy$/m, `# ${assistantName}`);
content = content.replace(
/You are Andy/g,
`You are ${assistantName}`,
);
fs.writeFileSync(mdFile, content);
}
}
}
function readGroupMd(folder: string): string {
return fs.readFileSync(
path.join(groupsDir, folder, 'CLAUDE.md'),
'utf-8',
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-'));
groupsDir = path.join(tmpDir, 'groups');
fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true });
fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true });
fs.writeFileSync(
path.join(groupsDir, 'main', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.',
);
fs.writeFileSync(
path.join(groupsDir, 'global', 'CLAUDE.md'),
'# Andy\n\nYou are Andy, a personal assistant.',
);
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('copies global template for non-main group', () => {
simulateRegister('telegram_dev-team', false);
const content = readGroupMd('telegram_dev-team');
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
});
it('copies main template for main group', () => {
simulateRegister('whatsapp_main', true);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('each channel can have its own main with admin context', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_main', true);
simulateRegister('discord_main', true);
for (const folder of [
'whatsapp_main',
'telegram_main',
'slack_main',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('Admin Context');
expect(content).toContain('You are Andy');
}
});
it('non-main groups across channels get global template', () => {
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_friends', false);
simulateRegister('slack_engineering', false);
simulateRegister('discord_general', false);
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
for (const folder of [
'telegram_friends',
'slack_engineering',
'discord_general',
]) {
const content = readGroupMd(folder);
expect(content).toContain('You are Andy');
expect(content).not.toContain('Admin Context');
}
});
it('custom name propagates to all channels and groups', () => {
// Register multiple channels, last one sets custom name
simulateRegister('whatsapp_main', true);
simulateRegister('telegram_main', true);
simulateRegister('slack_devs', false);
// Final registration triggers name update across all
simulateRegister('discord_main', true, 'Luna');
for (const folder of [
'main',
'global',
'whatsapp_main',
'telegram_main',
'slack_devs',
'discord_main',
]) {
const content = readGroupMd(folder);
expect(content).toContain('# Luna');
expect(content).toContain('You are Luna');
expect(content).not.toContain('Andy');
}
});
it('never overwrites existing CLAUDE.md on re-registration', () => {
simulateRegister('slack_main', true);
// User customizes the file extensively (persona, workspace, rules)
const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Gambi\n\nCustom persona with workspace rules and family context.',
);
// Re-registering same folder (e.g. re-running /add-slack)
simulateRegister('slack_main', true);
const content = readGroupMd('slack_main');
expect(content).toContain('Custom persona');
expect(content).not.toContain('Admin Context');
});
it('never overwrites when non-main becomes main (isMain changes)', () => {
// User registers a family group as non-main
simulateRegister('whatsapp_casa', false);
// User extensively customizes it (PARA system, task management, etc.)
const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md');
fs.writeFileSync(
mdPath,
'# Casa\n\nFamily group with PARA system, task management, shopping lists.',
);
// Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved
simulateRegister('whatsapp_casa', true);
const content = readGroupMd('whatsapp_casa');
expect(content).toContain('PARA system');
expect(content).not.toContain('Admin Context');
});
it('preserves custom CLAUDE.md across channels when changing main', () => {
// Real-world scenario: WhatsApp main + customized Discord research channel
simulateRegister('whatsapp_main', true);
simulateRegister('discord_main', false);
const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md');
fs.writeFileSync(
discordPath,
'# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.',
);
// Discord becomes main too — custom content must survive
simulateRegister('discord_main', true);
expect(readGroupMd('discord_main')).toContain('Research Assistant');
// WhatsApp main also untouched
expect(readGroupMd('whatsapp_main')).toContain('Admin Context');
});
it('handles missing templates gracefully', () => {
fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md'));
fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md'));
simulateRegister('discord_general', false);
expect(
fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')),
).toBe(false);
});
});

View File

@@ -116,6 +116,30 @@ export async function run(args: string[]): Promise<void> {
recursive: true,
});
// Create CLAUDE.md in the new group folder from template if it doesn't exist.
// The agent runs with CWD=/workspace/group and loads CLAUDE.md from there.
// Never overwrite an existing CLAUDE.md — users customize these extensively
// (persona, workspace structure, communication rules, family context, etc.)
// and a stock template replacement would destroy that work.
const groupClaudeMdPath = path.join(
projectRoot,
'groups',
parsed.folder,
'CLAUDE.md',
);
if (!fs.existsSync(groupClaudeMdPath)) {
const templatePath = parsed.isMain
? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md')
: path.join(projectRoot, 'groups', 'global', 'CLAUDE.md');
if (fs.existsSync(templatePath)) {
fs.copyFileSync(templatePath, groupClaudeMdPath);
logger.info(
{ file: groupClaudeMdPath, template: templatePath },
'Created CLAUDE.md from template',
);
}
}
// Update assistant name in CLAUDE.md files if different from default
let nameUpdated = false;
if (parsed.assistantName !== 'Andy') {
@@ -124,10 +148,11 @@ export async function run(args: string[]): Promise<void> {
'Updating assistant name',
);
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
];
const groupsDir = path.join(projectRoot, 'groups');
const mdFiles = fs
.readdirSync(groupsDir)
.map((d) => path.join(groupsDir, d, 'CLAUDE.md'))
.filter((f) => fs.existsSync(f));
for (const mdFile of mdFiles) {
if (fs.existsSync(mdFile)) {

View File

@@ -266,6 +266,20 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
// Kill orphaned nanoclaw processes to avoid channel connection conflicts
killOrphanedProcesses(projectRoot);
// Enable lingering so the user service survives SSH logout.
// Without linger, systemd terminates all user processes when the last session closes.
if (!runningAsRoot) {
try {
execSync('loginctl enable-linger', { stdio: 'ignore' });
logger.info('Enabled loginctl linger for current user');
} catch (err) {
logger.warn(
{ err },
'loginctl enable-linger failed — service may stop on SSH logout',
);
}
}
// Enable and start
try {
execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' });
@@ -301,6 +315,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
UNIT_PATH: unitPath,
SERVICE_LOADED: serviceLoaded,
...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}),
LINGER_ENABLED: !runningAsRoot,
STATUS: 'success',
LOG: 'logs/setup.log',
});

67
setup/timezone.ts Normal file
View File

@@ -0,0 +1,67 @@
/**
* Step: timezone — Detect, validate, and persist the user's timezone.
* Writes TZ to .env if a valid IANA timezone is resolved.
* Emits NEEDS_USER_INPUT=true when autodetection fails.
*/
import fs from 'fs';
import path from 'path';
import { isValidTimezone } from '../src/timezone.js';
import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
export async function run(args: string[]): Promise<void> {
const projectRoot = process.cwd();
const envFile = path.join(projectRoot, '.env');
// Check what's already in .env
let envFileTz: string | undefined;
if (fs.existsSync(envFile)) {
const content = fs.readFileSync(envFile, 'utf-8');
const match = content.match(/^TZ=(.+)$/m);
if (match) envFileTz = match[1].trim().replace(/^["']|["']$/g, '');
}
const systemTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
const envTz = process.env.TZ;
// Accept --tz flag from CLI (used when setup skill collects from user)
const tzFlagIdx = args.indexOf('--tz');
const userTz = tzFlagIdx !== -1 ? args[tzFlagIdx + 1] : undefined;
// Resolve: user-provided > .env > process.env > system autodetect
let resolvedTz: string | undefined;
for (const candidate of [userTz, envFileTz, envTz, systemTz]) {
if (candidate && isValidTimezone(candidate)) {
resolvedTz = candidate;
break;
}
}
const needsUserInput = !resolvedTz;
if (resolvedTz && resolvedTz !== envFileTz) {
// Write/update TZ in .env
if (fs.existsSync(envFile)) {
let content = fs.readFileSync(envFile, 'utf-8');
if (/^TZ=/m.test(content)) {
content = content.replace(/^TZ=.*$/m, `TZ=${resolvedTz}`);
} else {
content = content.trimEnd() + `\nTZ=${resolvedTz}\n`;
}
fs.writeFileSync(envFile, content);
} else {
fs.writeFileSync(envFile, `TZ=${resolvedTz}\n`);
}
logger.info({ timezone: resolvedTz }, 'Set TZ in .env');
}
emitStatus('TIMEZONE', {
SYSTEM_TZ: systemTz || 'unknown',
ENV_TZ: envTz || 'unset',
ENV_FILE_TZ: envFileTz || 'unset',
RESOLVED_TZ: resolvedTz || 'none',
NEEDS_USER_INPUT: needsUserInput,
STATUS: needsUserInput ? 'needs_input' : 'success',
});
}

View File

@@ -2,12 +2,14 @@ import os from 'os';
import path from 'path';
import { readEnvFile } from './env.js';
import { isValidTimezone } from './timezone.js';
// Read config values from .env (falls back to process.env).
const envConfig = readEnvFile([
'ASSISTANT_NAME',
'ASSISTANT_HAS_OWN_NUMBER',
'ONECLI_URL',
'TZ',
]);
export const ASSISTANT_NAME =
@@ -51,6 +53,10 @@ export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(
); // 10MB default
export const ONECLI_URL =
process.env.ONECLI_URL || envConfig.ONECLI_URL || 'http://localhost:10254';
export const MAX_MESSAGES_PER_PROMPT = Math.max(
1,
parseInt(process.env.MAX_MESSAGES_PER_PROMPT || '10', 10) || 10,
);
export const IPC_POLL_INTERVAL = 1000;
export const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '1800000', 10); // 30min default — how long to keep container alive after last result
export const MAX_CONCURRENT_CONTAINERS = Math.max(
@@ -62,12 +68,30 @@ function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export const TRIGGER_PATTERN = new RegExp(
`^@${escapeRegex(ASSISTANT_NAME)}\\b`,
'i',
);
export function buildTriggerPattern(trigger: string): RegExp {
return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i');
}
// Timezone for scheduled tasks (cron expressions, etc.)
// Uses system timezone by default
export const TIMEZONE =
process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone;
export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`;
export function getTriggerPattern(trigger?: string): RegExp {
const normalizedTrigger = trigger?.trim();
return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER);
}
export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER);
// Timezone for scheduled tasks, message formatting, etc.
// Validates each candidate is a real IANA identifier before accepting.
function resolveConfigTimezone(): string {
const candidates = [
process.env.TZ,
envConfig.TZ,
Intl.DateTimeFormat().resolvedOptions().timeZone,
];
for (const tz of candidates) {
if (tz && isValidTimezone(tz)) return tz;
}
return 'UTC';
}
export const TIMEZONE = resolveConfigTimezone();

View File

@@ -51,6 +51,14 @@ vi.mock('./mount-security.js', () => ({
validateAdditionalMounts: vi.fn(() => []),
}));
// Mock container-runtime
vi.mock('./container-runtime.js', () => ({
CONTAINER_RUNTIME_BIN: 'docker',
hostGatewayArgs: () => [],
readonlyMountArgs: (h: string, c: string) => ['-v', `${h}:${c}:ro`],
stopContainer: vi.fn(),
}));
// Mock OneCLI SDK
vi.mock('@onecli-sh/sdk', () => ({
OneCLI: class {

View File

@@ -2,7 +2,7 @@
* Container Runner for NanoClaw
* Spawns agent execution in containers and handles IPC
*/
import { ChildProcess, exec, spawn } from 'child_process';
import { ChildProcess, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
@@ -42,6 +42,7 @@ export interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
export interface ContainerOutput {
@@ -191,8 +192,17 @@ function buildVolumeMounts(
group.folder,
'agent-runner-src',
);
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
if (fs.existsSync(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) &&
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
}
mounts.push({
hostPath: groupAgentRunnerDir,
@@ -426,15 +436,15 @@ export async function runContainerAgent(
{ group: group.name, containerName },
'Container timeout, stopping gracefully',
);
exec(stopContainer(containerName), { timeout: 15000 }, (err) => {
if (err) {
logger.warn(
{ group: group.name, containerName, err },
'Graceful stop failed, force killing',
);
container.kill('SIGKILL');
}
});
try {
stopContainer(containerName);
} catch (err) {
logger.warn(
{ group: group.name, containerName, err },
'Graceful stop failed, force killing',
);
container.kill('SIGKILL');
}
};
let timeout = setTimeout(killOnTimeout, timeoutMs);
@@ -672,6 +682,7 @@ export function writeTasksSnapshot(
id: string;
groupFolder: string;
prompt: string;
script?: string | null;
schedule_type: string;
schedule_value: string;
status: string;

View File

@@ -39,11 +39,20 @@ describe('readonlyMountArgs', () => {
});
describe('stopContainer', () => {
it('returns stop command using CONTAINER_RUNTIME_BIN', () => {
expect(stopContainer('nanoclaw-test-123')).toBe(
it('calls docker stop for valid container names', () => {
stopContainer('nanoclaw-test-123');
expect(mockExecSync).toHaveBeenCalledWith(
`${CONTAINER_RUNTIME_BIN} stop -t 1 nanoclaw-test-123`,
{ stdio: 'pipe' },
);
});
it('rejects names with shell metacharacters', () => {
expect(() => stopContainer('foo; rm -rf /')).toThrow('Invalid container name');
expect(() => stopContainer('foo$(whoami)')).toThrow('Invalid container name');
expect(() => stopContainer('foo`id`')).toThrow('Invalid container name');
expect(mockExecSync).not.toHaveBeenCalled();
});
});
// --- ensureContainerRuntimeRunning ---

View File

@@ -27,9 +27,12 @@ export function readonlyMountArgs(
return ['-v', `${hostPath}:${containerPath}:ro`];
}
/** Returns the shell command to stop a container by name. */
export function stopContainer(name: string): string {
return `${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`;
/** Stop a container by name. Uses execFileSync to avoid shell injection. */
export function stopContainer(name: string): void {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
throw new Error(`Invalid container name: ${name}`);
}
execSync(`${CONTAINER_RUNTIME_BIN} stop -t 1 ${name}`, { stdio: 'pipe' });
}
/** Ensure the container runtime is running, starting it if needed. */
@@ -82,7 +85,7 @@ export function cleanupOrphans(): void {
const orphans = output.trim().split('\n').filter(Boolean);
for (const name of orphans) {
try {
execSync(stopContainer(name), { stdio: 'pipe' });
stopContainer(name);
} catch {
/* already stopped */
}

67
src/db-migration.test.ts Normal file
View File

@@ -0,0 +1,67 @@
import Database from 'better-sqlite3';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { describe, expect, it, vi } from 'vitest';
describe('database migrations', () => {
it('defaults Telegram backfill chats to direct messages', async () => {
const repoRoot = process.cwd();
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-db-test-'));
try {
process.chdir(tempDir);
fs.mkdirSync(path.join(tempDir, 'store'), { recursive: true });
const dbPath = path.join(tempDir, 'store', 'messages.db');
const legacyDb = new Database(dbPath);
legacyDb.exec(`
CREATE TABLE chats (
jid TEXT PRIMARY KEY,
name TEXT,
last_message_time TEXT
);
`);
legacyDb
.prepare(
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
)
.run('tg:12345', 'Telegram DM', '2024-01-01T00:00:00.000Z');
legacyDb
.prepare(
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
)
.run('tg:-10012345', 'Telegram Group', '2024-01-01T00:00:01.000Z');
legacyDb
.prepare(
`INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?)`,
)
.run('room@g.us', 'WhatsApp Group', '2024-01-01T00:00:02.000Z');
legacyDb.close();
vi.resetModules();
const { initDatabase, getAllChats, _closeDatabase } =
await import('./db.js');
initDatabase();
const chats = getAllChats();
expect(chats.find((chat) => chat.jid === 'tg:12345')).toMatchObject({
channel: 'telegram',
is_group: 0,
});
expect(chats.find((chat) => chat.jid === 'tg:-10012345')).toMatchObject({
channel: 'telegram',
is_group: 0,
});
expect(chats.find((chat) => chat.jid === 'room@g.us')).toMatchObject({
channel: 'whatsapp',
is_group: 1,
});
_closeDatabase();
} finally {
process.chdir(repoRoot);
}
});
});

View File

@@ -6,6 +6,7 @@ import {
deleteTask,
getAllChats,
getAllRegisteredGroups,
getLastBotMessageTimestamp,
getMessagesSince,
getNewMessages,
getTaskById,
@@ -14,6 +15,7 @@ import {
storeMessage,
updateTask,
} from './db.js';
import { formatMessages } from './router.js';
beforeEach(() => {
_initTestDatabase();
@@ -208,6 +210,92 @@ describe('getMessagesSince', () => {
expect(msgs).toHaveLength(3);
});
it('recovers cursor from last bot reply when lastAgentTimestamp is missing', () => {
// beforeEach already inserts m3 (bot reply at 00:00:03) and m4 (user at 00:00:04)
// Add more old history before the bot reply
for (let i = 1; i <= 50; i++) {
store({
id: `history-${i}`,
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `old message ${i}`,
timestamp: `2023-06-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
// New message after the bot reply (m3 at 00:00:03)
store({
id: 'new-1',
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: 'new message after bot reply',
timestamp: '2024-01-02T00:00:00.000Z',
});
// Recover cursor from the last bot message (m3 from beforeEach)
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
// Using recovered cursor: only gets messages after the bot reply
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
// m4 (third, 00:00:04) + new-1 — skips all 50 old messages and m1/m2
expect(msgs).toHaveLength(2);
expect(msgs[0].content).toBe('third');
expect(msgs[1].content).toBe('new message after bot reply');
});
it('caps messages to configured limit even with recovered cursor', () => {
// beforeEach inserts m3 (bot at 00:00:03). Add 30 messages after it.
for (let i = 1; i <= 30; i++) {
store({
id: `pending-${i}`,
chat_jid: 'group@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `pending message ${i}`,
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
const recovered = getLastBotMessageTimestamp('group@g.us', 'Andy');
expect(recovered).toBe('2024-01-01T00:00:03.000Z');
// With limit=10, only the 10 most recent are returned
const msgs = getMessagesSince('group@g.us', recovered!, 'Andy', 10);
expect(msgs).toHaveLength(10);
// Most recent 10: pending-21 through pending-30
expect(msgs[0].content).toBe('pending message 21');
expect(msgs[9].content).toBe('pending message 30');
});
it('returns last N messages when no bot reply and no cursor exist', () => {
// Use a fresh group with no bot messages
storeChatMetadata('fresh@g.us', '2024-01-01T00:00:00.000Z');
for (let i = 1; i <= 20; i++) {
store({
id: `fresh-${i}`,
chat_jid: 'fresh@g.us',
sender: 'user@s.whatsapp.net',
sender_name: 'User',
content: `message ${i}`,
timestamp: `2024-02-${String(i).padStart(2, '0')}T12:00:00.000Z`,
});
}
const recovered = getLastBotMessageTimestamp('fresh@g.us', 'Andy');
expect(recovered).toBeUndefined();
// No cursor → sinceTimestamp = '' but limit caps the result
const msgs = getMessagesSince('fresh@g.us', '', 'Andy', 10);
expect(msgs).toHaveLength(10);
const prompt = formatMessages(msgs, 'Asia/Jerusalem');
const messageTagCount = (prompt.match(/<message /g) || []).length;
expect(messageTagCount).toBe(10);
});
it('filters pre-migration bot messages via content prefix backstop', () => {
// Simulate a message written before migration: has prefix but is_bot_message = 0
store({

View File

@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
// Add script column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
} catch {
/* column already exists */
}
// Add is_bot_message column if it doesn't exist (migration for existing DBs)
try {
database.exec(
@@ -134,7 +141,7 @@ function createSchema(database: Database.Database): void {
`UPDATE chats SET channel = 'discord', is_group = 1 WHERE jid LIKE 'dc:%'`,
);
database.exec(
`UPDATE chats SET channel = 'telegram', is_group = 1 WHERE jid LIKE 'tg:%'`,
`UPDATE chats SET channel = 'telegram', is_group = 0 WHERE jid LIKE 'tg:%'`,
);
} catch {
/* columns already exist */
@@ -158,6 +165,11 @@ export function _initTestDatabase(): void {
createSchema(db);
}
/** @internal - for tests only. */
export function _closeDatabase(): void {
db.close();
}
/**
* Store chat metadata only (no message content).
* Used for all chats to enable group discovery without storing sensitive content.
@@ -363,19 +375,33 @@ export function getMessagesSince(
.all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
}
export function getLastBotMessageTimestamp(
chatJid: string,
botPrefix: string,
): string | undefined {
const row = db
.prepare(
`SELECT MAX(timestamp) as ts FROM messages
WHERE chat_jid = ? AND (is_bot_message = 1 OR content LIKE ?)`,
)
.get(chatJid, `${botPrefix}:%`) as { ts: string | null } | undefined;
return row?.ts ?? undefined;
}
export function createTask(
task: Omit<ScheduledTask, 'last_run' | 'last_result'>,
): void {
db.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.group_folder,
task.chat_jid,
task.prompt,
task.script || null,
task.schedule_type,
task.schedule_value,
task.context_mode || 'isolated',
@@ -410,7 +436,12 @@ export function updateTask(
updates: Partial<
Pick<
ScheduledTask,
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'
| 'prompt'
| 'script'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
>
>,
): void {
@@ -421,6 +452,10 @@ export function updateTask(
fields.push('prompt = ?');
values.push(updates.prompt);
}
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?');
values.push(updates.schedule_type);

View File

@@ -30,8 +30,9 @@ export function readEnvFile(keys: string[]): Record<string, string> {
if (!wanted.has(key)) continue;
let value = trimmed.slice(eqIdx + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
value.length >= 2 &&
((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'")))
) {
value = value.slice(1, -1);
}

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js';
import {
ASSISTANT_NAME,
getTriggerPattern,
TRIGGER_PATTERN,
} from './config.js';
import {
escapeXml,
formatMessages,
@@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => {
});
});
describe('getTriggerPattern', () => {
it('uses the configured per-group trigger when provided', () => {
const pattern = getTriggerPattern('@Claw');
expect(pattern.test('@Claw hello')).toBe(true);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false);
});
it('falls back to the default trigger when group trigger is missing', () => {
const pattern = getTriggerPattern(undefined);
expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true);
});
it('treats regex characters in custom triggers literally', () => {
const pattern = getTriggerPattern('@C.L.A.U.D.E');
expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true);
expect(pattern.test('@CXLXAUXDXE hello')).toBe(false);
});
});
// --- Outbound formatting (internal tag stripping + prefix) ---
describe('stripInternalTags', () => {
@@ -207,7 +233,7 @@ describe('formatOutbound', () => {
describe('trigger gating (requiresTrigger interaction)', () => {
// Replicates the exact logic from processGroupMessages and startMessageLoop:
// if (!isMainGroup && group.requiresTrigger !== false) { check trigger }
// if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger }
function shouldRequireTrigger(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
@@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => {
function shouldProcess(
isMainGroup: boolean,
requiresTrigger: boolean | undefined,
trigger: string | undefined,
messages: NewMessage[],
): boolean {
if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true;
return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim()));
const triggerPattern = getTriggerPattern(trigger);
return messages.some((m) => triggerPattern.test(m.content.trim()));
}
it('main group always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, undefined, msgs)).toBe(true);
expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true);
});
it('main group processes even with requiresTrigger=true', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(true, true, msgs)).toBe(true);
expect(shouldProcess(true, true, undefined, msgs)).toBe(true);
});
it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, undefined, msgs)).toBe(false);
expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true requires trigger', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, true, msgs)).toBe(false);
expect(shouldProcess(false, true, undefined, msgs)).toBe(false);
});
it('non-main group with requiresTrigger=true processes when trigger present', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, msgs)).toBe(true);
expect(shouldProcess(false, true, undefined, msgs)).toBe(true);
});
it('non-main group uses its per-group trigger instead of the default trigger', () => {
const msgs = [makeMsg({ content: '@Claw do something' })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true);
});
it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => {
const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })];
expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false);
});
it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => {
const msgs = [makeMsg({ content: 'hello no trigger' })];
expect(shouldProcess(false, false, msgs)).toBe(true);
expect(shouldProcess(false, false, undefined, msgs)).toBe(true);
});
});

View File

@@ -5,11 +5,14 @@ import { OneCLI } from '@onecli-sh/sdk';
import {
ASSISTANT_NAME,
DEFAULT_TRIGGER,
getTriggerPattern,
GROUPS_DIR,
IDLE_TIMEOUT,
MAX_MESSAGES_PER_PROMPT,
ONECLI_URL,
POLL_INTERVAL,
TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import './channels/index.js';
import {
@@ -32,6 +35,7 @@ import {
getAllSessions,
deleteSession,
getAllTasks,
getLastBotMessageTimestamp,
getMessagesSince,
getNewMessages,
getRouterState,
@@ -111,6 +115,27 @@ function loadState(): void {
);
}
/**
* Return the message cursor for a group, recovering from the last bot reply
* if lastAgentTimestamp is missing (new group, corrupted state, restart).
*/
function getOrRecoverCursor(chatJid: string): string {
const existing = lastAgentTimestamp[chatJid];
if (existing) return existing;
const botTs = getLastBotMessageTimestamp(chatJid, ASSISTANT_NAME);
if (botTs) {
logger.info(
{ chatJid, recoveredFrom: botTs },
'Recovered message cursor from last bot reply',
);
lastAgentTimestamp[chatJid] = botTs;
saveState();
return botTs;
}
return '';
}
function saveState(): void {
setRouterState('last_timestamp', lastTimestamp);
setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
@@ -134,6 +159,26 @@ function registerGroup(jid: string, group: RegisteredGroup): void {
// Create group folder
fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
// Copy CLAUDE.md template into the new group folder so agents have
// identity and instructions from the first run. (Fixes #1391)
const groupMdFile = path.join(groupDir, 'CLAUDE.md');
if (!fs.existsSync(groupMdFile)) {
const templateFile = path.join(
GROUPS_DIR,
group.isMain ? 'main' : 'global',
'CLAUDE.md',
);
if (fs.existsSync(templateFile)) {
let content = fs.readFileSync(templateFile, 'utf-8');
if (ASSISTANT_NAME !== 'Andy') {
content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`);
content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`);
}
fs.writeFileSync(groupMdFile, content);
logger.info({ folder: group.folder }, 'Created CLAUDE.md from template');
}
}
// Ensure a corresponding OneCLI agent exists (best-effort, non-blocking)
ensureOneCLIAgent(jid, group);
@@ -184,21 +229,22 @@ async function processGroupMessages(chatJid: string): Promise<boolean> {
const isMainGroup = group.isMain === true;
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince(
chatJid,
sinceTimestamp,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
if (missedMessages.length === 0) return true;
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = missedMessages.some(
(m) =>
TRIGGER_PATTERN.test(m.content.trim()) &&
triggerPattern.test(m.content.trim()) &&
(m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
@@ -307,6 +353,7 @@ async function runAgent(
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
@@ -422,7 +469,7 @@ async function startMessageLoop(): Promise<void> {
}
messageLoopRunning = true;
logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`);
while (true) {
try {
@@ -468,10 +515,11 @@ async function startMessageLoop(): Promise<void> {
// Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives.
if (needsTrigger) {
const triggerPattern = getTriggerPattern(group.trigger);
const allowlistCfg = loadSenderAllowlist();
const hasTrigger = groupMessages.some(
(m) =>
TRIGGER_PATTERN.test(m.content.trim()) &&
triggerPattern.test(m.content.trim()) &&
(m.is_from_me ||
isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
@@ -482,8 +530,9 @@ async function startMessageLoop(): Promise<void> {
// context that accumulated between triggers is included.
const allPending = getMessagesSince(
chatJid,
lastAgentTimestamp[chatJid] || '',
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
@@ -522,8 +571,12 @@ async function startMessageLoop(): Promise<void> {
*/
function recoverPendingMessages(): void {
for (const [chatJid, group] of Object.entries(registeredGroups)) {
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
const pending = getMessagesSince(
chatJid,
getOrRecoverCursor(chatJid),
ASSISTANT_NAME,
MAX_MESSAGES_PER_PROMPT,
);
if (pending.length > 0) {
logger.info(
{ group: group.name, pendingCount: pending.length },
@@ -707,6 +760,7 @@ async function main(): Promise<void> {
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script || undefined,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,

View File

@@ -162,6 +162,7 @@ export async function processTaskIpc(
schedule_type?: string;
schedule_value?: string;
context_mode?: string;
script?: string;
groupFolder?: string;
chatJid?: string;
targetJid?: string;
@@ -260,6 +261,7 @@ export async function processTaskIpc(
group_folder: targetFolder,
chat_jid: targetJid,
prompt: data.prompt,
script: data.script || null,
schedule_type: scheduleType,
schedule_value: data.schedule_value,
context_mode: contextMode,
@@ -352,6 +354,7 @@ export async function processTaskIpc(
const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.script !== undefined) updates.script = data.script || null;
if (data.schedule_type !== undefined)
updates.schedule_type = data.schedule_type as
| 'cron'
@@ -438,7 +441,10 @@ export async function processTaskIpc(
);
break;
}
// Defense in depth: agent cannot set isMain via IPC
// Defense in depth: agent cannot set isMain via IPC.
// Preserve isMain from the existing registration so IPC config
// updates (e.g. adding additionalMounts) don't strip the flag.
const existingGroup = registeredGroups[data.jid];
deps.registerGroup(data.jid, {
name: data.name,
folder: data.folder,
@@ -446,6 +452,7 @@ export async function processTaskIpc(
added_at: new Date().toISOString(),
containerConfig: data.containerConfig,
requiresTrigger: data.requiresTrigger,
isMain: existingGroup?.isMain,
});
} else {
logger.warn(

View File

@@ -1,11 +1,78 @@
import pino from 'pino';
const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const;
type Level = keyof typeof LEVELS;
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } },
});
const COLORS: Record<Level, string> = {
debug: '\x1b[34m',
info: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
fatal: '\x1b[41m\x1b[37m',
};
const KEY_COLOR = '\x1b[35m';
const MSG_COLOR = '\x1b[36m';
const RESET = '\x1b[39m';
const FULL_RESET = '\x1b[0m';
// Route uncaught errors through pino so they get timestamps in stderr
const threshold =
LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info;
function formatErr(err: unknown): string {
if (err instanceof Error) {
return `{\n "type": "${err.constructor.name}",\n "message": "${err.message}",\n "stack":\n ${err.stack}\n }`;
}
return JSON.stringify(err);
}
function formatData(data: Record<string, unknown>): string {
let out = '';
for (const [k, v] of Object.entries(data)) {
if (k === 'err') {
out += `\n ${KEY_COLOR}err${RESET}: ${formatErr(v)}`;
} else {
out += `\n ${KEY_COLOR}${k}${RESET}: ${JSON.stringify(v)}`;
}
}
return out;
}
function ts(): string {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}.${String(d.getMilliseconds()).padStart(3, '0')}`;
}
function log(
level: Level,
dataOrMsg: Record<string, unknown> | string,
msg?: string,
): void {
if (LEVELS[level] < threshold) return;
const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`;
const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout;
if (typeof dataOrMsg === 'string') {
stream.write(
`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${dataOrMsg}${RESET}\n`,
);
} else {
stream.write(
`[${ts()}] ${tag} (${process.pid}): ${MSG_COLOR}${msg}${RESET}${formatData(dataOrMsg)}\n`,
);
}
}
export const logger = {
debug: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
log('debug', dataOrMsg, msg),
info: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
log('info', dataOrMsg, msg),
warn: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
log('warn', dataOrMsg, msg),
error: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
log('error', dataOrMsg, msg),
fatal: (dataOrMsg: Record<string, unknown> | string, msg?: string) =>
log('fatal', dataOrMsg, msg),
};
// Route uncaught errors through logger so they get timestamps in stderr
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception');
process.exit(1);

View File

@@ -9,16 +9,10 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import pino from 'pino';
import { MOUNT_ALLOWLIST_PATH } from './config.js';
import { logger } from './logger.js';
import { AdditionalMount, AllowedRoot, MountAllowlist } from './types.js';
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: { target: 'pino-pretty', options: { colorize: true } },
});
// Cache the allowlist in memory - only reloads on process restart
let cachedAllowlist: MountAllowlist | null = null;
let allowlistLoadError: string | null = null;
@@ -63,7 +57,8 @@ export function loadMountAllowlist(): MountAllowlist | null {
try {
if (!fs.existsSync(MOUNT_ALLOWLIST_PATH)) {
allowlistLoadError = `Mount allowlist not found at ${MOUNT_ALLOWLIST_PATH}`;
// Do NOT cache this as an error — file may be created later without restart.
// Only parse/structural errors are permanently cached.
logger.warn(
{ path: MOUNT_ALLOWLIST_PATH },
'Mount allowlist not found - additional mounts will be BLOCKED. ' +
@@ -215,6 +210,11 @@ function isValidContainerPath(containerPath: string): boolean {
return false;
}
// Must not contain colons — prevents Docker -v option injection (e.g., "repo:rw")
if (containerPath.includes(':')) {
return false;
}
return true;
}

View File

@@ -139,6 +139,7 @@ async function runTask(
id: t.id,
groupFolder: t.group_folder,
prompt: t.prompt,
script: t.script,
schedule_type: t.schedule_type,
schedule_value: t.schedule_value,
status: t.status,
@@ -179,6 +180,7 @@ async function runTask(
isMain,
isScheduledTask: true,
assistantName: ASSISTANT_NAME,
script: task.script || undefined,
},
(proc, containerName) =>
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),

View File

@@ -1,6 +1,10 @@
import { describe, it, expect } from 'vitest';
import { formatLocalTime } from './timezone.js';
import {
formatLocalTime,
isValidTimezone,
resolveTimezone,
} from './timezone.js';
// --- formatLocalTime ---
@@ -26,4 +30,44 @@ describe('formatLocalTime', () => {
expect(ny).toContain('8:00');
expect(tokyo).toContain('9:00');
});
it('does not throw on invalid timezone, falls back to UTC', () => {
expect(() =>
formatLocalTime('2026-01-01T00:00:00.000Z', 'IST-2'),
).not.toThrow();
const result = formatLocalTime('2026-01-01T12:00:00.000Z', 'IST-2');
// Should format as UTC (noon UTC = 12:00 PM)
expect(result).toContain('12:00');
expect(result).toContain('PM');
});
});
describe('isValidTimezone', () => {
it('accepts valid IANA identifiers', () => {
expect(isValidTimezone('America/New_York')).toBe(true);
expect(isValidTimezone('UTC')).toBe(true);
expect(isValidTimezone('Asia/Tokyo')).toBe(true);
expect(isValidTimezone('Asia/Jerusalem')).toBe(true);
});
it('rejects invalid timezone strings', () => {
expect(isValidTimezone('IST-2')).toBe(false);
expect(isValidTimezone('XYZ+3')).toBe(false);
});
it('rejects empty and garbage strings', () => {
expect(isValidTimezone('')).toBe(false);
expect(isValidTimezone('NotATimezone')).toBe(false);
});
});
describe('resolveTimezone', () => {
it('returns the timezone if valid', () => {
expect(resolveTimezone('America/New_York')).toBe('America/New_York');
});
it('falls back to UTC for invalid timezone', () => {
expect(resolveTimezone('IST-2')).toBe('UTC');
expect(resolveTimezone('')).toBe('UTC');
});
});

View File

@@ -1,11 +1,32 @@
/**
* Check whether a timezone string is a valid IANA identifier
* that Intl.DateTimeFormat can use.
*/
export function isValidTimezone(tz: string): boolean {
try {
Intl.DateTimeFormat(undefined, { timeZone: tz });
return true;
} catch {
return false;
}
}
/**
* Return the given timezone if valid IANA, otherwise fall back to UTC.
*/
export function resolveTimezone(tz: string): string {
return isValidTimezone(tz) ? tz : 'UTC';
}
/**
* Convert a UTC ISO timestamp to a localized display string.
* Uses the Intl API (no external dependencies).
* Falls back to UTC if the timezone is invalid.
*/
export function formatLocalTime(utcIso: string, timezone: string): string {
const date = new Date(utcIso);
return date.toLocaleString('en-US', {
timeZone: timezone,
timeZone: resolveTimezone(timezone),
year: 'numeric',
month: 'short',
day: 'numeric',

View File

@@ -58,6 +58,7 @@ export interface ScheduledTask {
group_folder: string;
chat_jid: string;
prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string;
context_mode: 'group' | 'isolated';