mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
merge: catch up with upstream main
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -1,15 +1,21 @@
|
||||
---
|
||||
name: add-ollama-tool
|
||||
description: Add Ollama MCP server so the container agent can call local models for cheaper/faster tasks like summarization, translation, or general queries.
|
||||
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.
|
||||
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` — lists installed Ollama models
|
||||
- `ollama_generate` — sends a prompt to a specified model and returns the response
|
||||
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
|
||||
|
||||
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, and architecture info
|
||||
- `ollama_list_running` — list models currently loaded in memory with memory usage and processor type
|
||||
|
||||
## Phase 1: Pre-flight
|
||||
|
||||
@@ -89,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`:
|
||||
@@ -106,7 +129,7 @@ launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
|
||||
|
||||
## Phase 4: Verify
|
||||
|
||||
### Test via WhatsApp
|
||||
### Test inference
|
||||
|
||||
Tell the user:
|
||||
|
||||
@@ -114,6 +137,14 @@ Tell the user:
|
||||
>
|
||||
> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
|
||||
|
||||
### 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?"
|
||||
>
|
||||
> The agent should call `ollama_pull_model` or `ollama_list_running` respectively.
|
||||
|
||||
### Monitor activity (optional)
|
||||
|
||||
Run the watcher script for macOS notifications when Ollama is used:
|
||||
@@ -129,9 +160,10 @@ tail -f logs/nanoclaw.log | grep -i ollama
|
||||
```
|
||||
|
||||
Look for:
|
||||
- `Agent output: ... Ollama ...` — agent used Ollama successfully
|
||||
- `[OLLAMA] >>> Generating` — generation started (if log surfacing works)
|
||||
- `[OLLAMA] >>> Generating` — generation started
|
||||
- `[OLLAMA] <<< Done` — generation completed
|
||||
- `[OLLAMA] Pulling model:` — pull in progress (management tools)
|
||||
- `[OLLAMA] Deleted:` — model removed (management tools)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -151,3 +183,11 @@ The agent is trying to run `ollama` CLI inside the container instead of using th
|
||||
### Agent doesn't use Ollama tools
|
||||
|
||||
The agent may not know about the tools. Try being explicit: "use the ollama_generate tool with gemma3:1b to answer: ..."
|
||||
|
||||
### `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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -11,8 +11,8 @@ Telegram.
|
||||
|
||||
| Channel | Transformation |
|
||||
|---------|---------------|
|
||||
| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links flattened |
|
||||
| Telegram | same as WhatsApp |
|
||||
| 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 |
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -112,7 +84,10 @@ Run `npx tsx setup/index.ts --step timezone` 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
|
||||
@@ -147,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
|
||||
@@ -163,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.
|
||||
|
||||
@@ -181,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.
|
||||
|
||||
@@ -189,6 +198,10 @@ 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. Invoke `/use-native-credential-proxy` to set up the built-in credential proxy instead. That skill handles credential collection, `.env` configuration, and verification.
|
||||
|
||||
## 5. Set Up Channels
|
||||
|
||||
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
|
||||
@@ -265,7 +278,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`
|
||||
@@ -274,7 +287,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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,10 @@ All notable changes to NanoClaw will be documented in this file.
|
||||
|
||||
For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog).
|
||||
|
||||
## [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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -14,3 +14,5 @@ Thanks to everyone who has contributed to NanoClaw!
|
||||
- [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
|
||||
|
||||
@@ -72,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
|
||||
|
||||
@@ -160,7 +161,7 @@ Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via W
|
||||
|
||||
**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 the [security documentation](https://docs.nanoclaw.dev/concepts/security) 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?**
|
||||
|
||||
|
||||
@@ -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. Build and test after every forward merge.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
|
||||
+13
-11
@@ -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:**
|
||||
- Channel auth sessions (`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
|
||||
|
||||
@@ -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 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -267,7 +271,7 @@ The task will run in that group's context with access to their files and memory.
|
||||
|
||||
## Task Scripts
|
||||
|
||||
When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up.
|
||||
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
|
||||
|
||||
|
||||
Generated
+6
-453
@@ -1,26 +1,21 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.31",
|
||||
"version": "1.2.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.31",
|
||||
"version": "1.2.42",
|
||||
"dependencies": {
|
||||
"@onecli-sh/sdk": "^0.2.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"cron-parser": "5.5.0",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.0.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
"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",
|
||||
@@ -35,66 +30,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bcoe/v8-coverage": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz",
|
||||
"integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
@@ -743,16 +678,6 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -760,17 +685,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/resolve-uri": "^3.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@onecli-sh/sdk": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@onecli-sh/sdk/-/sdk-0.2.0.tgz",
|
||||
@@ -779,12 +693,6 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
@@ -1460,37 +1368,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
|
||||
"integrity": "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"ast-v8-to-istanbul": "^0.3.10",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
"istanbul-reports": "^3.2.0",
|
||||
"magicast": "^0.5.1",
|
||||
"obug": "^2.1.1",
|
||||
"std-env": "^3.10.0",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.0.18",
|
||||
"vitest": "4.0.18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
@@ -1670,27 +1547,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz",
|
||||
"integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -1841,12 +1697,6 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1879,15 +1729,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/dateformat": {
|
||||
"version": "4.6.3",
|
||||
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
|
||||
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -2199,12 +2040,6 @@
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-copy": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.2.tgz",
|
||||
"integrity": "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2223,12 +2058,6 @@
|
||||
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2374,19 +2203,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/husky": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
|
||||
@@ -2496,61 +2312,6 @@
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
|
||||
"integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-lib-report": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz",
|
||||
"integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"istanbul-lib-coverage": "^3.0.0",
|
||||
"make-dir": "^4.0.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/istanbul-reports": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
|
||||
"integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"html-escaper": "^2.0.0",
|
||||
"istanbul-lib-report": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/joycon": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
|
||||
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
|
||||
"integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -2643,34 +2404,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/magicast": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz",
|
||||
"integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
"integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
@@ -2771,15 +2504,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
@@ -2893,76 +2617,6 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty": {
|
||||
"version": "13.1.3",
|
||||
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
|
||||
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"colorette": "^2.0.7",
|
||||
"dateformat": "^4.6.3",
|
||||
"fast-copy": "^4.0.0",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"help-me": "^5.0.0",
|
||||
"joycon": "^3.1.1",
|
||||
"minimist": "^1.2.6",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^3.0.0",
|
||||
"pump": "^3.0.0",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"strip-json-comments": "^5.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"pino-pretty": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-pretty/node_modules/pino-abstract-transport": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -3043,22 +2697,6 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
|
||||
@@ -3078,12 +2716,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -3122,15 +2754,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3215,31 +2838,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
@@ -3325,15 +2923,6 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -3344,15 +2933,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -3376,18 +2956,6 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
@@ -3429,15 +2997,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
|
||||
"integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3801,7 +3360,10 @@
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
@@ -3823,15 +3385,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nanoclaw",
|
||||
"version": "1.2.31",
|
||||
"version": "1.2.42",
|
||||
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -23,15 +23,12 @@
|
||||
"dependencies": {
|
||||
"@onecli-sh/sdk": "^0.2.0",
|
||||
"better-sqlite3": "11.10.0",
|
||||
"cron-parser": "5.5.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
"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",
|
||||
|
||||
@@ -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="41.2k tokens, 21% of context window">
|
||||
<title>41.2k tokens, 21% 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">41.2k</text>
|
||||
<text x="74" y="14">41.2k</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 |
+20
-3
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
+16
-4
@@ -53,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(
|
||||
@@ -64,10 +68,18 @@ 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');
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-10
@@ -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';
|
||||
|
||||
@@ -431,15 +431,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);
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -141,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 */
|
||||
@@ -165,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.
|
||||
@@ -370,6 +375,19 @@ 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 {
|
||||
|
||||
+3
-2
@@ -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);
|
||||
}
|
||||
|
||||
+47
-9
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+42
-9
@@ -5,12 +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 +34,7 @@ import {
|
||||
getAllRegisteredGroups,
|
||||
getAllSessions,
|
||||
getAllTasks,
|
||||
getLastBotMessageTimestamp,
|
||||
getMessagesSince,
|
||||
getNewMessages,
|
||||
getRouterState,
|
||||
@@ -111,6 +114,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));
|
||||
@@ -204,21 +228,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;
|
||||
@@ -327,6 +352,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,
|
||||
@@ -397,7 +423,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 {
|
||||
@@ -443,10 +469,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)),
|
||||
);
|
||||
@@ -457,8 +484,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;
|
||||
@@ -497,8 +525,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 },
|
||||
@@ -682,6 +714,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,
|
||||
|
||||
+5
-1
@@ -441,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,
|
||||
@@ -449,6 +452,7 @@ export async function processTaskIpc(
|
||||
added_at: new Date().toISOString(),
|
||||
containerConfig: data.containerConfig,
|
||||
requiresTrigger: data.requiresTrigger,
|
||||
isMain: existingGroup?.isMain,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
|
||||
Reference in New Issue
Block a user