From d8a1ee8c3c5404a348a36852be73cb7eb00a6abb Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Tue, 10 Mar 2026 22:39:26 +0200
Subject: [PATCH 01/25] fix: use npm ci in bootstrap to prevent dirty lockfile
blocking merges
setup.sh ran npm install which modified package-lock.json, causing
git merge to refuse during channel skill installation. Switch to
npm ci (deterministic, doesn't modify lockfile) and clean up stale
peer flags in the lockfile.
Co-Authored-By: Claude Opus 4.6
---
.claude/skills/setup/SKILL.md | 2 +-
package-lock.json | 5 -----
setup.sh | 4 ++--
3 files changed, 3 insertions(+), 8 deletions(-)
diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md
index 544ee1d..18515f6 100644
--- a/.claude/skills/setup/SKILL.md
+++ b/.claude/skills/setup/SKILL.md
@@ -58,7 +58,7 @@ Run `bash setup.sh` and parse the status block.
- macOS: `brew install node@22` (if brew available) or install nvm then `nvm install 22`
- Linux: `curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs`, or nvm
- After installing Node, re-run `bash setup.sh`
-- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules` and `package-lock.json`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
+- If DEPS_OK=false → Read `logs/setup.log`. Try: delete `node_modules`, re-run `bash setup.sh`. If native module build fails, install build tools (`xcode-select --install` on macOS, `build-essential` on Linux), then retry.
- If NATIVE_OK=false → better-sqlite3 failed to load. Install build tools and re-run.
- Record PLATFORM and IS_WSL for later steps.
diff --git a/package-lock.json b/package-lock.json
index ef19a6c..8d63787 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1740,7 +1740,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2295,7 +2294,6 @@
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
@@ -2355,7 +2353,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -2431,7 +2428,6 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
@@ -2532,7 +2528,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"license": "ISC",
- "peer": true,
"bin": {
"yaml": "bin.mjs"
},
diff --git a/setup.sh b/setup.sh
index ef7d683..c37f143 100755
--- a/setup.sh
+++ b/setup.sh
@@ -79,8 +79,8 @@ install_deps() {
log "Running as root, using --unsafe-perm"
fi
- log "Running npm install $npm_flags"
- if npm install $npm_flags >> "$LOG_FILE" 2>&1; then
+ log "Running npm ci $npm_flags"
+ if npm ci $npm_flags >> "$LOG_FILE" 2>&1; then
DEPS_OK="true"
log "npm install succeeded"
else
From 04fb44e417d2702fef32cda515469d391e48c65e Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Tue, 10 Mar 2026 22:51:40 +0200
Subject: [PATCH 02/25] =?UTF-8?q?fix:=20setup=20registration=20=E2=80=94?=
=?UTF-8?q?=20use=20initDatabase/setRegisteredGroup,=20.ts=20imports,=20co?=
=?UTF-8?q?rrect=20CLI=20commands?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- setup/register.ts: replace inline DB logic with initDatabase() + setRegisteredGroup()
(fixes missing is_main column on existing DBs, .js MODULE_NOT_FOUND with tsx)
- SKILL.md (telegram, slack, discord): replace broken registerGroup() pseudo-code
with actual `npx tsx setup/index.ts --step register` commands
- docs/SPEC.md: fix registerGroup → setRegisteredGroup in example
Co-Authored-By: Claude Opus 4.6
---
.claude/skills/add-discord/SKILL.md | 23 +++---------
.claude/skills/add-slack/SKILL.md | 23 +++---------
.claude/skills/add-telegram/SKILL.md | 23 +++---------
docs/SPEC.md | 2 +-
setup/register.ts | 54 ++++++++--------------------
5 files changed, 31 insertions(+), 94 deletions(-)
diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md
index f4a3164..1b26ac1 100644
--- a/.claude/skills/add-discord/SKILL.md
+++ b/.claude/skills/add-discord/SKILL.md
@@ -126,31 +126,18 @@ Wait for the user to provide the channel ID (format: `dc:1234567890123456`).
### Register the channel
-Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
+The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main channel (responds to all messages):
-```typescript
-registerGroup("dc:", {
- name: " #",
- folder: "discord_main",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: false,
- isMain: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_main" --trigger "@${ASSISTANT_NAME}" --channel discord --no-trigger-required --is-main
```
For additional channels (trigger-only):
-```typescript
-registerGroup("dc:", {
- name: " #",
- folder: "discord_",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "dc:" --name " #" --folder "discord_" --trigger "@${ASSISTANT_NAME}" --channel discord
```
## Phase 5: Verify
diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md
index 4eb9225..0808b2a 100644
--- a/.claude/skills/add-slack/SKILL.md
+++ b/.claude/skills/add-slack/SKILL.md
@@ -114,31 +114,18 @@ Wait for the user to provide the channel ID.
### Register the channel
-Use the IPC register flow or register directly. The channel ID, name, and folder name are needed.
+The channel ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main channel (responds to all messages):
-```typescript
-registerGroup("slack:", {
- name: "",
- folder: "slack_main",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: false,
- isMain: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_main" --trigger "@${ASSISTANT_NAME}" --channel slack --no-trigger-required --is-main
```
For additional channels (trigger-only):
-```typescript
-registerGroup("slack:", {
- name: "",
- folder: "slack_",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "slack:" --name "" --folder "slack_" --trigger "@${ASSISTANT_NAME}" --channel slack
```
## Phase 5: Verify
diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md
index a2e29d7..2985156 100644
--- a/.claude/skills/add-telegram/SKILL.md
+++ b/.claude/skills/add-telegram/SKILL.md
@@ -129,31 +129,18 @@ Wait for the user to provide the chat ID (format: `tg:123456789` or `tg:-1001234
### Register the chat
-Use the IPC register flow or register directly. The chat ID, name, and folder name are needed.
+The chat ID, name, and folder name are needed. Use `npx tsx setup/index.ts --step register` with the appropriate flags.
For a main chat (responds to all messages):
-```typescript
-registerGroup("tg:", {
- name: "",
- folder: "telegram_main",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: false,
- isMain: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_main" --trigger "@${ASSISTANT_NAME}" --channel telegram --no-trigger-required --is-main
```
For additional chats (trigger-only):
-```typescript
-registerGroup("tg:", {
- name: "",
- folder: "telegram_",
- trigger: `@${ASSISTANT_NAME}`,
- added_at: new Date().toISOString(),
- requiresTrigger: true,
-});
+```bash
+npx tsx setup/index.ts --step register -- --jid "tg:" --name "" --folder "telegram_" --trigger "@${ASSISTANT_NAME}" --channel telegram
```
## Phase 5: Verify
diff --git a/docs/SPEC.md b/docs/SPEC.md
index d2b4723..598f34e 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -358,7 +358,7 @@ export const TRIGGER_PATTERN = new RegExp(`^@${ASSISTANT_NAME}\\b`, 'i');
Groups can have additional directories mounted via `containerConfig` in the SQLite `registered_groups` table (stored as JSON in the `container_config` column). Example registration:
```typescript
-registerGroup("1234567890@g.us", {
+setRegisteredGroup("1234567890@g.us", {
name: "Dev Team",
folder: "whatsapp_dev-team",
trigger: "@Andy",
diff --git a/setup/register.ts b/setup/register.ts
index 03ea7df..eeafa90 100644
--- a/setup/register.ts
+++ b/setup/register.ts
@@ -7,12 +7,11 @@
import fs from 'fs';
import path from 'path';
-import Database from 'better-sqlite3';
-
-import { STORE_DIR } from '../src/config.js';
-import { isValidGroupFolder } from '../src/group-folder.js';
-import { logger } from '../src/logger.js';
-import { emitStatus } from './status.js';
+import { STORE_DIR } from '../src/config.ts';
+import { initDatabase, setRegisteredGroup } from '../src/db.ts';
+import { isValidGroupFolder } from '../src/group-folder.ts';
+import { logger } from '../src/logger.ts';
+import { emitStatus } from './status.ts';
interface RegisterArgs {
jid: string;
@@ -98,41 +97,18 @@ export async function run(args: string[]): Promise {
fs.mkdirSync(path.join(projectRoot, 'data'), { recursive: true });
fs.mkdirSync(STORE_DIR, { recursive: true });
- // Write to SQLite using parameterized queries (no SQL injection)
- const dbPath = path.join(STORE_DIR, 'messages.db');
- const timestamp = new Date().toISOString();
- const requiresTriggerInt = parsed.requiresTrigger ? 1 : 0;
+ // Initialize database (creates schema + runs migrations)
+ initDatabase();
- const db = new Database(dbPath);
- // Ensure schema exists
- db.exec(`CREATE TABLE IF NOT EXISTS registered_groups (
- jid TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- folder TEXT NOT NULL UNIQUE,
- trigger_pattern TEXT NOT NULL,
- added_at TEXT NOT NULL,
- container_config TEXT,
- requires_trigger INTEGER DEFAULT 1,
- is_main INTEGER DEFAULT 0
- )`);
+ setRegisteredGroup(parsed.jid, {
+ name: parsed.name,
+ folder: parsed.folder,
+ trigger: parsed.trigger,
+ added_at: new Date().toISOString(),
+ requiresTrigger: parsed.requiresTrigger,
+ isMain: parsed.isMain,
+ });
- const isMainInt = parsed.isMain ? 1 : 0;
-
- db.prepare(
- `INSERT OR REPLACE INTO registered_groups
- (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
- VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
- ).run(
- parsed.jid,
- parsed.name,
- parsed.folder,
- parsed.trigger,
- timestamp,
- requiresTriggerInt,
- isMainInt,
- );
-
- db.close();
logger.info('Wrote registration to SQLite');
// Create group folders
From 0cfdde46c67606ff0cbe6bc71439812734b8ad50 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Tue, 10 Mar 2026 22:59:23 +0200
Subject: [PATCH 03/25] fix: remove claude plugin marketplace commands (skills
are local now)
Co-Authored-By: Claude Opus 4.6
---
.claude/settings.json | 11 +----------
.claude/skills/customize/SKILL.md | 7 +------
.claude/skills/setup/SKILL.md | 28 ++++++++--------------------
3 files changed, 10 insertions(+), 36 deletions(-)
diff --git a/.claude/settings.json b/.claude/settings.json
index f859a6d..0967ef4 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -1,10 +1 @@
-{
- "extraKnownMarketplaces": {
- "nanoclaw-skills": {
- "source": {
- "source": "github",
- "repo": "qwibitai/nanoclaw-skills"
- }
- }
- }
-}
+{}
diff --git a/.claude/skills/customize/SKILL.md b/.claude/skills/customize/SKILL.md
index 13b5b89..614a979 100644
--- a/.claude/skills/customize/SKILL.md
+++ b/.claude/skills/customize/SKILL.md
@@ -9,12 +9,7 @@ This skill helps users add capabilities or modify behavior. Use AskUserQuestion
## Workflow
-1. **Install marketplace** - If feature skills aren't available yet, install the marketplace plugin:
- ```bash
- claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
- ```
- This is hot-loaded — all feature skills become immediately available.
-2. **Understand the request** - Ask clarifying questions
+1. **Understand the request** - Ask clarifying questions
3. **Plan the changes** - Identify files to modify. If a skill exists for the request (e.g., `/add-telegram` for adding Telegram), invoke it instead of implementing manually.
4. **Implement** - Make changes directly to the code
5. **Test guidance** - Tell user how to verify
diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md
index 18515f6..d173927 100644
--- a/.claude/skills/setup/SKILL.md
+++ b/.claude/skills/setup/SKILL.md
@@ -122,19 +122,7 @@ AskUserQuestion: Claude subscription (Pro/Max) vs Anthropic API key?
**API key:** Tell user to add `ANTHROPIC_API_KEY=` to `.env`.
-## 5. Install Skills Marketplace
-
-Register and install the NanoClaw skills marketplace plugin so all feature skills (channel integrations, add-ons) are available:
-
-```bash
-claude plugin marketplace add qwibitai/nanoclaw-skills
-claude plugin marketplace update nanoclaw-skills
-claude plugin install nanoclaw-skills@nanoclaw-skills --scope project
-```
-
-The marketplace update ensures the local cache is fresh before installing. This is hot-loaded — no restart needed. All feature skills become immediately available.
-
-## 6. Set Up Channels
+## 5. Set Up Channels
AskUserQuestion (multiSelect): Which messaging channels do you want to enable?
- WhatsApp (authenticates via QR code or pairing code)
@@ -164,16 +152,16 @@ Each skill will:
npm install && npm run build
```
-If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 7.
+If the build fails, read the error output and fix it (usually a missing dependency). Then continue to step 6.
-## 7. Mount Allowlist
+## 6. Mount Allowlist
AskUserQuestion: Agent access to external directories?
**No:** `npx tsx setup/index.ts --step mounts -- --empty`
**Yes:** Collect paths/permissions. `npx tsx setup/index.ts --step mounts -- --json '{"allowedRoots":[...],"blockedPatterns":[],"nonMainReadOnly":true}'`
-## 8. Start Service
+## 7. Start Service
If service already running: unload first.
- macOS: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist`
@@ -203,23 +191,23 @@ Replace `USERNAME` with the actual username (from `whoami`). Run the two `sudo`
- Linux: check `systemctl --user status nanoclaw`.
- Re-run the service step after fixing.
-## 9. Verify
+## 8. Verify
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 8
+- SERVICE=not_found → re-run step 7
- CREDENTIALS=missing → re-run step 4
- 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 6
+- REGISTERED_GROUPS=0 → re-invoke the channel skills from step 5
- MOUNT_ALLOWLIST=missing → `npx tsx setup/index.ts --step mounts -- --empty`
Tell user to test: send a message in their registered chat. Show: `tail -f logs/nanoclaw.log`
## Troubleshooting
-**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 8), missing `.env` (step 4), missing channel credentials (re-invoke channel skill).
+**Service not starting:** Check `logs/nanoclaw.error.log`. Common: wrong Node path (re-run step 7), missing `.env` (step 4), 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`.
From 1f2e930d16c2d9ea3feee357e82602f6ff9ccc52 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Wed, 11 Mar 2026 12:25:14 +0200
Subject: [PATCH 04/25] fix: auto-resolve package-lock conflicts when merging
forks
Instead of failing on package-lock.json merge conflicts, take the
fork's version and continue. Applied to all channel skill merge
instructions and CLAUDE.md troubleshooting.
Co-Authored-By: Claude Opus 4.6
---
.claude/skills/add-discord/SKILL.md | 6 +++++-
.claude/skills/add-gmail/SKILL.md | 6 +++++-
.claude/skills/add-image-vision/SKILL.md | 6 +++++-
.claude/skills/add-pdf-reader/SKILL.md | 6 +++++-
.claude/skills/add-reactions/SKILL.md | 6 +++++-
.claude/skills/add-slack/SKILL.md | 6 +++++-
.claude/skills/add-telegram/SKILL.md | 6 +++++-
.claude/skills/add-voice-transcription/SKILL.md | 6 +++++-
.claude/skills/add-whatsapp/SKILL.md | 6 +++++-
.claude/skills/use-local-whisper/SKILL.md | 6 +++++-
CLAUDE.md | 2 +-
11 files changed, 51 insertions(+), 11 deletions(-)
diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md
index 1b26ac1..e46bd3e 100644
--- a/.claude/skills/add-discord/SKILL.md
+++ b/.claude/skills/add-discord/SKILL.md
@@ -39,7 +39,11 @@ git remote add discord https://github.com/qwibitai/nanoclaw-discord.git
```bash
git fetch discord main
-git merge discord/main
+git merge discord/main || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-gmail/SKILL.md b/.claude/skills/add-gmail/SKILL.md
index f77bbf7..781a0eb 100644
--- a/.claude/skills/add-gmail/SKILL.md
+++ b/.claude/skills/add-gmail/SKILL.md
@@ -40,7 +40,11 @@ git remote add gmail https://github.com/qwibitai/nanoclaw-gmail.git
```bash
git fetch gmail main
-git merge gmail/main
+git merge gmail/main || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md
index 53ef471..072bf7b 100644
--- a/.claude/skills/add-image-vision/SKILL.md
+++ b/.claude/skills/add-image-vision/SKILL.md
@@ -32,7 +32,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp skill/image-vision
-git merge whatsapp/skill/image-vision
+git merge whatsapp/skill/image-vision || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md
index cd3736b..a01e530 100644
--- a/.claude/skills/add-pdf-reader/SKILL.md
+++ b/.claude/skills/add-pdf-reader/SKILL.md
@@ -30,7 +30,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp skill/pdf-reader
-git merge whatsapp/skill/pdf-reader
+git merge whatsapp/skill/pdf-reader || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md
index be725c3..de86768 100644
--- a/.claude/skills/add-reactions/SKILL.md
+++ b/.claude/skills/add-reactions/SKILL.md
@@ -37,7 +37,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp skill/reactions
-git merge whatsapp/skill/reactions
+git merge whatsapp/skill/reactions || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This adds:
diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md
index 0808b2a..4c86e19 100644
--- a/.claude/skills/add-slack/SKILL.md
+++ b/.claude/skills/add-slack/SKILL.md
@@ -35,7 +35,11 @@ git remote add slack https://github.com/qwibitai/nanoclaw-slack.git
```bash
git fetch slack main
-git merge slack/main
+git merge slack/main || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md
index 2985156..10f25ab 100644
--- a/.claude/skills/add-telegram/SKILL.md
+++ b/.claude/skills/add-telegram/SKILL.md
@@ -39,7 +39,11 @@ git remote add telegram https://github.com/qwibitai/nanoclaw-telegram.git
```bash
git fetch telegram main
-git merge telegram/main
+git merge telegram/main || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-voice-transcription/SKILL.md b/.claude/skills/add-voice-transcription/SKILL.md
index c3c0043..8ccec32 100644
--- a/.claude/skills/add-voice-transcription/SKILL.md
+++ b/.claude/skills/add-voice-transcription/SKILL.md
@@ -41,7 +41,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp skill/voice-transcription
-git merge whatsapp/skill/voice-transcription
+git merge whatsapp/skill/voice-transcription || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md
index 8ce68be..0774799 100644
--- a/.claude/skills/add-whatsapp/SKILL.md
+++ b/.claude/skills/add-whatsapp/SKILL.md
@@ -62,7 +62,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp main
-git merge whatsapp/main
+git merge whatsapp/main || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This merges in:
diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md
index 76851f3..ec18a09 100644
--- a/.claude/skills/use-local-whisper/SKILL.md
+++ b/.claude/skills/use-local-whisper/SKILL.md
@@ -75,7 +75,11 @@ git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git
```bash
git fetch whatsapp skill/local-whisper
-git merge whatsapp/skill/local-whisper
+git merge whatsapp/skill/local-whisper || {
+ git checkout --theirs package-lock.json
+ git add package-lock.json
+ git merge --continue
+}
```
This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
diff --git a/CLAUDE.md b/CLAUDE.md
index 90c8910..318d6dd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,7 +57,7 @@ systemctl --user restart nanoclaw
## Troubleshooting
-**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && git merge whatsapp/main && npm run build`) to install it. Existing auth credentials and groups are preserved.
+**WhatsApp not connecting after upgrade:** WhatsApp is now a separate channel fork, not bundled in core. Run `/add-whatsapp` (or `git remote add whatsapp https://github.com/qwibitai/nanoclaw-whatsapp.git && git fetch whatsapp main && (git merge whatsapp/main || { git checkout --theirs package-lock.json && git add package-lock.json && git merge --continue; }) && npm run build`) to install it. Existing auth credentials and groups are preserved.
## Container Build Cache
From 7e9a698aa1ae130225a9ec4711c53ab16a72aa3c Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Wed, 11 Mar 2026 12:30:14 +0200
Subject: [PATCH 05/25] feat: add nanoclaw-docker-sandboxes to fork dispatch
list
Co-Authored-By: Claude Opus 4.6
---
.github/workflows/merge-forward-skills.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/merge-forward-skills.yml b/.github/workflows/merge-forward-skills.yml
index 20dada3..093130a 100644
--- a/.github/workflows/merge-forward-skills.yml
+++ b/.github/workflows/merge-forward-skills.yml
@@ -142,6 +142,7 @@ jobs:
'nanoclaw-discord',
'nanoclaw-slack',
'nanoclaw-gmail',
+ 'nanoclaw-docker-sandboxes',
];
const sha = context.sha.substring(0, 7);
for (const repo of forks) {
From 48d352a14261ea16d2f0330b3022d8133fd09718 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 13 Mar 2026 11:46:03 +0200
Subject: [PATCH 06/25] feat: add Docker Sandboxes announcement to README
Replace the Agent Swarms / Claude Code lines at the top with a
prominent Docker Sandboxes announcement section including install
commands and a link to the blog post.
Co-Authored-By: Claude Opus 4.6
---
README.md | 22 ++++++++++++++++++----
1 file changed, 18 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index e0e167d..3443b92 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,23 @@
•
-Using Claude Code, NanoClaw can dynamically rewrite its code to customize its feature set for your needs.
-**New:** First AI assistant to support [Agent Swarms](https://code.claude.com/docs/en/agent-teams). Spin up teams of agents that collaborate in your chat.
+
+
+
🐳 Now Running in Docker Sandboxes
+
Every agent gets its own isolated container inside a micro VM. Hypervisor-level isolation. Millisecond startup. No complex setup.
## Why I Built NanoClaw
@@ -70,8 +84,8 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
- **Main channel** - Your private channel (self-chat) for admin control; every group is completely isolated
- **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 Apple Container (macOS) or Docker (macOS/Linux)
-- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks. NanoClaw is the first personal AI assistant to support agent swarms.
+- **Container isolation** - Agents are sandboxed in [Docker Sandboxes](https://nanoclaw.dev/blog/nanoclaw-docker-sandboxes) (micro VM isolation), Apple Container (macOS), or Docker (macOS/Linux)
+- **Agent Swarms** - Spin up teams of specialized agents that collaborate on complex tasks
- **Optional integrations** - Add Gmail (`/add-gmail`) and more via skills
## Usage
From 49595b9c700e052902590c6e1bf079bab9f2f6d4 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 13 Mar 2026 11:48:10 +0200
Subject: [PATCH 07/25] fix: separate install commands into individual code
blocks
Allows each curl command to be copied independently without the
comment line.
Co-Authored-By: Claude Opus 4.6
---
README.md | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index 3443b92..51a9f95 100644
--- a/README.md
+++ b/README.md
@@ -18,11 +18,13 @@
🐳 Now Running in Docker Sandboxes
Every agent gets its own isolated container inside a micro VM. Hypervisor-level isolation. Millisecond startup. No complex setup.
+---
+
## Why I Built NanoClaw
[OpenClaw](https://github.com/openclaw/openclaw) is an impressive project, but I wouldn't have been able to sleep if I had given complex software I didn't understand full access to my life. OpenClaw has nearly half a million lines of code, 53 config files, and 70+ dependencies. Its security is at the application level (allowlists, pairing codes) rather than true OS-level isolation. Everything runs in one Node process with shared memory.
From e6ff5c640c921068c092331382030f8a2fd86d00 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 13 Mar 2026 12:02:15 +0200
Subject: [PATCH 12/25] feat: add manual Docker Sandboxes setup guide
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Step-by-step guide for running NanoClaw in Docker Sandboxes from
scratch without the install script. Covers proxy patches, DinD
mount fixes, channel setup, networking details, and troubleshooting.
Validated on macOS (Apple Silicon) with WhatsApp — other channels
and environments may need additional proxy patches.
Co-Authored-By: Claude Opus 4.6
---
README.md | 2 +-
docs/docker-sandboxes.md | 359 +++++++++++++++++++++++++++++++++++++++
2 files changed, 360 insertions(+), 1 deletion(-)
create mode 100644 docs/docker-sandboxes.md
diff --git a/README.md b/README.md
index ddc498a..1e5401f 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ curl -fsSL https://nanoclaw.dev/install-docker-sandboxes-windows.sh | bash
> Currently supported on macOS (Apple Silicon) and Windows (x86). Linux support coming soon.
-
---
diff --git a/docs/docker-sandboxes.md b/docs/docker-sandboxes.md
new file mode 100644
index 0000000..77dad83
--- /dev/null
+++ b/docs/docker-sandboxes.md
@@ -0,0 +1,359 @@
+# Running NanoClaw in Docker Sandboxes (Manual Setup)
+
+This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/security/sandbox/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
+
+## Architecture
+
+```
+Host (macOS / Windows WSL)
+└── Docker Sandbox (micro VM with isolated kernel)
+ ├── NanoClaw process (Node.js)
+ │ ├── Channel adapters (WhatsApp, Telegram, etc.)
+ │ └── Container spawner → nested Docker daemon
+ └── Docker-in-Docker
+ └── nanoclaw-agent containers
+ └── Claude Agent SDK
+```
+
+Each agent runs in its own container, inside a micro VM that is fully isolated from your host. Two layers of isolation: per-agent containers + the VM boundary.
+
+The sandbox provides a MITM proxy at `host.docker.internal:3128` that handles network access and injects your Anthropic API key automatically.
+
+> **Note:** This guide is based on a validated setup running on macOS (Apple Silicon) with WhatsApp. Other channels (Telegram, Slack, etc.) and environments (Windows WSL) may require additional proxy patches for their specific HTTP/WebSocket clients. The core patches (container runner, credential proxy, Dockerfile) apply universally — channel-specific proxy configuration varies.
+
+## Prerequisites
+
+- **Docker Desktop v4.40+** with Sandbox support
+- **Anthropic API key** (the sandbox proxy manages injection)
+- For **Telegram**: a bot token from [@BotFather](https://t.me/BotFather) and your chat ID
+- For **WhatsApp**: a phone with WhatsApp installed
+
+Verify sandbox support:
+```bash
+docker sandbox version
+```
+
+## Step 1: Create the Sandbox
+
+On your host machine:
+
+```bash
+# Create a workspace directory
+mkdir -p ~/nanoclaw-workspace
+
+# Create a shell sandbox with the workspace mounted
+docker sandbox create shell ~/nanoclaw-workspace
+```
+
+If you're using WhatsApp, configure proxy bypass so WhatsApp's Noise protocol isn't MITM-inspected:
+
+```bash
+docker sandbox network proxy shell-nanoclaw-workspace \
+ --bypass-host web.whatsapp.com \
+ --bypass-host "*.whatsapp.com" \
+ --bypass-host "*.whatsapp.net"
+```
+
+Telegram does not need proxy bypass.
+
+Enter the sandbox:
+```bash
+docker sandbox run shell-nanoclaw-workspace
+```
+
+## Step 2: Install Prerequisites
+
+Inside the sandbox:
+
+```bash
+sudo apt-get update && sudo apt-get install -y build-essential python3
+npm config set strict-ssl false
+```
+
+## Step 3: Clone and Install NanoClaw
+
+NanoClaw must live inside the workspace directory — Docker-in-Docker can only bind-mount from the shared workspace path.
+
+```bash
+# Clone to home first (virtiofs can corrupt git pack files during clone)
+cd ~
+git clone https://github.com/qwibitai/nanoclaw.git
+
+# Replace with YOUR workspace path (the host path you passed to `docker sandbox create`)
+WORKSPACE=/Users/you/nanoclaw-workspace
+
+# Move into workspace so DinD mounts work
+mv nanoclaw "$WORKSPACE/nanoclaw"
+cd "$WORKSPACE/nanoclaw"
+
+# Install dependencies
+npm install
+npm install https-proxy-agent
+```
+
+## Step 4: Apply Proxy and Sandbox Patches
+
+NanoClaw needs several patches to work inside a Docker Sandbox. These handle proxy routing, CA certificates, and Docker-in-Docker mount restrictions.
+
+### 4a. Dockerfile — proxy args for container image build
+
+`npm install` inside `docker build` fails with `SELF_SIGNED_CERT_IN_CHAIN` because the sandbox's MITM proxy presents its own certificate. Add proxy build args to `container/Dockerfile`:
+
+Add these lines after the `FROM` line:
+
+```dockerfile
+# Accept proxy build args
+ARG http_proxy
+ARG https_proxy
+ARG no_proxy
+ARG NODE_EXTRA_CA_CERTS
+ARG npm_config_strict_ssl=true
+RUN npm config set strict-ssl ${npm_config_strict_ssl}
+```
+
+And after the `RUN npm install` line:
+
+```dockerfile
+RUN npm config set strict-ssl true
+```
+
+### 4b. Build script — forward proxy args
+
+Patch `container/build.sh` to pass proxy env vars to `docker build`:
+
+Add these `--build-arg` flags to the `docker build` command:
+
+```bash
+--build-arg http_proxy="${http_proxy:-$HTTP_PROXY}" \
+--build-arg https_proxy="${https_proxy:-$HTTPS_PROXY}" \
+--build-arg no_proxy="${no_proxy:-$NO_PROXY}" \
+--build-arg npm_config_strict_ssl=false \
+```
+
+### 4c. Container runner — proxy forwarding, CA cert mount, /dev/null fix
+
+Three changes to `src/container-runner.ts`:
+
+**Replace `/dev/null` shadow mount.** The sandbox rejects `/dev/null` bind mounts. Find where `.env` is shadow-mounted to `/dev/null` and replace it with an empty file:
+
+```typescript
+// Create an empty file to shadow .env (Docker Sandbox rejects /dev/null mounts)
+const emptyEnvPath = path.join(DATA_DIR, 'empty-env');
+if (!fs.existsSync(emptyEnvPath)) fs.writeFileSync(emptyEnvPath, '');
+// Use emptyEnvPath instead of '/dev/null' in the mount
+```
+
+**Forward proxy env vars** to spawned agent containers. Add `-e` flags for `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY` and their lowercase variants.
+
+**Mount CA certificate.** If `NODE_EXTRA_CA_CERTS` or `SSL_CERT_FILE` is set, copy the cert into the project directory and mount it into agent containers:
+
+```typescript
+const caCertSrc = process.env.NODE_EXTRA_CA_CERTS || process.env.SSL_CERT_FILE;
+if (caCertSrc) {
+ const certDir = path.join(DATA_DIR, 'ca-cert');
+ fs.mkdirSync(certDir, { recursive: true });
+ fs.copyFileSync(caCertSrc, path.join(certDir, 'proxy-ca.crt'));
+ // Mount: certDir -> /workspace/ca-cert (read-only)
+ // Set NODE_EXTRA_CA_CERTS=/workspace/ca-cert/proxy-ca.crt in the container
+}
+```
+
+### 4d. Container runtime — prevent self-termination
+
+In `src/container-runtime.ts`, the `cleanupOrphans()` function matches containers by the `nanoclaw-` prefix. Inside a sandbox, the sandbox container itself may match (e.g., `nanoclaw-docker-sandbox`). Filter out the current hostname:
+
+```typescript
+// In cleanupOrphans(), filter out os.hostname() from the list of containers to stop
+```
+
+### 4e. Credential proxy — route through MITM proxy
+
+In `src/credential-proxy.ts`, upstream API requests need to go through the sandbox proxy. Add `HttpsProxyAgent` to outbound requests:
+
+```typescript
+import { HttpsProxyAgent } from 'https-proxy-agent';
+
+const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy;
+const upstreamAgent = proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
+// Pass upstreamAgent to https.request() options
+```
+
+### 4f. Setup script — proxy build args
+
+Patch `setup/container.ts` to pass the same proxy `--build-arg` flags as `build.sh` (Step 4b).
+
+## Step 5: Build
+
+```bash
+npm run build
+bash container/build.sh
+```
+
+## Step 6: Add a Channel
+
+### Telegram
+
+```bash
+# Apply the Telegram skill
+npx tsx scripts/apply-skill.ts .claude/skills/add-telegram
+
+# Rebuild after applying the skill
+npm run build
+
+# Configure .env
+cat > .env << EOF
+TELEGRAM_BOT_TOKEN=
+ASSISTANT_NAME=nanoclaw
+ANTHROPIC_API_KEY=proxy-managed
+EOF
+mkdir -p data/env && cp .env data/env/env
+
+# Register your chat
+npx tsx setup/index.ts --step register \
+ --jid "tg:" \
+ --name "My Chat" \
+ --trigger "@nanoclaw" \
+ --folder "telegram_main" \
+ --channel telegram \
+ --assistant-name "nanoclaw" \
+ --is-main \
+ --no-trigger-required
+```
+
+**To find your chat ID:** Send any message to your bot, then:
+```bash
+curl -s --proxy $HTTPS_PROXY "https://api.telegram.org/bot/getUpdates" | python3 -m json.tool
+```
+
+**Telegram in groups:** Disable Group Privacy in @BotFather (`/mybots` > Bot Settings > Group Privacy > Turn off), then remove and re-add the bot.
+
+**Important:** If the Telegram skill creates `src/channels/telegram.ts`, you'll need to patch it for proxy support. Add an `HttpsProxyAgent` and pass it to grammy's `Bot` constructor via `baseFetchConfig.agent`. Then rebuild.
+
+### WhatsApp
+
+Make sure you configured proxy bypass in [Step 1](#step-1-create-the-sandbox) first.
+
+```bash
+# Apply the WhatsApp skill
+npx tsx scripts/apply-skill.ts .claude/skills/add-whatsapp
+
+# Rebuild
+npm run build
+
+# Configure .env
+cat > .env << EOF
+ASSISTANT_NAME=nanoclaw
+ANTHROPIC_API_KEY=proxy-managed
+EOF
+mkdir -p data/env && cp .env data/env/env
+
+# Authenticate (choose one):
+
+# QR code — scan with WhatsApp camera:
+npx tsx src/whatsapp-auth.ts
+
+# OR pairing code — enter code in WhatsApp > Linked Devices > Link with phone number:
+npx tsx src/whatsapp-auth.ts --pairing-code --phone
+
+# Register your chat (JID = your phone number + @s.whatsapp.net)
+npx tsx setup/index.ts --step register \
+ --jid "@s.whatsapp.net" \
+ --name "My Chat" \
+ --trigger "@nanoclaw" \
+ --folder "whatsapp_main" \
+ --channel whatsapp \
+ --assistant-name "nanoclaw" \
+ --is-main \
+ --no-trigger-required
+```
+
+**Important:** The WhatsApp skill files (`src/channels/whatsapp.ts` and `src/whatsapp-auth.ts`) also need proxy patches — add `HttpsProxyAgent` for WebSocket connections and a proxy-aware version fetch. Then rebuild.
+
+### Both Channels
+
+Apply both skills, patch both for proxy support, combine the `.env` variables, and register each chat separately.
+
+## Step 7: Run
+
+```bash
+npm start
+```
+
+You don't need to set `ANTHROPIC_API_KEY` manually. The sandbox proxy intercepts requests and replaces `proxy-managed` with your real key automatically.
+
+## Networking Details
+
+### How the proxy works
+
+All traffic from the sandbox routes through the host proxy at `host.docker.internal:3128`:
+
+```
+Agent container → DinD bridge → Sandbox VM → host.docker.internal:3128 → Host proxy → api.anthropic.com
+```
+
+**"Bypass" does not mean traffic skips the proxy.** It means the proxy passes traffic through without MITM inspection. Node.js doesn't automatically use `HTTP_PROXY` env vars — you need explicit `HttpsProxyAgent` configuration in every HTTP/WebSocket client.
+
+### Shared paths for DinD mounts
+
+Only the workspace directory is available for Docker-in-Docker bind mounts. Paths outside the workspace fail with "path not shared":
+- `/dev/null` → replace with an empty file in the project dir
+- `/usr/local/share/ca-certificates/` → copy cert to project dir
+- `/home/agent/` → clone to workspace instead
+
+### Git clone and virtiofs
+
+The workspace is mounted via virtiofs. Git's pack file handling can corrupt over virtiofs during clone. Workaround: clone to `/home/agent` first, then `mv` into the workspace.
+
+## Troubleshooting
+
+### npm install fails with SELF_SIGNED_CERT_IN_CHAIN
+```bash
+npm config set strict-ssl false
+```
+
+### Container build fails with proxy errors
+```bash
+docker build \
+ --build-arg http_proxy=$http_proxy \
+ --build-arg https_proxy=$https_proxy \
+ -t nanoclaw-agent:latest container/
+```
+
+### Agent containers fail with "path not shared"
+All bind-mounted paths must be under the workspace directory. Check:
+- Is NanoClaw cloned into the workspace? (not `/home/agent/`)
+- Is the CA cert copied to the project root?
+- Has the empty `.env` shadow file been created?
+
+### Agent containers can't reach Anthropic API
+Verify proxy env vars are forwarded to agent containers. Check container logs for `HTTP_PROXY=http://host.docker.internal:3128`.
+
+### WhatsApp error 405
+The version fetch is returning a stale version. Make sure the proxy-aware `fetchWaVersionViaProxy` patch is applied — it fetches `sw.js` through `HttpsProxyAgent` and parses `client_revision`.
+
+### WhatsApp "Connection failed" immediately
+Proxy bypass not configured. From the **host**, run:
+```bash
+docker sandbox network proxy \
+ --bypass-host web.whatsapp.com \
+ --bypass-host "*.whatsapp.com" \
+ --bypass-host "*.whatsapp.net"
+```
+
+### Telegram bot doesn't receive messages
+1. Check the grammy proxy patch is applied (look for `HttpsProxyAgent` in `src/channels/telegram.ts`)
+2. Check Group Privacy is disabled in @BotFather if using in groups
+
+### Git clone fails with "inflate: data stream error"
+Clone to a non-workspace path first, then move:
+```bash
+cd ~ && git clone https://github.com/qwibitai/nanoclaw.git && mv nanoclaw /path/to/workspace/nanoclaw
+```
+
+### WhatsApp QR code doesn't display
+Run the auth command interactively inside the sandbox (not piped through `docker sandbox exec`):
+```bash
+docker sandbox run shell-nanoclaw-workspace
+# Then inside:
+npx tsx src/whatsapp-auth.ts
+```
From df9ba0e5f92be15e15872faece9a789c93662f53 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 13 Mar 2026 12:03:27 +0200
Subject: [PATCH 13/25] fix: correct Docker Sandboxes documentation URL
Co-Authored-By: Claude Opus 4.6
---
docs/docker-sandboxes.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/docker-sandboxes.md b/docs/docker-sandboxes.md
index 77dad83..e887bad 100644
--- a/docs/docker-sandboxes.md
+++ b/docs/docker-sandboxes.md
@@ -1,6 +1,6 @@
# Running NanoClaw in Docker Sandboxes (Manual Setup)
-This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/security/sandbox/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
+This guide walks through setting up NanoClaw inside a [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) from scratch — no install script, no pre-built fork. You'll clone the upstream repo, apply the necessary patches, and have agents running in full hypervisor-level isolation.
## Architecture
From 38ebb31e6d1916e3ab3e85bea6d94234ef34a0ec Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 13 Mar 2026 13:59:15 +0200
Subject: [PATCH 14/25] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 1e5401f..56d9331 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@
---
-
🐳 Now Running in Docker Sandboxes
+
🐳 Now Runs in Docker Sandboxes
Every agent gets its own isolated container inside a micro VM. Hypervisor-level isolation. Millisecond startup. No complex setup.
**macOS (Apple Silicon)**
From e7318be0a247b1d2cc8a47820b6ea88fa312ee6e Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Sat, 14 Mar 2026 15:16:33 +0200
Subject: [PATCH 15/25] chore: bump claude-agent-sdk to ^0.2.76
Co-Authored-By: Claude Opus 4.6
---
container/agent-runner/package-lock.json | 8 ++++----
container/agent-runner/package.json | 2 +-
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json
index 89cee2c..9ae119b 100644
--- a/container/agent-runner/package-lock.json
+++ b/container/agent-runner/package-lock.json
@@ -8,7 +8,7 @@
"name": "nanoclaw-agent-runner",
"version": "1.0.0",
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.2.34",
+ "@anthropic-ai/claude-agent-sdk": "^0.2.76",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
@@ -19,9 +19,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
- "version": "0.2.68",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.68.tgz",
- "integrity": "sha512-y4n6hTTgAqmiV/pqy1G4OgIdg6gDiAKPJaEgO1NOh7/rdsrXyc/HQoUmUy0ty4HkBq1hasm7hB92wtX3W1UMEw==",
+ "version": "0.2.76",
+ "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.76.tgz",
+ "integrity": "sha512-HZxvnT8ZWkzCnQygaYCA0dl8RSUzuVbxE1YG4ecy6vh4nQbTT36CxUxBy+QVdR12pPQluncC0mCOLhI2918Eaw==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
diff --git a/container/agent-runner/package.json b/container/agent-runner/package.json
index bf13328..42a994e 100644
--- a/container/agent-runner/package.json
+++ b/container/agent-runner/package.json
@@ -9,7 +9,7 @@
"start": "node dist/index.js"
},
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.2.34",
+ "@anthropic-ai/claude-agent-sdk": "^0.2.76",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"zod": "^4.0.0"
From 2640973b4156574ebdd8bc691b026a2f72067c6f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 14 Mar 2026 13:26:21 +0000
Subject: [PATCH 16/25] chore: bump version to 1.2.13
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 8d63787..18a369b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.12",
+ "version": "1.2.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.12",
+ "version": "1.2.13",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 5fae6f4..222ca13 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.12",
+ "version": "1.2.13",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From e2b0d2d0aa7c94ca9e7720d6cbf33133110ee88a Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Sat, 14 Mar 2026 16:59:52 +0200
Subject: [PATCH 17/25] feat: add /remote-control command for host-level Claude
Code access
Users can send /remote-control from the main group in any channel to
spawn a detached `claude remote-control` process on the host. The
session URL is sent back through the channel. /remote-control-end
kills the session.
Key design decisions:
- One global session at a time, restricted to main group only
- Process is fully detached (stdout/stderr to files, not pipes) so it
survives NanoClaw restarts
- PID + URL persisted to data/remote-control.json; restored on startup
- Commands intercepted in onMessage before DB storage
Co-Authored-By: Claude Opus 4.6 (1M context)
---
src/index.ts | 57 ++++++
src/remote-control.test.ts | 377 +++++++++++++++++++++++++++++++++++++
src/remote-control.ts | 216 +++++++++++++++++++++
3 files changed, 650 insertions(+)
create mode 100644 src/remote-control.test.ts
create mode 100644 src/remote-control.ts
diff --git a/src/index.ts b/src/index.ts
index c6295c5..504400d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -46,6 +46,11 @@ import { GroupQueue } from './group-queue.js';
import { resolveGroupFolderPath } from './group-folder.js';
import { startIpcWatcher } from './ipc.js';
import { findChannel, formatMessages, formatOutbound } from './router.js';
+import {
+ restoreRemoteControl,
+ startRemoteControl,
+ stopRemoteControl,
+} from './remote-control.js';
import {
isSenderAllowed,
isTriggerAllowed,
@@ -470,6 +475,7 @@ async function main(): Promise {
initDatabase();
logger.info('Database initialized');
loadState();
+ restoreRemoteControl();
// Start credential proxy (containers route API calls through this)
const proxyServer = await startCredentialProxy(
@@ -488,9 +494,60 @@ async function main(): Promise {
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+ // Handle /remote-control and /remote-control-end commands
+ async function handleRemoteControl(
+ command: string,
+ chatJid: string,
+ msg: NewMessage,
+ ): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group?.isMain) {
+ logger.warn(
+ { chatJid, sender: msg.sender },
+ 'Remote control rejected: not main group',
+ );
+ return;
+ }
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) return;
+
+ if (command === '/remote-control') {
+ const result = await startRemoteControl(
+ msg.sender,
+ chatJid,
+ process.cwd(),
+ );
+ if (result.ok) {
+ await channel.sendMessage(chatJid, result.url);
+ } else {
+ await channel.sendMessage(
+ chatJid,
+ `Remote Control failed: ${result.error}`,
+ );
+ }
+ } else {
+ const result = stopRemoteControl();
+ if (result.ok) {
+ await channel.sendMessage(chatJid, 'Remote Control session ended.');
+ } else {
+ await channel.sendMessage(chatJid, result.error);
+ }
+ }
+ }
+
// Channel callbacks (shared by all channels)
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
+ // Remote control commands — intercept before storage
+ const trimmed = msg.content.trim();
+ if (trimmed === '/remote-control' || trimmed === '/remote-control-end') {
+ handleRemoteControl(trimmed, chatJid, msg).catch((err) =>
+ logger.error({ err, chatJid }, 'Remote control command error'),
+ );
+ return;
+ }
+
// Sender allowlist drop mode: discard messages from denied senders before storing
if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
const cfg = loadSenderAllowlist();
diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts
new file mode 100644
index 0000000..4b5ab2f
--- /dev/null
+++ b/src/remote-control.test.ts
@@ -0,0 +1,377 @@
+import fs from 'fs';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+
+// Mock config before importing the module under test
+vi.mock('./config.js', () => ({
+ DATA_DIR: '/tmp/nanoclaw-rc-test',
+}));
+
+// Mock child_process
+const spawnMock = vi.fn();
+vi.mock('child_process', () => ({
+ spawn: (...args: any[]) => spawnMock(...args),
+}));
+
+import {
+ startRemoteControl,
+ stopRemoteControl,
+ restoreRemoteControl,
+ getActiveSession,
+ _resetForTesting,
+ _getStateFilePath,
+} from './remote-control.js';
+
+// --- Helpers ---
+
+function createMockProcess(pid = 12345) {
+ return { pid, unref: vi.fn(), kill: vi.fn() };
+}
+
+describe('remote-control', () => {
+ const STATE_FILE = _getStateFilePath();
+ let readFileSyncSpy: ReturnType;
+ let writeFileSyncSpy: ReturnType;
+ let unlinkSyncSpy: ReturnType;
+ let mkdirSyncSpy: ReturnType;
+ let openSyncSpy: ReturnType;
+ let closeSyncSpy: ReturnType;
+
+ // Track what readFileSync should return for the stdout file
+ let stdoutFileContent: string;
+
+ beforeEach(() => {
+ _resetForTesting();
+ spawnMock.mockReset();
+ stdoutFileContent = '';
+
+ // Default fs mocks
+ mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any);
+ writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {});
+ unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {});
+ openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any);
+ closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {});
+
+ // readFileSync: return stdoutFileContent for the stdout file, state file, etc.
+ readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.stdout')) return stdoutFileContent;
+ if (p.endsWith('remote-control.json')) {
+ throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
+ }
+ return '';
+ }) as any);
+ });
+
+ afterEach(() => {
+ _resetForTesting();
+ vi.restoreAllMocks();
+ });
+
+ // --- startRemoteControl ---
+
+ describe('startRemoteControl', () => {
+ it('spawns claude remote-control and returns the URL', async () => {
+ const proc = createMockProcess();
+ spawnMock.mockReturnValue(proc);
+
+ // Simulate URL appearing in stdout file on first poll
+ stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
+
+ expect(result).toEqual({
+ ok: true,
+ url: 'https://claude.ai/code?bridge=env_abc123',
+ });
+ expect(spawnMock).toHaveBeenCalledWith(
+ 'claude',
+ ['remote-control', '--name', 'NanoClaw Remote'],
+ expect.objectContaining({ cwd: '/project', detached: true }),
+ );
+ expect(proc.unref).toHaveBeenCalled();
+ });
+
+ it('uses file descriptors for stdout/stderr (not pipes)', async () => {
+ const proc = createMockProcess();
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ const spawnCall = spawnMock.mock.calls[0];
+ const options = spawnCall[2];
+ // stdio should use file descriptors (numbers), not 'pipe'
+ expect(options.stdio[0]).toBe('ignore');
+ expect(typeof options.stdio[1]).toBe('number');
+ expect(typeof options.stdio[2]).toBe('number');
+ });
+
+ it('closes file descriptors in parent after spawn', async () => {
+ const proc = createMockProcess();
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_test\n';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ // Two openSync calls (stdout + stderr), two closeSync calls
+ expect(openSyncSpy).toHaveBeenCalledTimes(2);
+ expect(closeSyncSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('saves state to disk after capturing URL', async () => {
+ const proc = createMockProcess(99999);
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_save\n';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ expect(writeFileSyncSpy).toHaveBeenCalledWith(
+ STATE_FILE,
+ expect.stringContaining('"pid":99999'),
+ );
+ });
+
+ it('returns existing URL if session is already active', async () => {
+ const proc = createMockProcess();
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_existing\n';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ // Second call should return existing URL without spawning
+ const result = await startRemoteControl('user2', 'tg:456', '/project');
+ expect(result).toEqual({
+ ok: true,
+ url: 'https://claude.ai/code?bridge=env_existing',
+ });
+ expect(spawnMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('starts new session if existing process is dead', async () => {
+ const proc1 = createMockProcess(11111);
+ const proc2 = createMockProcess(22222);
+ spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
+
+ // First start: process alive, URL found
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n';
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ // Old process (11111) is dead, new process (22222) is alive
+ killSpy.mockImplementation(((pid: number, sig: any) => {
+ if (pid === 11111 && (sig === 0 || sig === undefined)) {
+ throw new Error('ESRCH');
+ }
+ return true;
+ }) as any);
+
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_second\n';
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
+
+ expect(result).toEqual({
+ ok: true,
+ url: 'https://claude.ai/code?bridge=env_second',
+ });
+ expect(spawnMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns error if process exits before URL', async () => {
+ const proc = createMockProcess(33333);
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = '';
+
+ // Process is dead (poll will detect this)
+ vi.spyOn(process, 'kill').mockImplementation((() => {
+ throw new Error('ESRCH');
+ }) as any);
+
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
+ expect(result).toEqual({
+ ok: false,
+ error: 'Process exited before producing URL',
+ });
+ });
+
+ it('times out if URL never appears', async () => {
+ vi.useFakeTimers();
+ const proc = createMockProcess(44444);
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'no url here';
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ const promise = startRemoteControl('user1', 'tg:123', '/project');
+
+ // Advance past URL_TIMEOUT_MS (30s), with enough steps for polls
+ for (let i = 0; i < 160; i++) {
+ await vi.advanceTimersByTimeAsync(200);
+ }
+
+ const result = await promise;
+ expect(result).toEqual({
+ ok: false,
+ error: 'Timed out waiting for Remote Control URL',
+ });
+
+ vi.useRealTimers();
+ });
+
+ it('returns error if spawn throws', async () => {
+ spawnMock.mockImplementation(() => {
+ throw new Error('ENOENT');
+ });
+
+ const result = await startRemoteControl('user1', 'tg:123', '/project');
+ expect(result).toEqual({
+ ok: false,
+ error: 'Failed to start: ENOENT',
+ });
+ });
+ });
+
+ // --- stopRemoteControl ---
+
+ describe('stopRemoteControl', () => {
+ it('kills the process and clears state', async () => {
+ const proc = createMockProcess(55555);
+ spawnMock.mockReturnValue(proc);
+ stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n';
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ await startRemoteControl('user1', 'tg:123', '/project');
+
+ const result = stopRemoteControl();
+ expect(result).toEqual({ ok: true });
+ expect(killSpy).toHaveBeenCalledWith(55555, 'SIGTERM');
+ expect(unlinkSyncSpy).toHaveBeenCalledWith(STATE_FILE);
+ expect(getActiveSession()).toBeNull();
+ });
+
+ it('returns error when no session is active', () => {
+ const result = stopRemoteControl();
+ expect(result).toEqual({
+ ok: false,
+ error: 'No active Remote Control session',
+ });
+ });
+ });
+
+ // --- restoreRemoteControl ---
+
+ describe('restoreRemoteControl', () => {
+ it('restores session if state file exists and process is alive', () => {
+ const session = {
+ pid: 77777,
+ url: 'https://claude.ai/code?bridge=env_restored',
+ startedBy: 'user1',
+ startedInChat: 'tg:123',
+ startedAt: '2026-01-01T00:00:00.000Z',
+ };
+ readFileSyncSpy.mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
+ return '';
+ }) as any);
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ restoreRemoteControl();
+
+ const active = getActiveSession();
+ expect(active).not.toBeNull();
+ expect(active!.pid).toBe(77777);
+ expect(active!.url).toBe('https://claude.ai/code?bridge=env_restored');
+ });
+
+ it('clears state if process is dead', () => {
+ const session = {
+ pid: 88888,
+ url: 'https://claude.ai/code?bridge=env_dead',
+ startedBy: 'user1',
+ startedInChat: 'tg:123',
+ startedAt: '2026-01-01T00:00:00.000Z',
+ };
+ readFileSyncSpy.mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
+ return '';
+ }) as any);
+ vi.spyOn(process, 'kill').mockImplementation((() => {
+ throw new Error('ESRCH');
+ }) as any);
+
+ restoreRemoteControl();
+
+ expect(getActiveSession()).toBeNull();
+ expect(unlinkSyncSpy).toHaveBeenCalled();
+ });
+
+ it('does nothing if no state file exists', () => {
+ // readFileSyncSpy default throws ENOENT for .json
+ restoreRemoteControl();
+ expect(getActiveSession()).toBeNull();
+ });
+
+ it('clears state on corrupted JSON', () => {
+ readFileSyncSpy.mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.json')) return 'not json{{{';
+ return '';
+ }) as any);
+
+ restoreRemoteControl();
+
+ expect(getActiveSession()).toBeNull();
+ expect(unlinkSyncSpy).toHaveBeenCalled();
+ });
+
+ // ** This is the key integration test: restore → stop must work **
+ it('stopRemoteControl works after restoreRemoteControl', () => {
+ const session = {
+ pid: 77777,
+ url: 'https://claude.ai/code?bridge=env_restored',
+ startedBy: 'user1',
+ startedInChat: 'tg:123',
+ startedAt: '2026-01-01T00:00:00.000Z',
+ };
+ readFileSyncSpy.mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
+ return '';
+ }) as any);
+ const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ restoreRemoteControl();
+ expect(getActiveSession()).not.toBeNull();
+
+ const result = stopRemoteControl();
+ expect(result).toEqual({ ok: true });
+ expect(killSpy).toHaveBeenCalledWith(77777, 'SIGTERM');
+ expect(unlinkSyncSpy).toHaveBeenCalled();
+ expect(getActiveSession()).toBeNull();
+ });
+
+ it('startRemoteControl returns restored URL without spawning', () => {
+ const session = {
+ pid: 77777,
+ url: 'https://claude.ai/code?bridge=env_restored',
+ startedBy: 'user1',
+ startedInChat: 'tg:123',
+ startedAt: '2026-01-01T00:00:00.000Z',
+ };
+ readFileSyncSpy.mockImplementation(((p: string) => {
+ if (p.endsWith('remote-control.json')) return JSON.stringify(session);
+ return '';
+ }) as any);
+ vi.spyOn(process, 'kill').mockImplementation((() => true) as any);
+
+ restoreRemoteControl();
+
+ return startRemoteControl('user2', 'tg:456', '/project').then((result) => {
+ expect(result).toEqual({
+ ok: true,
+ url: 'https://claude.ai/code?bridge=env_restored',
+ });
+ expect(spawnMock).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/src/remote-control.ts b/src/remote-control.ts
new file mode 100644
index 0000000..df8f646
--- /dev/null
+++ b/src/remote-control.ts
@@ -0,0 +1,216 @@
+import { spawn } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import { DATA_DIR } from './config.js';
+import { logger } from './logger.js';
+
+interface RemoteControlSession {
+ pid: number;
+ url: string;
+ startedBy: string;
+ startedInChat: string;
+ startedAt: string;
+}
+
+let activeSession: RemoteControlSession | null = null;
+
+const URL_REGEX = /https:\/\/claude\.ai\/code\S+/;
+const URL_TIMEOUT_MS = 30_000;
+const URL_POLL_MS = 200;
+const STATE_FILE = path.join(DATA_DIR, 'remote-control.json');
+const STDOUT_FILE = path.join(DATA_DIR, 'remote-control.stdout');
+const STDERR_FILE = path.join(DATA_DIR, 'remote-control.stderr');
+
+function saveState(session: RemoteControlSession): void {
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
+ fs.writeFileSync(STATE_FILE, JSON.stringify(session));
+}
+
+function clearState(): void {
+ try {
+ fs.unlinkSync(STATE_FILE);
+ } catch {
+ // ignore
+ }
+}
+
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Restore session from disk on startup.
+ * If the process is still alive, adopt it. Otherwise, clean up.
+ */
+export function restoreRemoteControl(): void {
+ let data: string;
+ try {
+ data = fs.readFileSync(STATE_FILE, 'utf-8');
+ } catch {
+ return;
+ }
+
+ try {
+ const session: RemoteControlSession = JSON.parse(data);
+ if (session.pid && isProcessAlive(session.pid)) {
+ activeSession = session;
+ logger.info(
+ { pid: session.pid, url: session.url },
+ 'Restored Remote Control session from previous run',
+ );
+ } else {
+ clearState();
+ }
+ } catch {
+ clearState();
+ }
+}
+
+export function getActiveSession(): RemoteControlSession | null {
+ return activeSession;
+}
+
+/** @internal — exported for testing only */
+export function _resetForTesting(): void {
+ activeSession = null;
+}
+
+/** @internal — exported for testing only */
+export function _getStateFilePath(): string {
+ return STATE_FILE;
+}
+
+export async function startRemoteControl(
+ sender: string,
+ chatJid: string,
+ cwd: string,
+): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
+ if (activeSession) {
+ // Verify the process is still alive
+ if (isProcessAlive(activeSession.pid)) {
+ return { ok: true, url: activeSession.url };
+ }
+ // Process died — clean up and start a new one
+ activeSession = null;
+ clearState();
+ }
+
+ // Redirect stdout/stderr to files so the process has no pipes to the parent.
+ // This prevents SIGPIPE when NanoClaw restarts.
+ fs.mkdirSync(DATA_DIR, { recursive: true });
+ const stdoutFd = fs.openSync(STDOUT_FILE, 'w');
+ const stderrFd = fs.openSync(STDERR_FILE, 'w');
+
+ let proc;
+ try {
+ proc = spawn('claude', ['remote-control', '--name', 'NanoClaw Remote'], {
+ cwd,
+ stdio: ['ignore', stdoutFd, stderrFd],
+ detached: true,
+ });
+ } catch (err: any) {
+ fs.closeSync(stdoutFd);
+ fs.closeSync(stderrFd);
+ return { ok: false, error: `Failed to start: ${err.message}` };
+ }
+
+ // Close FDs in the parent — the child inherited copies
+ fs.closeSync(stdoutFd);
+ fs.closeSync(stderrFd);
+
+ // Fully detach from parent
+ proc.unref();
+
+ const pid = proc.pid;
+ if (!pid) {
+ return { ok: false, error: 'Failed to get process PID' };
+ }
+
+ // Poll the stdout file for the URL
+ return new Promise((resolve) => {
+ const startTime = Date.now();
+
+ const poll = () => {
+ // Check if process died
+ if (!isProcessAlive(pid)) {
+ resolve({ ok: false, error: 'Process exited before producing URL' });
+ return;
+ }
+
+ // Check for URL in stdout file
+ let content = '';
+ try {
+ content = fs.readFileSync(STDOUT_FILE, 'utf-8');
+ } catch {
+ // File might not have content yet
+ }
+
+ const match = content.match(URL_REGEX);
+ if (match) {
+ const session: RemoteControlSession = {
+ pid,
+ url: match[0],
+ startedBy: sender,
+ startedInChat: chatJid,
+ startedAt: new Date().toISOString(),
+ };
+ activeSession = session;
+ saveState(session);
+
+ logger.info(
+ { url: match[0], pid, sender, chatJid },
+ 'Remote Control session started',
+ );
+ resolve({ ok: true, url: match[0] });
+ return;
+ }
+
+ // Timeout check
+ if (Date.now() - startTime >= URL_TIMEOUT_MS) {
+ try {
+ process.kill(-pid, 'SIGTERM');
+ } catch {
+ try {
+ process.kill(pid, 'SIGTERM');
+ } catch {
+ // already dead
+ }
+ }
+ resolve({
+ ok: false,
+ error: 'Timed out waiting for Remote Control URL',
+ });
+ return;
+ }
+
+ setTimeout(poll, URL_POLL_MS);
+ };
+
+ poll();
+ });
+}
+
+export function stopRemoteControl(): {
+ ok: true;
+} | { ok: false; error: string } {
+ if (!activeSession) {
+ return { ok: false, error: 'No active Remote Control session' };
+ }
+
+ const { pid } = activeSession;
+ try {
+ process.kill(pid, 'SIGTERM');
+ } catch {
+ // already dead
+ }
+ activeSession = null;
+ clearState();
+ logger.info({ pid }, 'Remote Control session stopped');
+ return { ok: true };
+}
From 9b82611dc1396b20f90cb88342a99aa1363dcf39 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 14 Mar 2026 15:23:57 +0000
Subject: [PATCH 18/25] chore: bump version to 1.2.14
---
package-lock.json | 4 ++--
package.json | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 18a369b..04b06a2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.13",
+ "version": "1.2.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.13",
+ "version": "1.2.14",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 222ca13..76d9bfa 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.13",
+ "version": "1.2.14",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From fb66428eeb7561b663128d7712837a333a6c0b0d Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sat, 14 Mar 2026 15:24:00 +0000
Subject: [PATCH 19/25] =?UTF-8?q?docs:=20update=20token=20count=20to=2040.?=
=?UTF-8?q?4k=20tokens=20=C2=B7=2020%=20of=20context=20window?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
repo-tokens/badge.svg | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg
index c3810cb..4fd94b8 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-