From d48ef911f2cdb84668d3790cbb3538bb80e5a463 Mon Sep 17 00:00:00 2001
From: "Jay. (neocode24)"
Date: Sun, 1 Mar 2026 14:16:30 +0900
Subject: [PATCH 001/246] fix: add CJK font support for Chromium screenshots
Add fonts-noto-cjk to the container image so that Chromium renders
CJK (Chinese, Japanese, Korean) text correctly in screenshots and
PDF exports. Without this, CJK characters appear as empty rectangles.
Co-Authored-By: Claude Opus 4.6
---
container/Dockerfile | 1 +
1 file changed, 1 insertion(+)
diff --git a/container/Dockerfile b/container/Dockerfile
index 58e1acd..c35d3a4 100644
--- a/container/Dockerfile
+++ b/container/Dockerfile
@@ -7,6 +7,7 @@ FROM node:22-slim
RUN apt-get update && apt-get install -y \
chromium \
fonts-liberation \
+ fonts-noto-cjk \
fonts-noto-color-emoji \
libgbm1 \
libnss3 \
From 3475e89406691cd7cdc61e6f7eea9697360670c8 Mon Sep 17 00:00:00 2001
From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com>
Date: Sun, 1 Mar 2026 13:19:07 -0800
Subject: [PATCH 002/246] skill: add /update-nanoclaw for syncing customized
installs with upstream (#217)
Merge-first workflow that previews upstream changes, lets user choose
full merge / cherry-pick / rebase / abort, resolves only conflicted
files, and validates with build + test.
---
.claude/skills/update-nanoclaw/SKILL.md | 196 ++++++++++++++++++++++++
1 file changed, 196 insertions(+)
create mode 100644 .claude/skills/update-nanoclaw/SKILL.md
diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md
new file mode 100644
index 0000000..ea74e3a
--- /dev/null
+++ b/.claude/skills/update-nanoclaw/SKILL.md
@@ -0,0 +1,196 @@
+---
+name: update-nanoclaw
+description: Efficiently bring upstream NanoClaw updates into a customized install, with preview, selective cherry-pick, and low token usage.
+---
+
+# About
+
+Your NanoClaw fork drifts from upstream as you customize it. This skill pulls upstream changes into your install without losing your modifications.
+
+Run `/update-nanoclaw` in Claude Code.
+
+## How it works
+
+**Preflight**: checks for clean working tree (`git status --porcelain`). If `upstream` remote is missing, asks you for the URL (defaults to `https://github.com/qwibitai/nanoclaw.git`) and adds it. Detects the upstream branch name (`main` or `master`).
+
+**Backup**: creates a timestamped backup branch and tag (`backup/pre-update--`, `pre-update--`) before touching anything. Safe to run multiple times.
+
+**Preview**: runs `git log` and `git diff` against the merge base to show upstream changes since your last sync. Groups changed files into categories:
+- **Skills** (`.claude/skills/`): unlikely to conflict unless you edited an upstream skill
+- **Source** (`src/`): may conflict if you modified the same files
+- **Build/config** (`package.json`, `tsconfig*.json`, `container/`): review needed
+
+**Update paths** (you pick one):
+- `merge` (default): `git merge upstream/`. Resolves all conflicts in one pass.
+- `cherry-pick`: `git cherry-pick `. Pull in only the commits you want.
+- `rebase`: `git rebase upstream/`. Linear history, but conflicts resolve per-commit.
+- `abort`: just view the changelog, change nothing.
+
+**Conflict preview**: before merging, runs a dry-run (`git merge --no-commit --no-ff`) to show which files would conflict. You can still abort at this point.
+
+**Conflict resolution**: opens only conflicted files, resolves the conflict markers, keeps your local customizations intact.
+
+**Validation**: runs `npm run build` and `npm test`.
+
+## Rollback
+
+The backup tag is printed at the end of each run:
+```
+git reset --hard pre-update--
+```
+
+Backup branch `backup/pre-update--` also exists.
+
+## Token usage
+
+Only opens files with actual conflicts. Uses `git log`, `git diff`, and `git status` for everything else. Does not scan or refactor unrelated code.
+
+---
+
+# Goal
+Help a user with a customized NanoClaw install safely incorporate upstream changes without a fresh reinstall and without blowing tokens.
+
+# Operating principles
+- Never proceed with a dirty working tree.
+- Always create a rollback point (backup branch + tag) before touching anything.
+- Prefer git-native operations (fetch, merge, cherry-pick). Do not manually rewrite files except conflict markers.
+- Default to MERGE (one-pass conflict resolution). Offer REBASE as an explicit option.
+- Keep token usage low: rely on `git status`, `git log`, `git diff`, and open only conflicted files.
+
+# Step 0: Preflight (stop early if unsafe)
+Run:
+- `git status --porcelain`
+If output is non-empty:
+- Tell the user to commit or stash first, then stop.
+
+Confirm remotes:
+- `git remote -v`
+If `upstream` is missing:
+- Ask the user for the upstream repo URL (default: `https://github.com/qwibitai/nanoclaw.git`).
+- Add it: `git remote add upstream `
+- Then: `git fetch upstream --prune`
+
+Determine the upstream branch name:
+- `git branch -r | grep upstream/`
+- If `upstream/main` exists, use `main`.
+- If only `upstream/master` exists, use `master`.
+- Otherwise, ask the user which branch to use.
+- Store this as UPSTREAM_BRANCH for all subsequent commands. Every command below that references `upstream/main` should use `upstream/$UPSTREAM_BRANCH` instead.
+
+Fetch:
+- `git fetch upstream --prune`
+
+# Step 1: Create a safety net
+Capture current state:
+- `HASH=$(git rev-parse --short HEAD)`
+- `TIMESTAMP=$(date +%Y%m%d-%H%M%S)`
+
+Create backup branch and tag (using timestamp to avoid collisions on retry):
+- `git branch backup/pre-update-$HASH-$TIMESTAMP`
+- `git tag pre-update-$HASH-$TIMESTAMP`
+
+Save the tag name for later reference in the summary and rollback instructions.
+
+# Step 2: Preview what upstream changed (no edits yet)
+Compute common base:
+- `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
+
+Show upstream commits since BASE:
+- `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
+
+Show local commits since BASE (custom drift):
+- `git log --oneline $BASE..HEAD`
+
+Show file-level impact from upstream:
+- `git diff --name-only $BASE..upstream/$UPSTREAM_BRANCH`
+
+Bucket the upstream changed files:
+- **Skills** (`.claude/skills/`): unlikely to conflict unless the user edited an upstream skill
+- **Source** (`src/`): may conflict if user modified the same files
+- **Build/config** (`package.json`, `package-lock.json`, `tsconfig*.json`, `container/`, `launchd/`): review needed
+- **Other**: docs, tests, misc
+
+Present these buckets to the user and ask them to choose one path using AskUserQuestion:
+- A) **Full update**: merge all upstream changes
+- B) **Selective update**: cherry-pick specific upstream commits
+- C) **Abort**: they only wanted the preview
+- D) **Rebase mode**: advanced, linear history (warn: resolves conflicts per-commit)
+
+If Abort: stop here.
+
+# Step 3: Conflict preview (before committing anything)
+If Full update or Rebase:
+- Dry-run merge to preview conflicts. Run these as a single chained command so the abort always executes:
+ ```
+ git merge --no-commit --no-ff upstream/$UPSTREAM_BRANCH; git diff --name-only --diff-filter=U; git merge --abort
+ ```
+- If conflicts were listed: show them and ask user if they want to proceed.
+- If no conflicts: tell user it is clean and proceed.
+
+# Step 4A: Full update (MERGE, default)
+Run:
+- `git merge upstream/$UPSTREAM_BRANCH --no-edit`
+
+If conflicts occur:
+- Run `git status` and identify conflicted files.
+- For each conflicted file:
+ - Open the file.
+ - Resolve only conflict markers.
+ - Preserve intentional local customizations.
+ - Incorporate upstream fixes/improvements.
+ - Do not refactor surrounding code.
+ - `git add `
+- When all resolved:
+ - If merge did not auto-commit: `git commit --no-edit`
+
+# Step 4B: Selective update (CHERRY-PICK)
+If user chose Selective:
+- Recompute BASE if needed: `BASE=$(git merge-base HEAD upstream/$UPSTREAM_BRANCH)`
+- Show commit list again: `git log --oneline $BASE..upstream/$UPSTREAM_BRANCH`
+- Ask user which commit hashes they want.
+- Apply: `git cherry-pick ...`
+
+If conflicts during cherry-pick:
+- Resolve only conflict markers, then:
+ - `git add `
+ - `git cherry-pick --continue`
+If user wants to stop:
+ - `git cherry-pick --abort`
+
+# Step 4C: Rebase (only if user explicitly chose option D)
+Run:
+- `git rebase upstream/$UPSTREAM_BRANCH`
+
+If conflicts:
+- Resolve conflict markers only, then:
+ - `git add `
+ - `git rebase --continue`
+If it gets messy (more than 3 rounds of conflicts):
+ - `git rebase --abort`
+ - Recommend merge instead.
+
+# Step 5: Validation
+Run:
+- `npm run build`
+- `npm test` (do not fail the flow if tests are not configured)
+
+If build fails:
+- Show the error.
+- Only fix issues clearly caused by the merge (missing imports, type mismatches from merged code).
+- Do not refactor unrelated code.
+- If unclear, ask the user before making changes.
+
+# Step 6: Summary + rollback instructions
+Show:
+- Backup tag: the tag name created in Step 1
+- New HEAD: `git rev-parse --short HEAD`
+- Upstream HEAD: `git rev-parse --short upstream/$UPSTREAM_BRANCH`
+- Conflicts resolved (list files, if any)
+- Remaining local diff vs upstream: `git diff --name-only upstream/$UPSTREAM_BRANCH..HEAD`
+
+Tell the user:
+- To rollback: `git reset --hard `
+- Backup branch also exists: `backup/pre-update--`
+- Restart the service to apply changes:
+ - If using launchd: `launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist && launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist`
+ - If running manually: restart `npm run dev`
From 80cdd23c845d9ac379ee930fdf9d58f2f2d062e2 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Sun, 1 Mar 2026 23:23:31 +0200
Subject: [PATCH 003/246] chore: remove old /update skill, replaced by
/update-nanoclaw
The new /update-nanoclaw skill (PR #217) replaces the old update
mechanism. Delete the old skill, update module, CLI scripts, and tests.
Co-Authored-By: Claude Opus 4.6
---
.claude/skills/update/SKILL.md | 171 -------
.../skills/update/scripts/fetch-upstream.sh | 84 ----
CLAUDE.md | 2 +-
scripts/post-update.ts | 5 -
scripts/update-core.ts | 61 ---
.../__tests__/fetch-upstream.test.ts | 240 ----------
.../__tests__/update-core-cli.test.ts | 137 ------
skills-engine/__tests__/update.test.ts | 418 ------------------
skills-engine/apply.ts | 2 +-
skills-engine/index.ts | 3 -
skills-engine/types.ts | 20 -
skills-engine/update.ts | 355 ---------------
12 files changed, 2 insertions(+), 1496 deletions(-)
delete mode 100644 .claude/skills/update/SKILL.md
delete mode 100755 .claude/skills/update/scripts/fetch-upstream.sh
delete mode 100644 scripts/post-update.ts
delete mode 100644 scripts/update-core.ts
delete mode 100644 skills-engine/__tests__/fetch-upstream.test.ts
delete mode 100644 skills-engine/__tests__/update-core-cli.test.ts
delete mode 100644 skills-engine/__tests__/update.test.ts
delete mode 100644 skills-engine/update.ts
diff --git a/.claude/skills/update/SKILL.md b/.claude/skills/update/SKILL.md
deleted file mode 100644
index 7f4fc02..0000000
--- a/.claude/skills/update/SKILL.md
+++ /dev/null
@@ -1,171 +0,0 @@
----
-name: update
-description: "Update NanoClaw from upstream. Fetches latest changes, merges with your customizations and skills, runs migrations. Triggers on \"update\", \"pull upstream\", \"sync with upstream\", \"get latest changes\"."
----
-
-# Update NanoClaw
-
-Pull upstream changes and merge them with the user's installation, preserving skills and customizations. Scripts live in `.claude/skills/update/scripts/`.
-
-**Principle:** Handle everything automatically. Only pause for user confirmation before applying changes, or when merge conflicts need human judgment.
-
-**UX Note:** Use `AskUserQuestion` for all user-facing questions.
-
-## 1. Pre-flight
-
-Check that the skills system is initialized:
-
-```bash
-test -d .nanoclaw && echo "INITIALIZED" || echo "NOT_INITIALIZED"
-```
-
-**If NOT_INITIALIZED:** Run `initSkillsSystem()` first:
-
-```bash
-npx tsx -e "import { initNanoclawDir } from './skills-engine/init.js'; initNanoclawDir();"
-```
-
-Check for uncommitted git changes:
-
-```bash
-git status --porcelain
-```
-
-**If there are uncommitted changes:** Warn the user: "You have uncommitted changes. It's recommended to commit or stash them before updating. Continue anyway?" Use `AskUserQuestion` with options: "Continue anyway", "Abort (I'll commit first)". If they abort, stop here.
-
-## 2. Fetch upstream
-
-Run the fetch script:
-
-```bash
-./.claude/skills/update/scripts/fetch-upstream.sh
-```
-
-Parse the structured status block between `<<< STATUS` and `STATUS >>>` markers. Extract:
-- `TEMP_DIR` — path to extracted upstream files
-- `REMOTE` — which git remote was used
-- `CURRENT_VERSION` — version from local `package.json`
-- `NEW_VERSION` — version from upstream `package.json`
-- `STATUS` — "success" or "error"
-
-**If STATUS=error:** Show the error output and stop.
-
-**If CURRENT_VERSION equals NEW_VERSION:** Tell the user they're already up to date. Ask if they want to force the update anyway (there may be non-version-bumped changes). If no, clean up the temp dir and stop.
-
-## 3. Preview
-
-Run the preview to show what will change:
-
-```bash
-npx tsx scripts/update-core.ts --json --preview-only
-```
-
-This outputs JSON with: `currentVersion`, `newVersion`, `filesChanged`, `filesDeleted`, `conflictRisk`, `customPatchesAtRisk`.
-
-Present to the user:
-- "Updating from **{currentVersion}** to **{newVersion}**"
-- "{N} files will be changed" — list them if <= 20, otherwise summarize
-- If `conflictRisk` is non-empty: "These files have skill modifications and may conflict: {list}"
-- If `customPatchesAtRisk` is non-empty: "These custom patches may need re-application: {list}"
-- If `filesDeleted` is non-empty: "{N} files will be removed"
-
-## 4. Confirm
-
-Use `AskUserQuestion`: "Apply this update?" with options:
-- "Yes, apply update"
-- "No, cancel"
-
-If cancelled, clean up the temp dir (`rm -rf `) and stop.
-
-## 5. Apply
-
-Run the update:
-
-```bash
-npx tsx scripts/update-core.ts --json
-```
-
-Parse the JSON output. The result has: `success`, `previousVersion`, `newVersion`, `mergeConflicts`, `backupPending`, `customPatchFailures`, `skillReapplyResults`, `error`.
-
-**If success=true with no issues:** Continue to step 7.
-
-**If customPatchFailures exist:** Warn the user which custom patches failed to re-apply. These may need manual attention after the update.
-
-**If skillReapplyResults has false entries:** Warn the user which skill tests failed after re-application.
-
-## 6. Handle conflicts
-
-**If backupPending=true:** There are unresolved merge conflicts.
-
-For each file in `mergeConflicts`:
-1. Read the file — it contains conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)
-2. Check if there's an intent file for this path in any applied skill (e.g., `.claude/skills//modify/.intent.md`)
-3. Use the intent file and your understanding of the codebase to resolve the conflict
-4. Write the resolved file
-
-After resolving all conflicts:
-
-```bash
-npx tsx scripts/post-update.ts
-```
-
-This clears the backup, confirming the resolution.
-
-**If you cannot confidently resolve a conflict:** Show the user the conflicting sections and ask them to choose or provide guidance.
-
-## 7. Run migrations
-
-Run migrations between the old and new versions:
-
-```bash
-npx tsx scripts/run-migrations.ts
-```
-
-Parse the JSON output. It contains: `migrationsRun` (count), `results` (array of `{version, success, error?}`).
-
-**If any migration fails:** Show the error to the user. The update itself is already applied — the migration failure needs manual attention.
-
-**If no migrations found:** This is normal (most updates won't have migrations). Continue silently.
-
-## 8. Verify
-
-Run build and tests:
-
-```bash
-npm run build && npm test
-```
-
-**If build fails:** Show the error. Common causes:
-- Type errors from merged files — read the error, fix the file, retry
-- Missing dependencies — run `npm install` first, retry
-
-**If tests fail:** Show which tests failed. Try to diagnose and fix. If you can't fix automatically, report to the user.
-
-**If both pass:** Report success.
-
-## 9. Cleanup
-
-Remove the temp directory:
-
-```bash
-rm -rf
-```
-
-Report final status:
-- "Updated from **{previousVersion}** to **{newVersion}**"
-- Number of files changed
-- Any warnings (failed custom patches, failed skill tests, migration issues)
-- Build and test status
-
-## Troubleshooting
-
-**No upstream remote:** The fetch script auto-adds `upstream` pointing to `https://github.com/qwibitai/nanoclaw.git`. If the user forked from a different URL, they should set the remote manually: `git remote add upstream `.
-
-**Merge conflicts in many files:** Consider whether the user has heavily customized core files. Suggest using the skills system for modifications instead of direct edits, as skills survive updates better.
-
-**Build fails after update:** Check if `package.json` dependencies changed. Run `npm install` to pick up new dependencies.
-
-**Rollback:** If something goes wrong after applying but before cleanup, the backup is still in `.nanoclaw/backup/`. Run:
-```bash
-npx tsx -e "import { restoreBackup, clearBackup } from './skills-engine/backup.js'; restoreBackup(); clearBackup();"
-```
diff --git a/.claude/skills/update/scripts/fetch-upstream.sh b/.claude/skills/update/scripts/fetch-upstream.sh
deleted file mode 100755
index 76bc783..0000000
--- a/.claude/skills/update/scripts/fetch-upstream.sh
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-# Fetch upstream NanoClaw and extract to a temp directory.
-# Outputs a structured status block for machine parsing.
-
-SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
-PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
-cd "$PROJECT_ROOT"
-
-# Determine the correct remote
-REMOTE=""
-if git remote get-url upstream &>/dev/null; then
- REMOTE="upstream"
-elif git remote get-url origin &>/dev/null; then
- ORIGIN_URL=$(git remote get-url origin)
- if echo "$ORIGIN_URL" | grep -q "qwibitai/nanoclaw"; then
- REMOTE="origin"
- fi
-fi
-
-if [ -z "$REMOTE" ]; then
- echo "No upstream remote found. Adding upstream → https://github.com/qwibitai/nanoclaw.git"
- git remote add upstream https://github.com/qwibitai/nanoclaw.git
- REMOTE="upstream"
-fi
-
-echo "Fetching from $REMOTE..."
-if ! git fetch "$REMOTE" main 2>&1; then
- echo "<<< STATUS"
- echo "STATUS=error"
- echo "ERROR=Failed to fetch from $REMOTE"
- echo "STATUS >>>"
- exit 1
-fi
-
-# Get current version from local package.json
-CURRENT_VERSION="unknown"
-if [ -f package.json ]; then
- CURRENT_VERSION=$(node -e "console.log(require('./package.json').version || 'unknown')")
-fi
-
-# Create temp dir and extract only the paths the skills engine tracks.
-# Read BASE_INCLUDES from the single source of truth in skills-engine/constants.ts,
-# plus always include migrations/ for the migration runner.
-TEMP_DIR=$(mktemp -d /tmp/nanoclaw-update-XXXX)
-trap 'rm -rf "$TEMP_DIR"' ERR
-echo "Extracting $REMOTE/main to $TEMP_DIR..."
-
-CANDIDATES=$(node -e "
- const fs = require('fs');
- const src = fs.readFileSync('skills-engine/constants.ts', 'utf-8');
- const m = src.match(/BASE_INCLUDES\s*=\s*\[([^\]]+)\]/);
- if (!m) { console.error('Cannot parse BASE_INCLUDES'); process.exit(1); }
- const paths = m[1].match(/'([^']+)'/g).map(s => s.replace(/'/g, ''));
- paths.push('migrations/');
- console.log(paths.join(' '));
-")
-
-# Filter to paths that actually exist in the upstream tree.
-# git archive errors if a path doesn't exist, so we check first.
-PATHS=""
-for candidate in $CANDIDATES; do
- if [ -n "$(git ls-tree --name-only "$REMOTE/main" "$candidate" 2>/dev/null)" ]; then
- PATHS="$PATHS $candidate"
- fi
-done
-
-git archive "$REMOTE/main" -- $PATHS | tar -x -C "$TEMP_DIR"
-
-# Get new version from extracted package.json
-NEW_VERSION="unknown"
-if [ -f "$TEMP_DIR/package.json" ]; then
- NEW_VERSION=$(node -e "console.log(require('$TEMP_DIR/package.json').version || 'unknown')")
-fi
-
-echo ""
-echo "<<< STATUS"
-echo "TEMP_DIR=$TEMP_DIR"
-echo "REMOTE=$REMOTE"
-echo "CURRENT_VERSION=$CURRENT_VERSION"
-echo "NEW_VERSION=$NEW_VERSION"
-echo "STATUS=success"
-echo "STATUS >>>"
diff --git a/CLAUDE.md b/CLAUDE.md
index 010adb0..d0ae601 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -28,7 +28,7 @@ Single Node.js process that connects to WhatsApp, routes messages to Claude Agen
| `/setup` | First-time installation, authentication, service configuration |
| `/customize` | Adding channels, integrations, changing behavior |
| `/debug` | Container issues, logs, troubleshooting |
-| `/update` | Pull upstream NanoClaw changes, merge with customizations, run migrations |
+| `/update-nanoclaw` | Bring upstream NanoClaw updates into a customized install |
| `/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 |
diff --git a/scripts/post-update.ts b/scripts/post-update.ts
deleted file mode 100644
index 83612b5..0000000
--- a/scripts/post-update.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-#!/usr/bin/env tsx
-import { clearBackup } from '../skills-engine/backup.js';
-
-clearBackup();
-console.log('Backup cleared.');
diff --git a/scripts/update-core.ts b/scripts/update-core.ts
deleted file mode 100644
index 1bc05e1..0000000
--- a/scripts/update-core.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-#!/usr/bin/env tsx
-import { applyUpdate, previewUpdate } from '../skills-engine/update.js';
-
-const args = process.argv.slice(2);
-const jsonMode = args.includes('--json');
-const previewOnly = args.includes('--preview-only');
-const newCorePath = args.find((a) => !a.startsWith('--'));
-
-if (!newCorePath) {
- console.error(
- 'Usage: tsx scripts/update-core.ts [--json] [--preview-only] ',
- );
- process.exit(1);
-}
-
-// Preview
-const preview = previewUpdate(newCorePath);
-
-if (jsonMode && previewOnly) {
- console.log(JSON.stringify(preview, null, 2));
- process.exit(0);
-}
-
-function printPreview(): void {
- console.log('=== Update Preview ===');
- console.log(`Current version: ${preview.currentVersion}`);
- console.log(`New version: ${preview.newVersion}`);
- console.log(`Files changed: ${preview.filesChanged.length}`);
- if (preview.filesChanged.length > 0) {
- for (const f of preview.filesChanged) {
- console.log(` ${f}`);
- }
- }
- if (preview.conflictRisk.length > 0) {
- console.log(`Conflict risk: ${preview.conflictRisk.join(', ')}`);
- }
- if (preview.customPatchesAtRisk.length > 0) {
- console.log(
- `Custom patches at risk: ${preview.customPatchesAtRisk.join(', ')}`,
- );
- }
-}
-
-if (previewOnly) {
- printPreview();
- process.exit(0);
-}
-
-if (!jsonMode) {
- printPreview();
- console.log('');
- console.log('Applying update...');
-}
-
-const result = await applyUpdate(newCorePath);
-
-console.log(JSON.stringify(result, null, 2));
-
-if (!result.success) {
- process.exit(1);
-}
diff --git a/skills-engine/__tests__/fetch-upstream.test.ts b/skills-engine/__tests__/fetch-upstream.test.ts
deleted file mode 100644
index ca2f6ab..0000000
--- a/skills-engine/__tests__/fetch-upstream.test.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import { execFileSync, execSync } from 'child_process';
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-
-describe('fetch-upstream.sh', () => {
- let projectDir: string;
- let upstreamBareDir: string;
- const scriptPath = path.resolve(
- '.claude/skills/update/scripts/fetch-upstream.sh',
- );
-
- beforeEach(() => {
- // Create a bare repo to act as "upstream"
- upstreamBareDir = fs.mkdtempSync(
- path.join(os.tmpdir(), 'nanoclaw-upstream-'),
- );
- execSync('git init --bare -b main', {
- cwd: upstreamBareDir,
- stdio: 'pipe',
- });
-
- // Create a working repo, add files, push to the bare repo
- const seedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-seed-'));
- execSync('git init -b main', { cwd: seedDir, stdio: 'pipe' });
- execSync('git config user.email "test@test.com"', {
- cwd: seedDir,
- stdio: 'pipe',
- });
- execSync('git config user.name "Test"', { cwd: seedDir, stdio: 'pipe' });
- fs.writeFileSync(
- path.join(seedDir, 'package.json'),
- JSON.stringify({ name: 'nanoclaw', version: '2.0.0' }),
- );
- fs.mkdirSync(path.join(seedDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(seedDir, 'src/index.ts'), 'export const v = 2;');
- execSync('git add -A && git commit -m "upstream v2.0.0"', {
- cwd: seedDir,
- stdio: 'pipe',
- });
- execSync(`git remote add origin ${upstreamBareDir}`, {
- cwd: seedDir,
- stdio: 'pipe',
- });
- execSync('git push origin main', {
- cwd: seedDir,
- stdio: 'pipe',
- });
-
- fs.rmSync(seedDir, { recursive: true, force: true });
-
- // Create the "project" repo that will run the script
- projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-project-'));
- execSync('git init -b main', { cwd: projectDir, stdio: 'pipe' });
- execSync('git config user.email "test@test.com"', {
- cwd: projectDir,
- stdio: 'pipe',
- });
- execSync('git config user.name "Test"', {
- cwd: projectDir,
- stdio: 'pipe',
- });
- fs.writeFileSync(
- path.join(projectDir, 'package.json'),
- JSON.stringify({ name: 'nanoclaw', version: '1.0.0' }),
- );
- execSync('git add -A && git commit -m "init"', {
- cwd: projectDir,
- stdio: 'pipe',
- });
-
- // Copy skills-engine/constants.ts so fetch-upstream.sh can read BASE_INCLUDES
- const constantsSrc = path.resolve('skills-engine/constants.ts');
- const constantsDest = path.join(projectDir, 'skills-engine/constants.ts');
- fs.mkdirSync(path.dirname(constantsDest), { recursive: true });
- fs.copyFileSync(constantsSrc, constantsDest);
-
- // Copy the script into the project so it can find PROJECT_ROOT
- const skillScriptsDir = path.join(
- projectDir,
- '.claude/skills/update/scripts',
- );
- fs.mkdirSync(skillScriptsDir, { recursive: true });
- fs.copyFileSync(
- scriptPath,
- path.join(skillScriptsDir, 'fetch-upstream.sh'),
- );
- fs.chmodSync(path.join(skillScriptsDir, 'fetch-upstream.sh'), 0o755);
- });
-
- afterEach(() => {
- // Clean up temp dirs (also any TEMP_DIR created by the script)
- for (const dir of [projectDir, upstreamBareDir]) {
- if (dir && fs.existsSync(dir)) {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- }
- });
-
- function runFetchUpstream(): { stdout: string; exitCode: number } {
- try {
- const stdout = execFileSync(
- 'bash',
- ['.claude/skills/update/scripts/fetch-upstream.sh'],
- {
- cwd: projectDir,
- encoding: 'utf-8',
- stdio: 'pipe',
- timeout: 30_000,
- },
- );
- return { stdout, exitCode: 0 };
- } catch (err: any) {
- return {
- stdout: (err.stdout ?? '') + (err.stderr ?? ''),
- exitCode: err.status ?? 1,
- };
- }
- }
-
- function parseStatus(stdout: string): Record {
- const match = stdout.match(/<<< STATUS\n([\s\S]*?)\nSTATUS >>>/);
- if (!match) return {};
- const lines = match[1].trim().split('\n');
- const result: Record = {};
- for (const line of lines) {
- const eq = line.indexOf('=');
- if (eq > 0) {
- result[line.slice(0, eq)] = line.slice(eq + 1);
- }
- }
- return result;
- }
-
- it('uses existing upstream remote', () => {
- execSync(`git remote add upstream ${upstreamBareDir}`, {
- cwd: projectDir,
- stdio: 'pipe',
- });
-
- const { stdout, exitCode } = runFetchUpstream();
- const status = parseStatus(stdout);
-
- expect(exitCode).toBe(0);
- expect(status.STATUS).toBe('success');
- expect(status.REMOTE).toBe('upstream');
- expect(status.CURRENT_VERSION).toBe('1.0.0');
- expect(status.NEW_VERSION).toBe('2.0.0');
- expect(status.TEMP_DIR).toMatch(/^\/tmp\/nanoclaw-update-/);
-
- // Verify extracted files exist
- expect(fs.existsSync(path.join(status.TEMP_DIR, 'package.json'))).toBe(
- true,
- );
- expect(fs.existsSync(path.join(status.TEMP_DIR, 'src/index.ts'))).toBe(
- true,
- );
-
- // Cleanup temp dir
- fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
- });
-
- it('uses origin when it points to qwibitai/nanoclaw', () => {
- // Set origin to a URL containing qwibitai/nanoclaw
- execSync(`git remote add origin https://github.com/qwibitai/nanoclaw.git`, {
- cwd: projectDir,
- stdio: 'pipe',
- });
- // We can't actually fetch from GitHub in tests, but we can verify
- // it picks the right remote. We'll add a second remote it CAN fetch from.
- execSync(`git remote add upstream ${upstreamBareDir}`, {
- cwd: projectDir,
- stdio: 'pipe',
- });
-
- const { stdout, exitCode } = runFetchUpstream();
- const status = parseStatus(stdout);
-
- // It should find 'upstream' first (checked before origin)
- expect(exitCode).toBe(0);
- expect(status.REMOTE).toBe('upstream');
-
- if (status.TEMP_DIR) {
- fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
- }
- });
-
- it('adds upstream remote when none exists', { timeout: 15_000 }, () => {
- // Remove origin if any
- try {
- execSync('git remote remove origin', {
- cwd: projectDir,
- stdio: 'pipe',
- });
- } catch {
- // No origin
- }
-
- const { stdout } = runFetchUpstream();
-
- // It will try to add upstream pointing to github (which will fail to fetch),
- // but we can verify it attempted to add the remote
- expect(stdout).toContain('Adding upstream');
-
- // Verify the remote was added
- const remotes = execSync('git remote -v', {
- cwd: projectDir,
- encoding: 'utf-8',
- });
- expect(remotes).toContain('upstream');
- expect(remotes).toContain('qwibitai/nanoclaw');
- });
-
- it('extracts files to temp dir correctly', () => {
- execSync(`git remote add upstream ${upstreamBareDir}`, {
- cwd: projectDir,
- stdio: 'pipe',
- });
-
- const { stdout, exitCode } = runFetchUpstream();
- const status = parseStatus(stdout);
-
- expect(exitCode).toBe(0);
-
- // Check file content matches what was pushed
- const pkg = JSON.parse(
- fs.readFileSync(path.join(status.TEMP_DIR, 'package.json'), 'utf-8'),
- );
- expect(pkg.version).toBe('2.0.0');
-
- const indexContent = fs.readFileSync(
- path.join(status.TEMP_DIR, 'src/index.ts'),
- 'utf-8',
- );
- expect(indexContent).toBe('export const v = 2;');
-
- fs.rmSync(status.TEMP_DIR, { recursive: true, force: true });
- });
-});
diff --git a/skills-engine/__tests__/update-core-cli.test.ts b/skills-engine/__tests__/update-core-cli.test.ts
deleted file mode 100644
index c95e65d..0000000
--- a/skills-engine/__tests__/update-core-cli.test.ts
+++ /dev/null
@@ -1,137 +0,0 @@
-import { execFileSync } from 'child_process';
-import fs from 'fs';
-import path from 'path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { stringify } from 'yaml';
-
-import {
- cleanup,
- createTempDir,
- initGitRepo,
- setupNanoclawDir,
-} from './test-helpers.js';
-
-describe('update-core.ts CLI flags', () => {
- let tmpDir: string;
- const scriptPath = path.resolve('scripts/update-core.ts');
- const tsxBin = path.resolve('node_modules/.bin/tsx');
-
- beforeEach(() => {
- tmpDir = createTempDir();
- setupNanoclawDir(tmpDir);
- initGitRepo(tmpDir);
-
- // Write state file
- const statePath = path.join(tmpDir, '.nanoclaw', 'state.yaml');
- fs.writeFileSync(
- statePath,
- stringify({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- }),
- );
- });
-
- afterEach(() => {
- cleanup(tmpDir);
- });
-
- function createNewCore(files: Record): string {
- const dir = path.join(tmpDir, 'new-core');
- fs.mkdirSync(dir, { recursive: true });
- for (const [relPath, content] of Object.entries(files)) {
- const fullPath = path.join(dir, relPath);
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
- fs.writeFileSync(fullPath, content);
- }
- return dir;
- }
-
- it('--json --preview-only outputs JSON preview without applying', () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
-
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'original');
-
- const newCoreDir = createNewCore({
- 'src/index.ts': 'updated',
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const stdout = execFileSync(
- tsxBin,
- [scriptPath, '--json', '--preview-only', newCoreDir],
- { cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
- );
-
- const preview = JSON.parse(stdout);
-
- expect(preview.currentVersion).toBe('1.0.0');
- expect(preview.newVersion).toBe('2.0.0');
- expect(preview.filesChanged).toContain('src/index.ts');
-
- // File should NOT have been modified (preview only)
- expect(fs.readFileSync(path.join(tmpDir, 'src/index.ts'), 'utf-8')).toBe(
- 'original',
- );
- });
-
- it('--preview-only without --json outputs human-readable text', () => {
- const newCoreDir = createNewCore({
- 'src/new-file.ts': 'export const x = 1;',
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const stdout = execFileSync(
- tsxBin,
- [scriptPath, '--preview-only', newCoreDir],
- { cwd: tmpDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 },
- );
-
- expect(stdout).toContain('Update Preview');
- expect(stdout).toContain('2.0.0');
- // Should NOT contain JSON (it's human-readable mode)
- expect(stdout).not.toContain('"currentVersion"');
- });
-
- it('--json applies and outputs JSON result', () => {
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'original');
-
- const newCoreDir = createNewCore({
- 'src/index.ts': 'original',
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const stdout = execFileSync(tsxBin, [scriptPath, '--json', newCoreDir], {
- cwd: tmpDir,
- encoding: 'utf-8',
- stdio: 'pipe',
- timeout: 30_000,
- });
-
- const result = JSON.parse(stdout);
-
- expect(result.success).toBe(true);
- expect(result.previousVersion).toBe('1.0.0');
- expect(result.newVersion).toBe('2.0.0');
- });
-
- it('exits with error when no path provided', () => {
- try {
- execFileSync(tsxBin, [scriptPath], {
- cwd: tmpDir,
- encoding: 'utf-8',
- stdio: 'pipe',
- timeout: 30_000,
- });
- expect.unreachable('Should have exited with error');
- } catch (err: any) {
- expect(err.status).toBe(1);
- expect(err.stderr).toContain('Usage');
- }
- });
-});
diff --git a/skills-engine/__tests__/update.test.ts b/skills-engine/__tests__/update.test.ts
deleted file mode 100644
index a4091ed..0000000
--- a/skills-engine/__tests__/update.test.ts
+++ /dev/null
@@ -1,418 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-import { afterEach, beforeEach, describe, expect, it } from 'vitest';
-import { stringify } from 'yaml';
-
-import {
- cleanup,
- createTempDir,
- initGitRepo,
- setupNanoclawDir,
-} from './test-helpers.js';
-
-let tmpDir: string;
-const originalCwd = process.cwd();
-
-describe('update', () => {
- beforeEach(() => {
- tmpDir = createTempDir();
- setupNanoclawDir(tmpDir);
- initGitRepo(tmpDir);
- process.chdir(tmpDir);
- });
-
- afterEach(() => {
- process.chdir(originalCwd);
- cleanup(tmpDir);
- });
-
- function writeStateFile(state: Record): void {
- const statePath = path.join(tmpDir, '.nanoclaw', 'state.yaml');
- fs.writeFileSync(statePath, stringify(state), 'utf-8');
- }
-
- function createNewCoreDir(files: Record): string {
- const newCoreDir = path.join(tmpDir, 'new-core');
- fs.mkdirSync(newCoreDir, { recursive: true });
-
- for (const [relPath, content] of Object.entries(files)) {
- const fullPath = path.join(newCoreDir, relPath);
- fs.mkdirSync(path.dirname(fullPath), { recursive: true });
- fs.writeFileSync(fullPath, content);
- }
-
- return newCoreDir;
- }
-
- describe('previewUpdate', () => {
- it('detects new files in update', async () => {
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/new-file.ts': 'export const x = 1;',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.filesChanged).toContain('src/new-file.ts');
- expect(preview.currentVersion).toBe('1.0.0');
- });
-
- it('detects changed files vs base', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'modified',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.filesChanged).toContain('src/index.ts');
- });
-
- it('does not list unchanged files', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'same content');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'same content',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.filesChanged).not.toContain('src/index.ts');
- });
-
- it('identifies conflict risk with applied skills', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'original');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [
- {
- name: 'telegram',
- version: '1.0.0',
- applied_at: new Date().toISOString(),
- file_hashes: { 'src/index.ts': 'abc123' },
- },
- ],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'updated core',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.conflictRisk).toContain('src/index.ts');
- });
-
- it('identifies custom patches at risk', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/config.ts'), 'original');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- custom_modifications: [
- {
- description: 'custom tweak',
- applied_at: new Date().toISOString(),
- files_modified: ['src/config.ts'],
- patch_file: '.nanoclaw/custom/001-tweak.patch',
- },
- ],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/config.ts': 'updated core config',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.customPatchesAtRisk).toContain('src/config.ts');
- });
-
- it('reads version from package.json in new core', async () => {
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.newVersion).toBe('2.0.0');
- });
-
- it('detects files deleted in new core', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'keep this');
- fs.writeFileSync(path.join(baseDir, 'src/removed.ts'), 'delete this');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- // New core only has index.ts — removed.ts is gone
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'keep this',
- });
-
- const { previewUpdate } = await import('../update.js');
- const preview = previewUpdate(newCoreDir);
-
- expect(preview.filesDeleted).toContain('src/removed.ts');
- expect(preview.filesChanged).not.toContain('src/removed.ts');
- });
- });
-
- describe('applyUpdate', () => {
- it('rejects when customize session is active', async () => {
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- // Create the pending.yaml that indicates active customize
- const customDir = path.join(tmpDir, '.nanoclaw', 'custom');
- fs.mkdirSync(customDir, { recursive: true });
- fs.writeFileSync(path.join(customDir, 'pending.yaml'), 'active: true');
-
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'new content',
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.success).toBe(false);
- expect(result.error).toContain('customize session');
- });
-
- it('copies new files that do not exist yet', async () => {
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/brand-new.ts': 'export const fresh = true;',
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.error).toBeUndefined();
- expect(result.success).toBe(true);
- expect(
- fs.readFileSync(path.join(tmpDir, 'src/brand-new.ts'), 'utf-8'),
- ).toBe('export const fresh = true;');
- });
-
- it('performs clean three-way merge', async () => {
- // Set up base
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(
- path.join(baseDir, 'src/index.ts'),
- 'line 1\nline 2\nline 3\n',
- );
-
- // Current has user changes at the bottom
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(
- path.join(tmpDir, 'src/index.ts'),
- 'line 1\nline 2\nline 3\nuser addition\n',
- );
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- // New core changes at the top
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'core update\nline 1\nline 2\nline 3\n',
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.success).toBe(true);
- expect(result.newVersion).toBe('2.0.0');
-
- const merged = fs.readFileSync(
- path.join(tmpDir, 'src/index.ts'),
- 'utf-8',
- );
- expect(merged).toContain('core update');
- expect(merged).toContain('user addition');
- });
-
- it('updates base directory after successful merge', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'old base');
-
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'old base');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'new base content',
- });
-
- const { applyUpdate } = await import('../update.js');
- await applyUpdate(newCoreDir);
-
- const newBase = fs.readFileSync(
- path.join(tmpDir, '.nanoclaw', 'base', 'src/index.ts'),
- 'utf-8',
- );
- expect(newBase).toBe('new base content');
- });
-
- it('updates core_version in state after success', async () => {
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- const newCoreDir = createNewCoreDir({
- 'package.json': JSON.stringify({ version: '2.0.0' }),
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.success).toBe(true);
- expect(result.previousVersion).toBe('1.0.0');
- expect(result.newVersion).toBe('2.0.0');
-
- // Verify state file was updated
- const { readState } = await import('../state.js');
- const state = readState();
- expect(state.core_version).toBe('2.0.0');
- });
-
- it('restores backup on merge conflict', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(
- path.join(baseDir, 'src/index.ts'),
- 'line 1\nline 2\nline 3\n',
- );
-
- // Current has conflicting change on same line
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(
- path.join(tmpDir, 'src/index.ts'),
- 'line 1\nuser changed line 2\nline 3\n',
- );
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- // New core also changes line 2 — guaranteed conflict
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'line 1\ncore changed line 2\nline 3\n',
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.success).toBe(false);
- expect(result.mergeConflicts).toContain('src/index.ts');
- expect(result.backupPending).toBe(true);
-
- // File should have conflict markers (backup preserved, not restored)
- const content = fs.readFileSync(
- path.join(tmpDir, 'src/index.ts'),
- 'utf-8',
- );
- expect(content).toContain('<<<<<<<');
- expect(content).toContain('>>>>>>>');
- });
-
- it('removes files deleted in new core', async () => {
- const baseDir = path.join(tmpDir, '.nanoclaw', 'base');
- fs.mkdirSync(path.join(baseDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(baseDir, 'src/index.ts'), 'keep');
- fs.writeFileSync(path.join(baseDir, 'src/removed.ts'), 'old content');
-
- // Working tree has both files
- fs.mkdirSync(path.join(tmpDir, 'src'), { recursive: true });
- fs.writeFileSync(path.join(tmpDir, 'src/index.ts'), 'keep');
- fs.writeFileSync(path.join(tmpDir, 'src/removed.ts'), 'old content');
-
- writeStateFile({
- skills_system_version: '0.1.0',
- core_version: '1.0.0',
- applied_skills: [],
- });
-
- // New core only has index.ts
- const newCoreDir = createNewCoreDir({
- 'src/index.ts': 'keep',
- });
-
- const { applyUpdate } = await import('../update.js');
- const result = await applyUpdate(newCoreDir);
-
- expect(result.success).toBe(true);
- expect(fs.existsSync(path.join(tmpDir, 'src/index.ts'))).toBe(true);
- expect(fs.existsSync(path.join(tmpDir, 'src/removed.ts'))).toBe(false);
- });
- });
-});
diff --git a/skills-engine/apply.ts b/skills-engine/apply.ts
index 3c114df..3d5e761 100644
--- a/skills-engine/apply.ts
+++ b/skills-engine/apply.ts
@@ -301,7 +301,7 @@ export async function applySkill(skillDir: string): Promise {
}
}
- // Store structured outcomes including the test command so applyUpdate() can run them
+ // Store structured outcomes including the test command
const outcomes: Record = manifest.structured
? { ...manifest.structured }
: {};
diff --git a/skills-engine/index.ts b/skills-engine/index.ts
index 5c35ed2..ab6285e 100644
--- a/skills-engine/index.ts
+++ b/skills-engine/index.ts
@@ -36,7 +36,6 @@ export { findSkillDir, replaySkills } from './replay.js';
export type { ReplayOptions, ReplayResult } from './replay.js';
export { uninstallSkill } from './uninstall.js';
export { initSkillsSystem, migrateExisting } from './migrate.js';
-export { applyUpdate, previewUpdate } from './update.js';
export {
compareSemver,
computeFileHash,
@@ -65,6 +64,4 @@ export type {
SkillManifest,
SkillState,
UninstallResult,
- UpdatePreview,
- UpdateResult,
} from './types.js';
diff --git a/skills-engine/types.ts b/skills-engine/types.ts
index f177eda..57a7524 100644
--- a/skills-engine/types.ts
+++ b/skills-engine/types.ts
@@ -76,26 +76,6 @@ export interface CustomModification {
patch_file: string;
}
-export interface UpdatePreview {
- currentVersion: string;
- newVersion: string;
- filesChanged: string[];
- filesDeleted: string[];
- conflictRisk: string[];
- customPatchesAtRisk: string[];
-}
-
-export interface UpdateResult {
- success: boolean;
- previousVersion: string;
- newVersion: string;
- mergeConflicts?: string[];
- backupPending?: boolean;
- customPatchFailures?: string[];
- skillReapplyResults?: Record;
- error?: string;
-}
-
export interface UninstallResult {
success: boolean;
skill: string;
diff --git a/skills-engine/update.ts b/skills-engine/update.ts
deleted file mode 100644
index 5d2e7f7..0000000
--- a/skills-engine/update.ts
+++ /dev/null
@@ -1,355 +0,0 @@
-import { execFileSync, execSync } from 'child_process';
-import crypto from 'crypto';
-import fs from 'fs';
-import os from 'os';
-import path from 'path';
-
-import { parse as parseYaml } from 'yaml';
-
-import { clearBackup, createBackup, restoreBackup } from './backup.js';
-import { BASE_DIR, NANOCLAW_DIR } from './constants.js';
-import { copyDir } from './fs-utils.js';
-import { isCustomizeActive } from './customize.js';
-import { acquireLock } from './lock.js';
-import { mergeFile } from './merge.js';
-import { recordPathRemap } from './path-remap.js';
-import { computeFileHash, readState, writeState } from './state.js';
-import {
- mergeDockerComposeServices,
- mergeEnvAdditions,
- mergeNpmDependencies,
- runNpmInstall,
-} from './structured.js';
-import type { UpdatePreview, UpdateResult } from './types.js';
-
-function walkDir(dir: string, root?: string): string[] {
- const rootDir = root ?? dir;
- const results: string[] = [];
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
- const fullPath = path.join(dir, entry.name);
- if (entry.isDirectory()) {
- results.push(...walkDir(fullPath, rootDir));
- } else {
- results.push(path.relative(rootDir, fullPath));
- }
- }
- return results;
-}
-
-export function previewUpdate(newCorePath: string): UpdatePreview {
- const projectRoot = process.cwd();
- const state = readState();
- const baseDir = path.join(projectRoot, BASE_DIR);
-
- // Read new version from package.json in newCorePath
- const newPkgPath = path.join(newCorePath, 'package.json');
- let newVersion = 'unknown';
- if (fs.existsSync(newPkgPath)) {
- const pkg = JSON.parse(fs.readFileSync(newPkgPath, 'utf-8'));
- newVersion = pkg.version ?? 'unknown';
- }
-
- // Walk all files in newCorePath, compare against base to find changed files
- const newCoreFiles = walkDir(newCorePath);
- const filesChanged: string[] = [];
- const filesDeleted: string[] = [];
-
- for (const relPath of newCoreFiles) {
- const basePath = path.join(baseDir, relPath);
- const newPath = path.join(newCorePath, relPath);
-
- if (!fs.existsSync(basePath)) {
- filesChanged.push(relPath);
- continue;
- }
-
- const baseHash = computeFileHash(basePath);
- const newHash = computeFileHash(newPath);
- if (baseHash !== newHash) {
- filesChanged.push(relPath);
- }
- }
-
- // Detect files deleted in the new core (exist in base but not in newCorePath)
- if (fs.existsSync(baseDir)) {
- const baseFiles = walkDir(baseDir);
- const newCoreSet = new Set(newCoreFiles);
- for (const relPath of baseFiles) {
- if (!newCoreSet.has(relPath)) {
- filesDeleted.push(relPath);
- }
- }
- }
-
- // Check which changed files have skill overlaps
- const conflictRisk: string[] = [];
- const customPatchesAtRisk: string[] = [];
-
- for (const relPath of filesChanged) {
- // Check applied skills
- for (const skill of state.applied_skills) {
- if (skill.file_hashes[relPath]) {
- conflictRisk.push(relPath);
- break;
- }
- }
-
- // Check custom modifications
- if (state.custom_modifications) {
- for (const mod of state.custom_modifications) {
- if (mod.files_modified.includes(relPath)) {
- customPatchesAtRisk.push(relPath);
- break;
- }
- }
- }
- }
-
- return {
- currentVersion: state.core_version,
- newVersion,
- filesChanged,
- filesDeleted,
- conflictRisk,
- customPatchesAtRisk,
- };
-}
-
-export async function applyUpdate(newCorePath: string): Promise {
- const projectRoot = process.cwd();
- const state = readState();
- const baseDir = path.join(projectRoot, BASE_DIR);
-
- // --- Pre-flight ---
- if (isCustomizeActive()) {
- return {
- success: false,
- previousVersion: state.core_version,
- newVersion: 'unknown',
- error:
- 'A customize session is active. Run commitCustomize() or abortCustomize() first.',
- };
- }
-
- const releaseLock = acquireLock();
-
- try {
- // --- Preview ---
- const preview = previewUpdate(newCorePath);
-
- // --- Backup ---
- const filesToBackup = [
- ...preview.filesChanged.map((f) => path.join(projectRoot, f)),
- ...preview.filesDeleted.map((f) => path.join(projectRoot, f)),
- ];
- createBackup(filesToBackup);
-
- // --- Three-way merge ---
- const mergeConflicts: string[] = [];
-
- for (const relPath of preview.filesChanged) {
- const currentPath = path.join(projectRoot, relPath);
- const basePath = path.join(baseDir, relPath);
- const newCoreSrcPath = path.join(newCorePath, relPath);
-
- if (!fs.existsSync(currentPath)) {
- // File doesn't exist yet — just copy from new core
- fs.mkdirSync(path.dirname(currentPath), { recursive: true });
- fs.copyFileSync(newCoreSrcPath, currentPath);
- continue;
- }
-
- if (!fs.existsSync(basePath)) {
- // No base — use current as base
- fs.mkdirSync(path.dirname(basePath), { recursive: true });
- fs.copyFileSync(currentPath, basePath);
- }
-
- // Three-way merge: current ← base → newCore
- const tmpCurrent = path.join(
- os.tmpdir(),
- `nanoclaw-update-${crypto.randomUUID()}-${path.basename(relPath)}`,
- );
- fs.copyFileSync(currentPath, tmpCurrent);
-
- const result = mergeFile(tmpCurrent, basePath, newCoreSrcPath);
-
- if (result.clean) {
- fs.copyFileSync(tmpCurrent, currentPath);
- fs.unlinkSync(tmpCurrent);
- } else {
- // Conflict — copy markers to working tree
- fs.copyFileSync(tmpCurrent, currentPath);
- fs.unlinkSync(tmpCurrent);
- mergeConflicts.push(relPath);
- }
- }
-
- if (mergeConflicts.length > 0) {
- // Preserve backup so user can resolve conflicts manually, then continue
- // Call clearBackup() after resolution or restoreBackup() + clearBackup() to abort
- return {
- success: false,
- previousVersion: preview.currentVersion,
- newVersion: preview.newVersion,
- mergeConflicts,
- backupPending: true,
- error: `Unresolved merge conflicts in: ${mergeConflicts.join(', ')}. Resolve manually then call clearBackup(), or restoreBackup() + clearBackup() to abort.`,
- };
- }
-
- // --- Remove deleted files ---
- for (const relPath of preview.filesDeleted) {
- const currentPath = path.join(projectRoot, relPath);
- if (fs.existsSync(currentPath)) {
- fs.unlinkSync(currentPath);
- }
- }
-
- // --- Re-apply custom patches ---
- const customPatchFailures: string[] = [];
- if (state.custom_modifications) {
- for (const mod of state.custom_modifications) {
- const patchPath = path.join(projectRoot, mod.patch_file);
- if (!fs.existsSync(patchPath)) {
- customPatchFailures.push(
- `${mod.description}: patch file missing (${mod.patch_file})`,
- );
- continue;
- }
- try {
- execFileSync('git', ['apply', '--3way', patchPath], {
- stdio: 'pipe',
- cwd: projectRoot,
- });
- } catch {
- customPatchFailures.push(mod.description);
- }
- }
- }
-
- // --- Record path remaps from update metadata ---
- const remapFile = path.join(
- newCorePath,
- '.nanoclaw-meta',
- 'path_remap.yaml',
- );
- if (fs.existsSync(remapFile)) {
- const remap = parseYaml(fs.readFileSync(remapFile, 'utf-8')) as Record<
- string,
- string
- >;
- if (remap && typeof remap === 'object') {
- recordPathRemap(remap);
- }
- }
-
- // --- Update base ---
- if (fs.existsSync(baseDir)) {
- fs.rmSync(baseDir, { recursive: true, force: true });
- }
- fs.mkdirSync(baseDir, { recursive: true });
- copyDir(newCorePath, baseDir);
-
- // --- Structured ops: re-apply from all skills ---
- const allNpmDeps: Record = {};
- const allEnvAdditions: string[] = [];
- const allDockerServices: Record = {};
- let hasNpmDeps = false;
-
- for (const skill of state.applied_skills) {
- const outcomes = skill.structured_outcomes as
- | Record
- | undefined;
- if (!outcomes) continue;
-
- if (outcomes.npm_dependencies) {
- Object.assign(
- allNpmDeps,
- outcomes.npm_dependencies as Record,
- );
- hasNpmDeps = true;
- }
- if (outcomes.env_additions) {
- allEnvAdditions.push(...(outcomes.env_additions as string[]));
- }
- if (outcomes.docker_compose_services) {
- Object.assign(
- allDockerServices,
- outcomes.docker_compose_services as Record,
- );
- }
- }
-
- if (hasNpmDeps) {
- const pkgPath = path.join(projectRoot, 'package.json');
- mergeNpmDependencies(pkgPath, allNpmDeps);
- }
-
- if (allEnvAdditions.length > 0) {
- const envPath = path.join(projectRoot, '.env.example');
- mergeEnvAdditions(envPath, allEnvAdditions);
- }
-
- if (Object.keys(allDockerServices).length > 0) {
- const composePath = path.join(projectRoot, 'docker-compose.yml');
- mergeDockerComposeServices(composePath, allDockerServices);
- }
-
- if (hasNpmDeps) {
- runNpmInstall();
- }
-
- // --- Run tests for each applied skill ---
- const skillReapplyResults: Record = {};
-
- for (const skill of state.applied_skills) {
- const outcomes = skill.structured_outcomes as
- | Record
- | undefined;
- if (!outcomes?.test) continue;
-
- const testCmd = outcomes.test as string;
- try {
- execSync(testCmd, {
- stdio: 'pipe',
- cwd: projectRoot,
- timeout: 120_000,
- });
- skillReapplyResults[skill.name] = true;
- } catch {
- skillReapplyResults[skill.name] = false;
- }
- }
-
- // --- Update state ---
- state.core_version = preview.newVersion;
- writeState(state);
-
- // --- Cleanup ---
- clearBackup();
-
- return {
- success: true,
- previousVersion: preview.currentVersion,
- newVersion: preview.newVersion,
- customPatchFailures:
- customPatchFailures.length > 0 ? customPatchFailures : undefined,
- skillReapplyResults:
- Object.keys(skillReapplyResults).length > 0
- ? skillReapplyResults
- : undefined,
- };
- } catch (err) {
- restoreBackup();
- clearBackup();
- return {
- success: false,
- previousVersion: state.core_version,
- newVersion: 'unknown',
- error: err instanceof Error ? err.message : String(err),
- };
- } finally {
- releaseLock();
- }
-}
From 51bb32930c97d7d98b11be079d592da4f5d7067c Mon Sep 17 00:00:00 2001
From: Lix
Date: Mon, 2 Mar 2026 05:45:40 +0800
Subject: [PATCH 004/246] feat: add third-party model support (#592)
- Support ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for custom API endpoints
- Add documentation for third-party/open-source model usage
Co-authored-by: wenglixin
---
README.md | 16 ++++++++++++++++
README_zh.md | 16 ++++++++++++++++
src/container-runner.ts | 7 ++++++-
3 files changed, 38 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 2248556..d8318ca 100644
--- a/README.md
+++ b/README.md
@@ -155,6 +155,22 @@ Agents run in containers, not behind application-level permission checks. They c
We don't want configuration sprawl. Every user should customize NanoClaw so that the code does exactly what they want, rather than configuring a generic system. If you prefer having config files, you can tell Claude to add them.
+**Can I use third-party or open-source models?**
+
+Yes. NanoClaw supports any API-compatible model endpoint. Set these environment variables in your `.env` file:
+
+```bash
+ANTHROPIC_BASE_URL=https://your-api-endpoint.com
+ANTHROPIC_AUTH_TOKEN=your-token-here
+```
+
+This allows you to use:
+- Local models via [Ollama](https://ollama.ai) with an API proxy
+- Open-source models hosted on [Together AI](https://together.ai), [Fireworks](https://fireworks.ai), etc.
+- Custom model deployments with Anthropic-compatible APIs
+
+Note: The model must support the Anthropic API format for best compatibility.
+
**How do I debug issues?**
Ask Claude Code. "Why isn't the scheduler running?" "What's in the recent logs?" "Why did this message not get a response?" That's the AI-native approach that underlies NanoClaw.
diff --git a/README_zh.md b/README_zh.md
index a0e7115..bd2be5c 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -159,6 +159,22 @@ Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在
我们不希望配置泛滥。每个用户都应该定制它,让代码完全符合他们的需求,而不是去配置一个通用的系统。如果您喜欢用配置文件,告诉 Claude 让它加上。
+**我可以使用第三方或开源模型吗?**
+
+可以。NanoClaw 支持任何 API 兼容的模型端点。在 `.env` 文件中设置以下环境变量:
+
+```bash
+ANTHROPIC_BASE_URL=https://your-api-endpoint.com
+ANTHROPIC_AUTH_TOKEN=your-token-here
+```
+
+这使您能够使用:
+- 通过 [Ollama](https://ollama.ai) 配合 API 代理运行的本地模型
+- 托管在 [Together AI](https://together.ai)、[Fireworks](https://fireworks.ai) 等平台上的开源模型
+- 兼容 Anthropic API 格式的自定义模型部署
+
+注意:为获得最佳兼容性,模型需支持 Anthropic API 格式。
+
**我该如何调试问题?**
问 Claude Code。"为什么计划任务没有运行?" "最近的日志里有什么?" "为什么这条消息没有得到回应?" 这就是 AI 原生的方法。
diff --git a/src/container-runner.ts b/src/container-runner.ts
index 1af5b52..b754690 100644
--- a/src/container-runner.ts
+++ b/src/container-runner.ts
@@ -204,7 +204,12 @@ function buildVolumeMounts(
* Secrets are never written to disk or mounted as files.
*/
function readSecrets(): Record {
- return readEnvFile(['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY']);
+ return readEnvFile([
+ 'CLAUDE_CODE_OAUTH_TOKEN',
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_BASE_URL',
+ 'ANTHROPIC_AUTH_TOKEN',
+ ]);
}
function buildContainerArgs(
From 94680e99bc875ee0b30c77ec85dcfc29c179574f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 1 Mar 2026 21:45:53 +0000
Subject: [PATCH 005/246] chore: bump version to 1.1.4
---
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 353d39f..dc07c32 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.1.3",
+ "version": "1.1.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.1.3",
+ "version": "1.1.4",
"dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
diff --git a/package.json b/package.json
index f33839f..6e50f63 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.1.3",
+ "version": "1.1.4",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 16d005fcbe08eb8aee92de7cef07719c6945c922 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Sun, 1 Mar 2026 23:52:55 +0200
Subject: [PATCH 006/246] docs: add happydog-intj and bindoon to contributors
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Credit for independently implementing ANTHROPIC_BASE_URL support (#573, #503)
which was merged via #592.
Co-Authored-By: happy dog <212984+happydog-intj@users.noreply.github.com>
Co-Authored-By: 潕量 <5189853+bindoon@users.noreply.github.com>
---
CONTRIBUTORS.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index ab32921..7b08414 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -7,3 +7,5 @@ Thanks to everyone who has contributed to NanoClaw!
- [pottertech](https://github.com/pottertech) — Skip Potter
- [rgarcia](https://github.com/rgarcia) — Rafael
- [AmaxGuan](https://github.com/AmaxGuan) — Lingfeng Guan
+- [happydog-intj](https://github.com/happydog-intj) — happy dog
+- [bindoon](https://github.com/bindoon) — 潕量
From 16ab46314866b4bb4dae157dce6d6d62ccf0fccd Mon Sep 17 00:00:00 2001
From: glifocat
Date: Sun, 1 Mar 2026 23:14:08 +0100
Subject: [PATCH 007/246] fix: normalize wrapped WhatsApp messages before
reading content (#628)
WhatsApp wraps certain message types in container objects:
- viewOnceMessageV2 (listen-once voice, view-once media)
- ephemeralMessage (disappearing messages)
- editedMessage (edited messages)
Without calling Baileys' normalizeMessageContent(), the fields
conversation, extendedTextMessage, imageMessage, etc. are nested
inside the wrapper and invisible to our direct field access. This
causes these messages to be silently dropped with no error.
- Import and call normalizeMessageContent() early in messages.upsert
- Use the normalized content object for all field reads
- Add mock to test suite
Co-authored-by: Ethan M
Co-authored-by: Claude Opus 4.6
---
src/channels/whatsapp.test.ts | 1 +
src/channels/whatsapp.ts | 14 ++++++++++----
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/src/channels/whatsapp.test.ts b/src/channels/whatsapp.test.ts
index d7d0875..5bf1893 100644
--- a/src/channels/whatsapp.test.ts
+++ b/src/channels/whatsapp.test.ts
@@ -87,6 +87,7 @@ vi.mock('@whiskeysockets/baileys', () => {
fetchLatestWaWebVersion: vi
.fn()
.mockResolvedValue({ version: [2, 3000, 0] }),
+ normalizeMessageContent: vi.fn((content: unknown) => content),
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
useMultiFileAuthState: vi.fn().mockResolvedValue({
state: {
diff --git a/src/channels/whatsapp.ts b/src/channels/whatsapp.ts
index f603025..d8b4e1e 100644
--- a/src/channels/whatsapp.ts
+++ b/src/channels/whatsapp.ts
@@ -8,6 +8,7 @@ import makeWASocket, {
WASocket,
fetchLatestWaWebVersion,
makeCacheableSignalKeyStore,
+ normalizeMessageContent,
useMultiFileAuthState,
} from '@whiskeysockets/baileys';
@@ -172,6 +173,11 @@ export class WhatsAppChannel implements Channel {
this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
if (!msg.message) continue;
+ // Unwrap container types (viewOnceMessageV2, ephemeralMessage,
+ // editedMessage, etc.) so that conversation, extendedTextMessage,
+ // imageMessage, etc. are accessible at the top level.
+ const normalized = normalizeMessageContent(msg.message);
+ if (!normalized) continue;
const rawJid = msg.key.remoteJid;
if (!rawJid || rawJid === 'status@broadcast') continue;
@@ -196,10 +202,10 @@ export class WhatsAppChannel implements Channel {
const groups = this.opts.registeredGroups();
if (groups[chatJid]) {
const content =
- msg.message?.conversation ||
- msg.message?.extendedTextMessage?.text ||
- msg.message?.imageMessage?.caption ||
- msg.message?.videoMessage?.caption ||
+ normalized.conversation ||
+ normalized.extendedTextMessage?.text ||
+ normalized.imageMessage?.caption ||
+ normalized.videoMessage?.caption ||
'';
// Skip protocol messages with no text content (encryption keys, read receipts, etc.)
From 77641b052f7f102ad859b5c5ea85a613543db087 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 1 Mar 2026 22:14:21 +0000
Subject: [PATCH 008/246] chore: bump version to 1.1.5
---
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 dc07c32..3d5e924 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.1.4",
+ "version": "1.1.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.1.4",
+ "version": "1.1.5",
"dependencies": {
"@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
diff --git a/package.json b/package.json
index 6e50f63..0ac2c78 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.1.4",
+ "version": "1.1.5",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 5eddca97f7903159f29a962a1a693234a0d51b32 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Sun, 1 Mar 2026 22:14:25 +0000
Subject: [PATCH 009/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?8.5k=20tokens=20=C2=B7=2019%=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 e59720d..104f67a 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
-
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.
@@ -33,6 +32,8 @@ claude
Then run `/setup`. Claude Code handles everything: dependencies, authentication, container setup and service configuration.
+> **Note:** Commands prefixed with `/` (like `/setup`, `/add-whatsapp`) are [Claude Code skills](https://code.claude.com/docs/en/skills). Type them inside the `claude` CLI prompt, not in your regular terminal.
+
## Philosophy
**Small enough to understand.** One process, a few source files and no microservices. If you want to understand the full NanoClaw codebase, just ask Claude Code to walk you through it.
@@ -54,7 +55,7 @@ Then run `/setup`. Claude Code handles everything: dependencies, authentication,
## What It Supports
-- **Messenger I/O** - Message NanoClaw from your phone. Supports WhatsApp, Telegram, Discord, Slack, Signal and headless operation.
+- **Multi-channel messaging** - Talk to your assistant from WhatsApp, Telegram, Discord, Slack, or Gmail. Add channels with skills like `/add-whatsapp` or `/add-telegram`. Run one or many at the same time.
- **Isolated group context** - Each group has its own `CLAUDE.md` memory, isolated filesystem, and runs in its own container sandbox with only that filesystem mounted to it.
- **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
@@ -106,7 +107,7 @@ Users then run `/add-telegram` on their fork and get clean code that does exactl
Skills we'd like to see:
**Communication Channels**
-- `/add-slack` - Add Slack
+- `/add-signal` - Add Signal as a channel
**Session Management**
- `/clear` - Add a `/clear` command that compacts the conversation (summarizes context while preserving critical information in the same session). Requires figuring out how to trigger compaction programmatically via the Claude Agent SDK.
@@ -121,14 +122,16 @@ Skills we'd like to see:
## Architecture
```
-WhatsApp (baileys) --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
+Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Response
```
-Single Node.js process. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
+Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem.
+
+For the full architecture details, see [docs/SPEC.md](docs/SPEC.md).
Key files:
- `src/index.ts` - Orchestrator: state, message loop, agent invocation
-- `src/channels/whatsapp.ts` - WhatsApp connection, auth, send/receive
+- `src/channels/registry.ts` - Channel registry (self-registration at startup)
- `src/ipc.ts` - IPC watcher and task processing
- `src/router.ts` - Message formatting and outbound routing
- `src/group-queue.ts` - Per-group queue with global concurrency limit
@@ -191,6 +194,10 @@ This keeps the base system minimal and lets every user customize their installat
Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42).
+## Changelog
+
+See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes.
+
## License
MIT
diff --git a/README_zh.md b/README_zh.md
index bd2be5c..a05265a 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -12,14 +12,15 @@
•
+通过 Claude Code,NanoClaw 可以动态重写自身代码,根据您的需求定制功能。
**新功能:** 首个支持 [Agent Swarms(智能体集群)](https://code.claude.com/docs/en/agent-teams) 的 AI 助手。可轻松组建智能体团队,在您的聊天中高效协作。
## 我为什么创建这个项目
-[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,愿景宏大。但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有 52+ 个模块、8 个配置管理文件、45+ 个依赖项,以及为 15 个渠道提供商设计的抽象层。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
+[OpenClaw](https://github.com/openclaw/openclaw) 是一个令人印象深刻的项目,但我无法安心使用一个我不了解却能访问我个人隐私的软件。OpenClaw 有近 50 万行代码、53 个配置文件和 70+ 个依赖项。其安全性是应用级别的(通过白名单、配对码实现),而非操作系统级别的隔离。所有东西都在一个共享内存的 Node 进程中运行。
-NanoClaw 用一个您能在 8 分钟内理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
+NanoClaw 用一个您能快速理解的代码库,为您提供了同样的核心功能。只有一个进程,少数几个文件。智能体(Agent)运行在具有文件系统隔离的真实 Linux 容器中,而不是依赖于权限检查。
## 快速开始
@@ -31,25 +32,27 @@ claude
然后运行 `/setup`。Claude Code 会处理一切:依赖安装、身份验证、容器设置、服务配置。
+> **注意:** 以 `/` 开头的命令(如 `/setup`、`/add-whatsapp`)是 [Claude Code 技能](https://code.claude.com/docs/en/skills)。请在 `claude` CLI 提示符中输入,而非在普通终端中。
+
## 设计哲学
**小巧易懂:** 单一进程,少量源文件。无微服务、无消息队列、无复杂抽象层。让 Claude Code 引导您轻松上手。
**通过隔离保障安全:** 智能体运行在 Linux 容器(在 macOS 上是 Apple Container,或 Docker)中。它们只能看到被明确挂载的内容。即便通过 Bash 访问也十分安全,因为所有命令都在容器内执行,不会直接操作您的宿主机。
-**为单一用户打造:** 这不是一个框架,是一个完全符合我个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
+**为单一用户打造:** 这不是一个框架,是一个完全符合您个人需求的、可工作的软件。您可以 Fork 本项目,然后让 Claude Code 根据您的精确需求进行修改和适配。
**定制即代码修改:** 没有繁杂的配置文件。想要不同的行为?直接修改代码。代码库足够小,这样做是安全的。
-**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
+**AI 原生:** 无安装向导(由 Claude Code 指导安装)。无需监控仪表盘,直接询问 Claude 即可了解系统状况。无调试工具(描述问题,Claude 会修复它)。
**技能(Skills)优于功能(Features):** 贡献者不应该向代码库添加新功能(例如支持 Telegram)。相反,他们应该贡献像 `/add-telegram` 这样的 [Claude Code 技能](https://code.claude.com/docs/en/skills),这些技能可以改造您的 fork。最终,您得到的是只做您需要事情的整洁代码。
-**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。工具套件至关重要。一个低效的工具套件会让再聪明的模型也显得迟钝,而一个优秀的套件则能赋予它们超凡的能力。Claude Code (在我看来) 是市面上最好的工具套件。
+**最好的工具套件,最好的模型:** 本项目运行在 Claude Agent SDK 之上,这意味着您直接运行的就是 Claude Code。Claude Code 高度强大,其编码和问题解决能力使其能够修改和扩展 NanoClaw,为每个用户量身定制。
## 功能支持
-- **WhatsApp 输入/输出** - 通过手机给 Claude 发消息
+- **多渠道消息** - 通过 WhatsApp、Telegram、Discord、Slack 或 Gmail 与您的助手对话。使用 `/add-whatsapp` 或 `/add-telegram` 等技能添加渠道,可同时运行一个或多个。
- **隔离的群组上下文** - 每个群组都拥有独立的 `CLAUDE.md` 记忆和隔离的文件系统。它们在各自的容器沙箱中运行,且仅挂载所需的文件系统。
- **主频道** - 您的私有频道(self-chat),用于管理控制;其他所有群组都完全隔离
- **计划任务** - 运行 Claude 的周期性作业,并可以给您回发消息
@@ -101,15 +104,10 @@ claude
我们希望看到的技能:
**通信渠道**
-- `/add-telegram` - 添加 Telegram 作为渠道。应提供选项让用户选择替换 WhatsApp 或作为额外渠道添加。也应能将其添加为控制渠道(可以触发动作)或仅作为被其他地方触发的动作所使用的渠道。
-- `/add-slack` - 添加 Slack
-- `/add-discord` - 添加 Discord
-
-**平台支持**
-- `/setup-windows` - 通过 WSL2 + Docker 支持 Windows
+- `/add-signal` - 添加 Signal 作为渠道
**会话管理**
-- `/add-clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
+- `/clear` - 添加一个 `/clear` 命令,用于压缩会话(在同一会话中总结上下文,同时保留关键信息)。这需要研究如何通过 Claude Agent SDK 以编程方式触发压缩。
## 系统要求
@@ -121,17 +119,19 @@ claude
## 架构
```
-WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
+渠道 --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) --> 响应
```
-单一 Node.js 进程。智能体在具有挂载目录的隔离 Linux 容器中执行。每个群组的消息队列都带有全局并发控制。通过文件系统进行进程间通信(IPC)。
+单一 Node.js 进程。渠道通过技能添加,启动时自注册 — 编排器连接具有凭据的渠道。智能体在具有文件系统隔离的 Linux 容器中执行。每个群组的消息队列带有并发控制。通过文件系统进行 IPC。
+
+完整架构详情请见 [docs/SPEC.md](docs/SPEC.md)。
关键文件:
- `src/index.ts` - 编排器:状态管理、消息循环、智能体调用
-- `src/channels/whatsapp.ts` - WhatsApp 连接、认证、收发消息
+- `src/channels/registry.ts` - 渠道注册表(启动时自注册)
- `src/ipc.ts` - IPC 监听与任务处理
- `src/router.ts` - 消息格式化与出站路由
-- `src/group-queue.ts` - 各带全局并发限制的群组队列
+- `src/group-queue.ts` - 带全局并发限制的群组队列
- `src/container-runner.ts` - 生成流式智能体容器
- `src/task-scheduler.ts` - 运行计划任务
- `src/db.ts` - SQLite 操作(消息、群组、会话、状态)
@@ -139,10 +139,6 @@ WhatsApp (baileys) --> SQLite --> 轮询循环 --> 容器 (Claude Agent SDK) -->
## FAQ
-**为什么是 WhatsApp 而不是 Telegram/Signal 等?**
-
-因为我用 WhatsApp。Fork 这个项目然后运行一个技能来改变它。正是这个项目的核心理念。
-
**为什么是 Docker?**
Docker 提供跨平台支持(macOS 和 Linux)和成熟的生态系统。在 macOS 上,您可以选择通过运行 `/convert-to-apple-container` 切换到 Apple Container,以获得更轻量级的原生运行时体验。
@@ -181,7 +177,7 @@ ANTHROPIC_AUTH_TOKEN=your-token-here
**为什么我的安装不成功?**
-我不知道。运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 `SKILL.md` 安装文件。
+如果遇到问题,安装过程中 Claude 会尝试动态修复。如果问题仍然存在,运行 `claude`,然后运行 `/debug`。如果 Claude 发现一个可能影响其他用户的问题,请开一个 PR 来修改 setup SKILL.md。
**什么样的代码更改会被接受?**
@@ -195,6 +191,10 @@ ANTHROPIC_AUTH_TOKEN=your-token-here
有任何疑问或建议?欢迎[加入 Discord 社区](https://discord.gg/VDdww8qS42)与我们交流。
+## 更新日志
+
+破坏性变更和迁移说明请见 [CHANGELOG.md](CHANGELOG.md)。
+
## 许可证
MIT
diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts
index 006b812..fc1236d 100644
--- a/container/agent-runner/src/ipc-mcp-stdio.ts
+++ b/container/agent-runner/src/ipc-mcp-stdio.ts
@@ -246,13 +246,13 @@ server.tool(
server.tool(
'register_group',
- `Register a new WhatsApp group so the agent can respond to messages there. Main group only.
+ `Register a new chat/group so the agent can respond to messages there. Main group only.
-Use available_groups.json to find the JID for a group. The folder name should be lowercase with hyphens (e.g., "family-chat").`,
+Use available_groups.json to find the JID for a group. The folder name must be channel-prefixed: "{channel}_{group-name}" (e.g., "whatsapp_family-chat", "telegram_dev-team", "discord_general"). Use lowercase with hyphens for the group name part.`,
{
- jid: z.string().describe('The WhatsApp JID (e.g., "120363336345536173@g.us")'),
+ jid: z.string().describe('The chat JID (e.g., "120363336345536173@g.us", "tg:-1001234567890", "dc:1234567890123456")'),
name: z.string().describe('Display name for the group'),
- folder: z.string().describe('Folder name for group files (lowercase, hyphens, e.g., "family-chat")'),
+ folder: z.string().describe('Channel-prefixed folder name (e.g., "whatsapp_family-chat", "telegram_dev-team")'),
trigger: z.string().describe('Trigger word (e.g., "@Andy")'),
},
async (args) => {
diff --git a/docs/SPEC.md b/docs/SPEC.md
index b439012..d2b4723 100644
--- a/docs/SPEC.md
+++ b/docs/SPEC.md
@@ -1,79 +1,81 @@
# NanoClaw Specification
-A personal Claude assistant accessible via WhatsApp, with persistent memory per conversation, scheduled tasks, and email integration.
+A personal Claude assistant with multi-channel support, persistent memory per conversation, scheduled tasks, and container-isolated agent execution.
---
## Table of Contents
1. [Architecture](#architecture)
-2. [Folder Structure](#folder-structure)
-3. [Configuration](#configuration)
-4. [Memory System](#memory-system)
-5. [Session Management](#session-management)
-6. [Message Flow](#message-flow)
-7. [Commands](#commands)
-8. [Scheduled Tasks](#scheduled-tasks)
-9. [MCP Servers](#mcp-servers)
-10. [Deployment](#deployment)
-11. [Security Considerations](#security-considerations)
+2. [Architecture: Channel System](#architecture-channel-system)
+3. [Folder Structure](#folder-structure)
+4. [Configuration](#configuration)
+5. [Memory System](#memory-system)
+6. [Session Management](#session-management)
+7. [Message Flow](#message-flow)
+8. [Commands](#commands)
+9. [Scheduled Tasks](#scheduled-tasks)
+10. [MCP Servers](#mcp-servers)
+11. [Deployment](#deployment)
+12. [Security Considerations](#security-considerations)
---
## Architecture
```
-┌─────────────────────────────────────────────────────────────────────┐
-│ HOST (macOS) │
-│ (Main Node.js Process) │
-├─────────────────────────────────────────────────────────────────────┤
-│ │
-│ ┌──────────────┐ ┌────────────────────┐ │
-│ │ WhatsApp │────────────────────▶│ SQLite Database │ │
-│ │ (baileys) │◀────────────────────│ (messages.db) │ │
-│ └──────────────┘ store/send └─────────┬──────────┘ │
-│ │ │
-│ ┌────────────────────────────────────────┘ │
-│ │ │
-│ ▼ │
-│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
-│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
-│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │
-│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
-│ │ │ │
-│ └───────────┬───────────┘ │
-│ │ spawns container │
-│ ▼ │
-├─────────────────────────────────────────────────────────────────────┤
-│ CONTAINER (Linux VM) │
-├─────────────────────────────────────────────────────────────────────┤
-│ ┌──────────────────────────────────────────────────────────────┐ │
-│ │ AGENT RUNNER │ │
-│ │ │ │
-│ │ Working directory: /workspace/group (mounted from host) │ │
-│ │ Volume mounts: │ │
-│ │ • groups/{name}/ → /workspace/group │ │
-│ │ • groups/global/ → /workspace/global/ (non-main only) │ │
-│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
-│ │ • Additional dirs → /workspace/extra/* │ │
-│ │ │ │
-│ │ Tools (all groups): │ │
-│ │ • Bash (safe - sandboxed in container!) │ │
-│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │
-│ │ • WebSearch, WebFetch (internet access) │ │
-│ │ • agent-browser (browser automation) │ │
-│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │
-│ │ │ │
-│ └──────────────────────────────────────────────────────────────┘ │
-│ │
-└──────────────────────────────────────────────────────────────────────┘
+┌──────────────────────────────────────────────────────────────────────┐
+│ HOST (macOS / Linux) │
+│ (Main Node.js Process) │
+├──────────────────────────────────────────────────────────────────────┤
+│ │
+│ ┌──────────────────┐ ┌────────────────────┐ │
+│ │ Channels │─────────────────▶│ SQLite Database │ │
+│ │ (self-register │◀────────────────│ (messages.db) │ │
+│ │ at startup) │ store/send └─────────┬──────────┘ │
+│ └──────────────────┘ │ │
+│ │ │
+│ ┌─────────────────────────────────────────┘ │
+│ │ │
+│ ▼ │
+│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │
+│ │ Message Loop │ │ Scheduler Loop │ │ IPC Watcher │ │
+│ │ (polls SQLite) │ │ (checks tasks) │ │ (file-based) │ │
+│ └────────┬─────────┘ └────────┬─────────┘ └───────────────┘ │
+│ │ │ │
+│ └───────────┬───────────┘ │
+│ │ spawns container │
+│ ▼ │
+├──────────────────────────────────────────────────────────────────────┤
+│ CONTAINER (Linux VM) │
+├──────────────────────────────────────────────────────────────────────┤
+│ ┌──────────────────────────────────────────────────────────────┐ │
+│ │ AGENT RUNNER │ │
+│ │ │ │
+│ │ Working directory: /workspace/group (mounted from host) │ │
+│ │ Volume mounts: │ │
+│ │ • groups/{name}/ → /workspace/group │ │
+│ │ • groups/global/ → /workspace/global/ (non-main only) │ │
+│ │ • data/sessions/{group}/.claude/ → /home/node/.claude/ │ │
+│ │ • Additional dirs → /workspace/extra/* │ │
+│ │ │ │
+│ │ Tools (all groups): │ │
+│ │ • Bash (safe - sandboxed in container!) │ │
+│ │ • Read, Write, Edit, Glob, Grep (file operations) │ │
+│ │ • WebSearch, WebFetch (internet access) │ │
+│ │ • agent-browser (browser automation) │ │
+│ │ • mcp__nanoclaw__* (scheduler tools via IPC) │ │
+│ │ │ │
+│ └──────────────────────────────────────────────────────────────┘ │
+│ │
+└───────────────────────────────────────────────────────────────────────┘
```
### Technology Stack
| Component | Technology | Purpose |
|-----------|------------|---------|
-| WhatsApp Connection | Node.js (@whiskeysockets/baileys) | Connect to WhatsApp, send/receive messages |
+| Channel System | Channel registry (`src/channels/registry.ts`) | Channels self-register at startup |
| Message Storage | SQLite (better-sqlite3) | Store messages for polling |
| Container Runtime | Containers (Linux VMs) | Isolated environments for agent execution |
| Agent | @anthropic-ai/claude-agent-sdk (0.2.29) | Run Claude with tools and MCP servers |
@@ -82,6 +84,158 @@ A personal Claude assistant accessible via WhatsApp, with persistent memory per
---
+## Architecture: Channel System
+
+The core ships with no channels built in — each channel (WhatsApp, Telegram, Slack, Discord, Gmail) is installed as a [Claude Code skill](https://code.claude.com/docs/en/skills) that adds the channel code to your fork. Channels self-register at startup; installed channels with missing credentials emit a WARN log and are skipped.
+
+### System Diagram
+
+```mermaid
+graph LR
+ subgraph Channels["Channels"]
+ WA[WhatsApp]
+ TG[Telegram]
+ SL[Slack]
+ DC[Discord]
+ New["Other Channel (Signal, Gmail...)"]
+ end
+
+ subgraph Orchestrator["Orchestrator — index.ts"]
+ ML[Message Loop]
+ GQ[Group Queue]
+ RT[Router]
+ TS[Task Scheduler]
+ DB[(SQLite)]
+ end
+
+ subgraph Execution["Container Execution"]
+ CR[Container Runner]
+ LC["Linux Container"]
+ IPC[IPC Watcher]
+ end
+
+ %% Flow
+ WA & TG & SL & DC & New -->|onMessage| ML
+ ML --> GQ
+ GQ -->|concurrency| CR
+ CR --> LC
+ LC -->|filesystem IPC| IPC
+ IPC -->|tasks & messages| RT
+ RT -->|Channel.sendMessage| Channels
+ TS -->|due tasks| CR
+
+ %% DB Connections
+ DB <--> ML
+ DB <--> TS
+
+ %% Styling for the dynamic channel
+ style New stroke-dasharray: 5 5,stroke-width:2px
+```
+
+### Channel Registry
+
+The channel system is built on a factory registry in `src/channels/registry.ts`:
+
+```typescript
+export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
+
+const registry = new Map();
+
+export function registerChannel(name: string, factory: ChannelFactory): void {
+ registry.set(name, factory);
+}
+
+export function getChannelFactory(name: string): ChannelFactory | undefined {
+ return registry.get(name);
+}
+
+export function getRegisteredChannelNames(): string[] {
+ return [...registry.keys()];
+}
+```
+
+Each factory receives `ChannelOpts` (callbacks for `onMessage`, `onChatMetadata`, and `registeredGroups`) and returns either a `Channel` instance or `null` if that channel's credentials are not configured.
+
+### Channel Interface
+
+Every channel implements this interface (defined in `src/types.ts`):
+
+```typescript
+interface Channel {
+ name: string;
+ connect(): Promise;
+ sendMessage(jid: string, text: string): Promise;
+ isConnected(): boolean;
+ ownsJid(jid: string): boolean;
+ disconnect(): Promise;
+ setTyping?(jid: string, isTyping: boolean): Promise;
+ syncGroups?(force: boolean): Promise;
+}
+```
+
+### Self-Registration Pattern
+
+Channels self-register using a barrel-import pattern:
+
+1. Each channel skill adds a file to `src/channels/` (e.g. `whatsapp.ts`, `telegram.ts`) that calls `registerChannel()` at module load time:
+
+ ```typescript
+ // src/channels/whatsapp.ts
+ import { registerChannel, ChannelOpts } from './registry.js';
+
+ export class WhatsAppChannel implements Channel { /* ... */ }
+
+ registerChannel('whatsapp', (opts: ChannelOpts) => {
+ // Return null if credentials are missing
+ if (!existsSync(authPath)) return null;
+ return new WhatsAppChannel(opts);
+ });
+ ```
+
+2. The barrel file `src/channels/index.ts` imports all channel modules, triggering registration:
+
+ ```typescript
+ import './whatsapp.js';
+ import './telegram.js';
+ // ... each skill adds its import here
+ ```
+
+3. At startup, the orchestrator (`src/index.ts`) loops through registered channels and connects whichever ones return a valid instance:
+
+ ```typescript
+ for (const name of getRegisteredChannelNames()) {
+ const factory = getChannelFactory(name);
+ const channel = factory?.(channelOpts);
+ if (channel) {
+ await channel.connect();
+ channels.push(channel);
+ }
+ }
+ ```
+
+### Key Files
+
+| File | Purpose |
+|------|---------|
+| `src/channels/registry.ts` | Channel factory registry |
+| `src/channels/index.ts` | Barrel imports that trigger channel self-registration |
+| `src/types.ts` | `Channel` interface, `ChannelOpts`, message types |
+| `src/index.ts` | Orchestrator — instantiates channels, runs message loop |
+| `src/router.ts` | Finds the owning channel for a JID, formats messages |
+
+### Adding a New Channel
+
+To add a new channel, contribute a skill to `.claude/skills/add-/` that:
+
+1. Adds a `src/channels/.ts` file implementing the `Channel` interface
+2. Calls `registerChannel(name, factory)` at module load
+3. Returns `null` from the factory if credentials are missing
+4. Adds an import line to `src/channels/index.ts`
+
+See existing skills (`/add-whatsapp`, `/add-telegram`, `/add-slack`, `/add-discord`, `/add-gmail`) for the pattern.
+
+---
+
## Folder Structure
```
@@ -100,7 +254,8 @@ nanoclaw/
├── src/
│ ├── index.ts # Orchestrator: state, message loop, agent invocation
│ ├── channels/
-│ │ └── whatsapp.ts # WhatsApp connection, auth, send/receive
+│ │ ├── registry.ts # Channel factory registry
+│ │ └── index.ts # Barrel imports for channel self-registration
│ ├── ipc.ts # IPC watcher and task processing
│ ├── router.ts # Message formatting and outbound routing
│ ├── config.ts # Configuration constants
@@ -141,10 +296,10 @@ nanoclaw/
│
├── groups/
│ ├── CLAUDE.md # Global memory (all groups read this)
-│ ├── main/ # Self-chat (main control channel)
+│ ├── {channel}_main/ # Main control channel (e.g., whatsapp_main/)
│ │ ├── CLAUDE.md # Main channel memory
│ │ └── logs/ # Task execution logs
-│ └── {Group Name}/ # Per-group folders (created on registration)
+│ └── {channel}_{group-name}/ # Per-group folders (created on registration)
│ ├── CLAUDE.md # Group-specific memory
│ ├── logs/ # Task logs for this group
│ └── *.md # Files created by the agent
@@ -205,7 +360,7 @@ Groups can have additional directories mounted via `containerConfig` in the SQLi
```typescript
registerGroup("1234567890@g.us", {
name: "Dev Team",
- folder: "dev-team",
+ folder: "whatsapp_dev-team",
trigger: "@Andy",
added_at: new Date().toISOString(),
containerConfig: {
@@ -221,6 +376,8 @@ registerGroup("1234567890@g.us", {
});
```
+Folder names follow the convention `{channel}_{group-name}` (e.g., `whatsapp_family-chat`, `telegram_dev-team`). The main group has `isMain: true` set during registration.
+
Additional mounts appear at `/workspace/extra/{containerPath}` inside the container.
**Mount syntax note:** Read-write mounts use `-v host:container`, but readonly mounts require `--mount "type=bind,source=...,target=...,readonly"` (the `:ro` suffix may not work on all runtimes).
@@ -314,10 +471,10 @@ Sessions enable conversation continuity - Claude remembers what you talked about
### Incoming Message Flow
```
-1. User sends WhatsApp message
+1. User sends a message via any connected channel
│
▼
-2. Baileys receives message via WhatsApp Web protocol
+2. Channel receives message (e.g. Baileys for WhatsApp, Bot API for Telegram)
│
▼
3. Message stored in SQLite (store/messages.db)
@@ -349,7 +506,7 @@ Sessions enable conversation continuity - Claude remembers what you talked about
└── Uses tools as needed (search, email, etc.)
│
▼
-9. Router prefixes response with assistant name and sends via WhatsApp
+9. Router prefixes response with assistant name and sends via the owning channel
│
▼
10. Router updates last agent timestamp and saves session ID
@@ -473,7 +630,7 @@ The `nanoclaw` MCP server is created dynamically per agent call with the current
| `pause_task` | Pause a task |
| `resume_task` | Resume a paused task |
| `cancel_task` | Delete a task |
-| `send_message` | Send a WhatsApp message to the group |
+| `send_message` | Send a message to the group via its channel |
---
@@ -487,7 +644,8 @@ When NanoClaw starts, it:
1. **Ensures container runtime is running** - Automatically starts it if needed; kills orphaned NanoClaw containers from previous runs
2. Initializes the SQLite database (migrates from JSON files if they exist)
3. Loads state from SQLite (registered groups, sessions, router state)
-4. Connects to WhatsApp (on `connection.open`):
+4. **Connects channels** — loops through registered channels, instantiates those with credentials, calls `connect()` on each
+5. Once at least one channel is connected:
- Starts the scheduler loop
- Starts the IPC watcher for container messages
- Sets up the per-group queue with `processGroupMessages`
diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md
index 9ae1b13..e7aa25a 100644
--- a/groups/main/CLAUDE.md
+++ b/groups/main/CLAUDE.md
@@ -119,13 +119,13 @@ sqlite3 /workspace/project/store/messages.db "
### Registered Groups Config
-Groups are registered in `/workspace/project/data/registered_groups.json`:
+Groups are registered in the SQLite `registered_groups` table:
```json
{
"1234567890-1234567890@g.us": {
"name": "Family Chat",
- "folder": "family-chat",
+ "folder": "whatsapp_family-chat",
"trigger": "@Andy",
"added_at": "2024-01-31T12:00:00.000Z"
}
@@ -133,32 +133,34 @@ Groups are registered in `/workspace/project/data/registered_groups.json`:
```
Fields:
-- **Key**: The WhatsApp JID (unique identifier for the chat)
+- **Key**: The chat JID (unique identifier — WhatsApp, Telegram, Slack, Discord, etc.)
- **name**: Display name for the group
-- **folder**: Folder name under `groups/` for this group's files and memory
+- **folder**: Channel-prefixed folder name under `groups/` for this group's files and memory
- **trigger**: The trigger word (usually same as global, but could differ)
- **requiresTrigger**: Whether `@trigger` prefix is needed (default: `true`). Set to `false` for solo/personal chats where all messages should be processed
+- **isMain**: Whether this is the main control group (elevated privileges, no trigger required)
- **added_at**: ISO timestamp when registered
### Trigger Behavior
-- **Main group**: No trigger needed — all messages are processed automatically
+- **Main group** (`isMain: true`): No trigger needed — all messages are processed automatically
- **Groups with `requiresTrigger: false`**: No trigger needed — all messages processed (use for 1-on-1 or solo chats)
- **Other groups** (default): Messages must start with `@AssistantName` to be processed
### Adding a Group
1. Query the database to find the group's JID
-2. Read `/workspace/project/data/registered_groups.json`
-3. Add the new group entry with `containerConfig` if needed
-4. Write the updated JSON back
-5. Create the group folder: `/workspace/project/groups/{folder-name}/`
-6. Optionally create an initial `CLAUDE.md` for the group
+2. Use the `register_group` MCP tool with the JID, name, folder, and trigger
+3. Optionally include `containerConfig` for additional mounts
+4. The group folder is created automatically: `/workspace/project/groups/{folder-name}/`
+5. Optionally create an initial `CLAUDE.md` for the group
-Example folder name conventions:
-- "Family Chat" → `family-chat`
-- "Work Team" → `work-team`
-- Use lowercase, hyphens instead of spaces
+Folder naming convention — channel prefix with underscore separator:
+- WhatsApp "Family Chat" → `whatsapp_family-chat`
+- Telegram "Dev Team" → `telegram_dev-team`
+- Discord "General" → `discord_general`
+- Slack "Engineering" → `slack_engineering`
+- Use lowercase, hyphens for the group name part
#### Adding Additional Directories for a Group
diff --git a/package-lock.json b/package-lock.json
index ed1f6cc..baeffb5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,27 +1,23 @@
{
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"dependencies": {
- "@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
- "qrcode": "^1.5.4",
- "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
- "@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"prettier": "^3.8.1",
@@ -93,62 +89,6 @@
"node": ">=18"
}
},
- "node_modules/@borewit/text-codec": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
- "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
- "license": "MIT",
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@cacheable/memory": {
- "version": "2.0.7",
- "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.7.tgz",
- "integrity": "sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/utils": "^2.3.3",
- "@keyv/bigmap": "^1.3.0",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- }
- },
- "node_modules/@cacheable/node-cache": {
- "version": "1.7.6",
- "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
- "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
- "license": "MIT",
- "dependencies": {
- "cacheable": "^2.3.1",
- "hookified": "^1.14.0",
- "keyv": "^5.5.5"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@cacheable/utils": {
- "version": "2.3.4",
- "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.3.4.tgz",
- "integrity": "sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.3.0",
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@emnapi/runtime": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
- "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
@@ -591,486 +531,6 @@
"node": ">=18"
}
},
- "node_modules/@hapi/boom": {
- "version": "9.1.4",
- "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
- "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@hapi/hoek": "9.x.x"
- }
- },
- "node_modules/@hapi/hoek": {
- "version": "9.3.0",
- "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
- "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@img/colour": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
- "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/@img/sharp-darwin-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
- "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-darwin-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
- "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
- "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
- "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "darwin"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
- "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
- "cpu": [
- "arm"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
- "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-ppc64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
- "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
- "cpu": [
- "ppc64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-riscv64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
- "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
- "cpu": [
- "riscv64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-s390x": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
- "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
- "cpu": [
- "s390x"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
- "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
- "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
- "cpu": [
- "arm64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
- "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
- "cpu": [
- "x64"
- ],
- "license": "LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "linux"
- ],
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-linux-arm": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
- "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
- "cpu": [
- "arm"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
- "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-ppc64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
- "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
- "cpu": [
- "ppc64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-ppc64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-riscv64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
- "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
- "cpu": [
- "riscv64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-riscv64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-s390x": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
- "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
- "cpu": [
- "s390x"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-s390x": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linux-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
- "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
- "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
- "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
- }
- },
- "node_modules/@img/sharp-wasm32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
- "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
- "cpu": [
- "wasm32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/runtime": "^1.7.0"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-arm64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
- "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
- "cpu": [
- "arm64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-ia32": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
- "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
- "cpu": [
- "ia32"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
- "node_modules/@img/sharp-win32-x64": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
- "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
- "cpu": [
- "x64"
- ],
- "license": "Apache-2.0 AND LGPL-3.0-or-later",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- }
- },
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -1099,98 +559,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@keyv/bigmap": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
- "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
- "license": "MIT",
- "dependencies": {
- "hashery": "^1.4.0",
- "hookified": "^1.15.0"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "keyv": "^5.6.0"
- }
- },
- "node_modules/@keyv/serialize": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
- "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
- "license": "MIT"
- },
"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/@protobufjs/aspromise": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
- "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/base64": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
- "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/codegen": {
- "version": "2.0.4",
- "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
- "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/eventemitter": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
- "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/fetch": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
- "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.1",
- "@protobufjs/inquire": "^1.1.0"
- }
- },
- "node_modules/@protobufjs/float": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
- "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/inquire": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
- "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/path": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
- "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/pool": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
- "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
- "license": "BSD-3-Clause"
- },
- "node_modules/@protobufjs/utf8": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
- "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
- "license": "BSD-3-Clause"
- },
"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",
@@ -1548,29 +922,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@tokenizer/inflate": {
- "version": "0.4.1",
- "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
- "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
- "license": "MIT",
- "dependencies": {
- "debug": "^4.4.3",
- "token-types": "^6.1.1"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/@tokenizer/token": {
- "version": "0.3.0",
- "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
- "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
- "license": "MIT"
- },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
@@ -1606,28 +957,16 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@types/long": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
- "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
- "license": "MIT"
- },
"node_modules/@types/node": {
"version": "22.19.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz",
"integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
- "node_modules/@types/qrcode-terminal": {
- "version": "0.12.2",
- "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz",
- "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@vitest/coverage-v8": {
"version": "4.0.18",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.18.tgz",
@@ -1770,69 +1109,6 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/@whiskeysockets/baileys": {
- "version": "7.0.0-rc.9",
- "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
- "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
- "hasInstallScript": true,
- "license": "MIT",
- "dependencies": {
- "@cacheable/node-cache": "^1.4.0",
- "@hapi/boom": "^9.1.3",
- "async-mutex": "^0.5.0",
- "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
- "lru-cache": "^11.1.0",
- "music-metadata": "^11.7.0",
- "p-queue": "^9.0.0",
- "pino": "^9.6",
- "protobufjs": "^7.2.4",
- "ws": "^8.13.0"
- },
- "engines": {
- "node": ">=20.0.0"
- },
- "peerDependencies": {
- "audio-decode": "^2.1.3",
- "jimp": "^1.6.0",
- "link-preview-js": "^3.0.0",
- "sharp": "*"
- },
- "peerDependenciesMeta": {
- "audio-decode": {
- "optional": true
- },
- "jimp": {
- "optional": true
- },
- "link-preview-js": {
- "optional": true
- }
- }
- },
- "node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/ansi-styles": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
- "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
- "license": "MIT",
- "dependencies": {
- "color-convert": "^2.0.1"
- },
- "engines": {
- "node": ">=8"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1855,15 +1131,6 @@
"js-tokens": "^10.0.0"
}
},
- "node_modules/async-mutex": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
- "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
- "license": "MIT",
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1948,28 +1215,6 @@
"ieee754": "^1.1.13"
}
},
- "node_modules/cacheable": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.2.tgz",
- "integrity": "sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==",
- "license": "MIT",
- "dependencies": {
- "@cacheable/memory": "^2.0.7",
- "@cacheable/utils": "^2.3.3",
- "hookified": "^1.15.0",
- "keyv": "^5.5.5",
- "qified": "^0.6.0"
- }
- },
- "node_modules/camelcase": {
- "version": "5.3.1",
- "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
- "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -1986,50 +1231,12 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
- "node_modules/cliui": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
- "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
- "license": "ISC",
- "dependencies": {
- "string-width": "^4.2.0",
- "strip-ansi": "^6.0.0",
- "wrap-ansi": "^6.2.0"
- }
- },
- "node_modules/color-convert": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
- "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
- "license": "MIT",
- "dependencies": {
- "color-name": "~1.1.4"
- },
- "engines": {
- "node": ">=7.0.0"
- }
- },
- "node_modules/color-name": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
- "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
- "license": "MIT"
- },
"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/content-type": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
- "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/cron-parser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz",
@@ -2042,12 +1249,6 @@
"node": ">=18"
}
},
- "node_modules/curve25519-js": {
- "version": "0.0.4",
- "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
- "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
- "license": "MIT"
- },
"node_modules/dateformat": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
@@ -2057,32 +1258,6 @@
"node": "*"
}
},
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/decamelize": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
- "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -2116,18 +1291,6 @@
"node": ">=8"
}
},
- "node_modules/dijkstrajs": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
- "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
- "license": "MIT"
- },
- "node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -2196,12 +1359,6 @@
"@types/estree": "^1.0.0"
}
},
- "node_modules/eventemitter3": {
- "version": "5.0.4",
- "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
- "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
- "license": "MIT"
- },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -2251,43 +1408,12 @@
}
}
},
- "node_modules/file-type": {
- "version": "21.3.0",
- "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
- "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/inflate": "^0.4.1",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.1",
- "uint8array-extras": "^1.4.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sindresorhus/file-type?sponsor=1"
- }
- },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
- "node_modules/find-up": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
- "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
- "license": "MIT",
- "dependencies": {
- "locate-path": "^5.0.0",
- "path-exists": "^4.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -2309,15 +1435,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
@@ -2347,30 +1464,12 @@
"node": ">=8"
}
},
- "node_modules/hashery": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.4.0.tgz",
- "integrity": "sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
"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/hookified": {
- "version": "1.15.1",
- "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
- "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
- "license": "MIT"
- },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -2426,15 +1525,6 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
- "node_modules/is-fullwidth-code-point": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
- "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/istanbul-lib-coverage": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz",
@@ -2490,91 +1580,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/keyv": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
- "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@keyv/serialize": "^1.1.1"
- }
- },
- "node_modules/libsignal": {
- "name": "@whiskeysockets/libsignal-node",
- "version": "2.0.1",
- "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
- "license": "GPL-3.0",
- "dependencies": {
- "curve25519-js": "^0.0.4",
- "protobufjs": "6.8.8"
- }
- },
- "node_modules/libsignal/node_modules/@types/node": {
- "version": "10.17.60",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
- "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
- "license": "MIT"
- },
- "node_modules/libsignal/node_modules/long": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
- "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
- "license": "Apache-2.0"
- },
- "node_modules/libsignal/node_modules/protobufjs": {
- "version": "6.8.8",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
- "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/long": "^4.0.0",
- "@types/node": "^10.1.0",
- "long": "^4.0.0"
- },
- "bin": {
- "pbjs": "bin/pbjs",
- "pbts": "bin/pbts"
- }
- },
- "node_modules/locate-path": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
- "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
- "license": "MIT",
- "dependencies": {
- "p-locate": "^4.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/long": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
- "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
- "license": "Apache-2.0"
- },
- "node_modules/lru-cache": {
- "version": "11.2.6",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
- "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
- "license": "BlueOak-1.0.0",
- "engines": {
- "node": "20 || >=22"
- }
- },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@@ -2622,15 +1627,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/media-typer": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
- "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -2658,43 +1654,6 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
- "node_modules/music-metadata": {
- "version": "11.12.0",
- "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.0.tgz",
- "integrity": "sha512-9ChYnmVmyHvFxR2g0MWFSHmJfbssRy07457G4gbb4LA9WYvyZea/8EMbqvg5dcv4oXNCNL01m8HXtymLlhhkYg==",
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- },
- {
- "type": "buymeacoffee",
- "url": "https://buymeacoffee.com/borewit"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "content-type": "^1.0.5",
- "debug": "^4.4.3",
- "file-type": "^21.3.0",
- "media-typer": "^1.1.0",
- "strtok3": "^10.3.4",
- "token-types": "^6.1.2",
- "uint8array-extras": "^1.5.0",
- "win-guid": "^0.2.1"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2761,79 +1720,6 @@
"wrappy": "1"
}
},
- "node_modules/p-limit": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
- "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
- "license": "MIT",
- "dependencies": {
- "p-try": "^2.0.0"
- },
- "engines": {
- "node": ">=6"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-locate": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
- "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
- "license": "MIT",
- "dependencies": {
- "p-limit": "^2.2.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/p-queue": {
- "version": "9.1.0",
- "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
- "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
- "license": "MIT",
- "dependencies": {
- "eventemitter3": "^5.0.1",
- "p-timeout": "^7.0.0"
- },
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-timeout": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
- "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/p-try": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
- "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
- "license": "MIT",
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/path-exists": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
- "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/pathe": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -2932,15 +1818,6 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
- "node_modules/pngjs": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
- "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
- "license": "MIT",
- "engines": {
- "node": ">=10.13.0"
- }
- },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3028,30 +1905,6 @@
],
"license": "MIT"
},
- "node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
- "hasInstallScript": true,
- "license": "BSD-3-Clause",
- "dependencies": {
- "@protobufjs/aspromise": "^1.1.2",
- "@protobufjs/base64": "^1.1.2",
- "@protobufjs/codegen": "^2.0.4",
- "@protobufjs/eventemitter": "^1.1.0",
- "@protobufjs/fetch": "^1.1.0",
- "@protobufjs/float": "^1.0.2",
- "@protobufjs/inquire": "^1.1.0",
- "@protobufjs/path": "^1.1.2",
- "@protobufjs/pool": "^1.1.0",
- "@protobufjs/utf8": "^1.1.0",
- "@types/node": ">=13.7.0",
- "long": "^5.0.0"
- },
- "engines": {
- "node": ">=12.0.0"
- }
- },
"node_modules/pump": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz",
@@ -3062,43 +1915,6 @@
"once": "^1.3.1"
}
},
- "node_modules/qified": {
- "version": "0.6.0",
- "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
- "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
- "license": "MIT",
- "dependencies": {
- "hookified": "^1.14.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
- "node_modules/qrcode": {
- "version": "1.5.4",
- "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
- "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
- "license": "MIT",
- "dependencies": {
- "dijkstrajs": "^1.0.1",
- "pngjs": "^5.0.0",
- "yargs": "^15.3.1"
- },
- "bin": {
- "qrcode": "bin/qrcode"
- },
- "engines": {
- "node": ">=10.13.0"
- }
- },
- "node_modules/qrcode-terminal": {
- "version": "0.12.0",
- "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
- "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
- "bin": {
- "qrcode-terminal": "bin/qrcode-terminal.js"
- }
- },
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
@@ -3152,21 +1968,6 @@
"node": ">= 12.13.0"
}
},
- "node_modules/require-directory": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
- "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
- "license": "MIT",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/require-main-filename": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
- "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
- "license": "ISC"
- },
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
@@ -3279,57 +2080,6 @@
"node": ">=10"
}
},
- "node_modules/set-blocking": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
- "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
- "license": "ISC"
- },
- "node_modules/sharp": {
- "version": "0.34.5",
- "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
- "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "peer": true,
- "dependencies": {
- "@img/colour": "^1.0.0",
- "detect-libc": "^2.1.2",
- "semver": "^7.7.3"
- },
- "engines": {
- "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/libvips"
- },
- "optionalDependencies": {
- "@img/sharp-darwin-arm64": "0.34.5",
- "@img/sharp-darwin-x64": "0.34.5",
- "@img/sharp-libvips-darwin-arm64": "1.2.4",
- "@img/sharp-libvips-darwin-x64": "1.2.4",
- "@img/sharp-libvips-linux-arm": "1.2.4",
- "@img/sharp-libvips-linux-arm64": "1.2.4",
- "@img/sharp-libvips-linux-ppc64": "1.2.4",
- "@img/sharp-libvips-linux-riscv64": "1.2.4",
- "@img/sharp-libvips-linux-s390x": "1.2.4",
- "@img/sharp-libvips-linux-x64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
- "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
- "@img/sharp-linux-arm": "0.34.5",
- "@img/sharp-linux-arm64": "0.34.5",
- "@img/sharp-linux-ppc64": "0.34.5",
- "@img/sharp-linux-riscv64": "0.34.5",
- "@img/sharp-linux-s390x": "0.34.5",
- "@img/sharp-linux-x64": "0.34.5",
- "@img/sharp-linuxmusl-arm64": "0.34.5",
- "@img/sharp-linuxmusl-x64": "0.34.5",
- "@img/sharp-wasm32": "0.34.5",
- "@img/sharp-win32-arm64": "0.34.5",
- "@img/sharp-win32-ia32": "0.34.5",
- "@img/sharp-win32-x64": "0.34.5"
- }
- },
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -3433,32 +2183,6 @@
"safe-buffer": "~5.2.0"
}
},
- "node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-json-comments": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
@@ -3471,22 +2195,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strtok3": {
- "version": "10.3.4",
- "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
- "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
- "license": "MIT",
- "dependencies": {
- "@tokenizer/token": "^0.3.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3581,30 +2289,6 @@
"node": ">=14.0.0"
}
},
- "node_modules/token-types": {
- "version": "6.1.2",
- "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
- "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
- "license": "MIT",
- "dependencies": {
- "@borewit/text-codec": "^0.2.1",
- "@tokenizer/token": "^0.3.0",
- "ieee754": "^1.2.1"
- },
- "engines": {
- "node": ">=14.16"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Borewit"
- }
- },
- "node_modules/tslib": {
- "version": "2.8.1",
- "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
- "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
- "license": "0BSD"
- },
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -3652,22 +2336,11 @@
"node": ">=14.17"
}
},
- "node_modules/uint8array-extras": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
- "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
- "license": "MIT",
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": {
@@ -3831,12 +2504,6 @@
}
}
},
- "node_modules/which-module": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
- "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
- "license": "ISC"
- },
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3854,59 +2521,12 @@
"node": ">=8"
}
},
- "node_modules/win-guid": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
- "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
- "license": "MIT"
- },
- "node_modules/wrap-ansi": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
- "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
- "node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/y18n": {
- "version": "4.0.3",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
- "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
- "license": "ISC"
- },
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
@@ -3923,41 +2543,6 @@
"url": "https://github.com/sponsors/eemeli"
}
},
- "node_modules/yargs": {
- "version": "15.4.1",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
- "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^6.0.0",
- "decamelize": "^1.2.0",
- "find-up": "^4.1.0",
- "get-caller-file": "^2.0.1",
- "require-directory": "^2.1.1",
- "require-main-filename": "^2.0.0",
- "set-blocking": "^2.0.0",
- "string-width": "^4.2.0",
- "which-module": "^2.0.0",
- "y18n": "^4.0.0",
- "yargs-parser": "^18.1.2"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/yargs-parser": {
- "version": "18.1.3",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
- "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
- "license": "ISC",
- "dependencies": {
- "camelcase": "^5.0.0",
- "decamelize": "^1.2.0"
- },
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
diff --git a/package.json b/package.json
index cad0604..6f78b4c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.1.6",
+ "version": "1.2.0",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
@@ -8,31 +8,27 @@
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
- "auth": "tsx src/whatsapp-auth.ts",
"typecheck": "tsc --noEmit",
"format": "prettier --write \"src/**/*.ts\"",
"format:fix": "prettier --write \"src/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\"",
"prepare": "husky",
"setup": "tsx setup/index.ts",
+ "auth": "tsx src/whatsapp-auth.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
- "@whiskeysockets/baileys": "^7.0.0-rc.9",
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
- "qrcode": "^1.5.4",
- "qrcode-terminal": "^0.12.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.0",
- "@types/qrcode-terminal": "^0.12.2",
"@vitest/coverage-v8": "^4.0.18",
"husky": "^9.1.7",
"prettier": "^3.8.1",
diff --git a/setup/environment.test.ts b/setup/environment.test.ts
index b33f272..deda62f 100644
--- a/setup/environment.test.ts
+++ b/setup/environment.test.ts
@@ -104,9 +104,8 @@ describe('Docker detection logic', () => {
});
});
-describe('WhatsApp auth detection', () => {
- it('detects non-empty auth directory logic', () => {
- // Simulate the check: directory exists and has files
+describe('channel auth detection', () => {
+ it('detects non-empty auth directory', () => {
const hasAuth = (authDir: string) => {
try {
return fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
@@ -119,3 +118,4 @@ describe('WhatsApp auth detection', () => {
expect(hasAuth('/tmp/nonexistent_auth_dir_xyz')).toBe(false);
});
});
+
diff --git a/setup/groups.ts b/setup/groups.ts
index d251d5d..6697029 100644
--- a/setup/groups.ts
+++ b/setup/groups.ts
@@ -1,5 +1,7 @@
/**
- * Step: groups — Connect to WhatsApp, fetch group metadata, write to DB.
+ * Step: groups — Fetch group metadata from messaging platforms, write to DB.
+ * WhatsApp requires an upfront sync (Baileys groupFetchAllParticipating).
+ * Other channels discover group names at runtime — this step auto-skips for them.
* Replaces 05-sync-groups.sh + 05b-list-groups.sh
*/
import { execSync } from 'child_process';
@@ -62,6 +64,25 @@ async function listGroups(limit: number): Promise {
}
async function syncGroups(projectRoot: string): Promise {
+ // Only WhatsApp needs an upfront group sync; other channels resolve names at runtime.
+ // Detect WhatsApp by checking for auth credentials on disk.
+ const authDir = path.join(projectRoot, 'store', 'auth');
+ const hasWhatsAppAuth =
+ fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0;
+
+ if (!hasWhatsAppAuth) {
+ logger.info('WhatsApp auth not found — skipping group sync');
+ emitStatus('SYNC_GROUPS', {
+ BUILD: 'skipped',
+ SYNC: 'skipped',
+ GROUPS_IN_DB: 0,
+ REASON: 'whatsapp_not_configured',
+ STATUS: 'success',
+ LOG: 'logs/setup.log',
+ });
+ return;
+ }
+
// Build TypeScript first
logger.info('Building TypeScript');
let buildOk = false;
@@ -85,7 +106,7 @@ async function syncGroups(projectRoot: string): Promise {
process.exit(1);
}
- // Run inline sync script via node
+ // Run sync script via a temp file to avoid shell escaping issues with node -e
logger.info('Fetching group metadata');
let syncOk = false;
try {
@@ -158,17 +179,20 @@ sock.ev.on('connection.update', async (update) => {
});
`;
- const output = execSync(
- `node --input-type=module -e ${JSON.stringify(syncScript)}`,
- {
+ const tmpScript = path.join(projectRoot, '.tmp-group-sync.mjs');
+ fs.writeFileSync(tmpScript, syncScript, 'utf-8');
+ try {
+ const output = execSync(`node ${tmpScript}`, {
cwd: projectRoot,
encoding: 'utf-8',
timeout: 45000,
stdio: ['ignore', 'pipe', 'pipe'],
- },
- );
- syncOk = output.includes('SYNCED:');
- logger.info({ output: output.trim() }, 'Sync output');
+ });
+ syncOk = output.includes('SYNCED:');
+ logger.info({ output: output.trim() }, 'Sync output');
+ } finally {
+ try { fs.unlinkSync(tmpScript); } catch { /* ignore cleanup errors */ }
+ }
} catch (err) {
logger.error({ err }, 'Sync failed');
}
diff --git a/setup/index.ts b/setup/index.ts
index 287a790..7ac13e2 100644
--- a/setup/index.ts
+++ b/setup/index.ts
@@ -11,7 +11,6 @@ const STEPS: Record<
> = {
environment: () => import('./environment.js'),
container: () => import('./container.js'),
- 'whatsapp-auth': () => import('./whatsapp-auth.js'),
groups: () => import('./groups.js'),
register: () => import('./register.js'),
mounts: () => import('./mounts.js'),
diff --git a/setup/register.test.ts b/setup/register.test.ts
index 7258445..d47d95c 100644
--- a/setup/register.test.ts
+++ b/setup/register.test.ts
@@ -18,7 +18,8 @@ function createTestDb(): Database.Database {
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
- requires_trigger INTEGER DEFAULT 1
+ requires_trigger INTEGER DEFAULT 1,
+ is_main INTEGER DEFAULT 0
)`);
return db;
}
@@ -130,6 +131,49 @@ describe('parameterized SQL registration', () => {
expect(row.requires_trigger).toBe(0);
});
+ it('stores is_main flag', () => {
+ db.prepare(
+ `INSERT OR REPLACE INTO registered_groups
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
+ ).run(
+ '789@s.whatsapp.net',
+ 'Personal',
+ 'whatsapp_main',
+ '@Andy',
+ '2024-01-01T00:00:00.000Z',
+ 0,
+ 1,
+ );
+
+ const row = db
+ .prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
+ .get('789@s.whatsapp.net') as { is_main: number };
+
+ expect(row.is_main).toBe(1);
+ });
+
+ it('defaults is_main to 0', () => {
+ db.prepare(
+ `INSERT OR REPLACE INTO registered_groups
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
+ VALUES (?, ?, ?, ?, ?, NULL, ?)`,
+ ).run(
+ '123@g.us',
+ 'Some Group',
+ 'whatsapp_some-group',
+ '@Andy',
+ '2024-01-01T00:00:00.000Z',
+ 1,
+ );
+
+ const row = db
+ .prepare('SELECT is_main FROM registered_groups WHERE jid = ?')
+ .get('123@g.us') as { is_main: number };
+
+ expect(row.is_main).toBe(0);
+ });
+
it('upserts on conflict', () => {
const stmt = db.prepare(
`INSERT OR REPLACE INTO registered_groups
diff --git a/setup/register.ts b/setup/register.ts
index 55c3569..03ea7df 100644
--- a/setup/register.ts
+++ b/setup/register.ts
@@ -1,8 +1,8 @@
/**
* Step: register — Write channel registration config, create group folders.
- * Replaces 06-register-channel.sh
*
- * Fixes: SQL injection (parameterized queries), sed -i '' (uses fs directly).
+ * Accepts --channel to specify the messaging platform (whatsapp, telegram, slack, discord).
+ * Uses parameterized SQL queries to prevent injection.
*/
import fs from 'fs';
import path from 'path';
@@ -19,7 +19,9 @@ interface RegisterArgs {
name: string;
trigger: string;
folder: string;
+ channel: string;
requiresTrigger: boolean;
+ isMain: boolean;
assistantName: string;
}
@@ -29,7 +31,9 @@ function parseArgs(args: string[]): RegisterArgs {
name: '',
trigger: '',
folder: '',
+ channel: 'whatsapp', // backward-compat: pre-refactor installs omit --channel
requiresTrigger: true,
+ isMain: false,
assistantName: 'Andy',
};
@@ -47,9 +51,15 @@ function parseArgs(args: string[]): RegisterArgs {
case '--folder':
result.folder = args[++i] || '';
break;
+ case '--channel':
+ result.channel = (args[++i] || '').toLowerCase();
+ break;
case '--no-trigger-required':
result.requiresTrigger = false;
break;
+ case '--is-main':
+ result.isMain = true;
+ break;
case '--assistant-name':
result.assistantName = args[++i] || 'Andy';
break;
@@ -83,8 +93,10 @@ export async function run(args: string[]): Promise {
logger.info(parsed, 'Registering channel');
- // Ensure data directory exists
+ // Ensure data and store directories exist (store/ may not exist on
+ // fresh installs that skip WhatsApp auth, which normally creates it)
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');
@@ -100,13 +112,16 @@ export async function run(args: string[]): Promise {
trigger_pattern TEXT NOT NULL,
added_at TEXT NOT NULL,
container_config TEXT,
- requires_trigger INTEGER DEFAULT 1
+ requires_trigger INTEGER DEFAULT 1,
+ is_main INTEGER DEFAULT 0
)`);
+ 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)
- VALUES (?, ?, ?, ?, ?, NULL, ?)`,
+ (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, NULL, ?, ?)`,
).run(
parsed.jid,
parsed.name,
@@ -114,6 +129,7 @@ export async function run(args: string[]): Promise {
parsed.trigger,
timestamp,
requiresTriggerInt,
+ isMainInt,
);
db.close();
@@ -134,7 +150,7 @@ export async function run(args: string[]): Promise {
const mdFiles = [
path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'),
- path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'),
+ path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'),
];
for (const mdFile of mdFiles) {
@@ -174,6 +190,7 @@ export async function run(args: string[]): Promise {
JID: parsed.jid,
NAME: parsed.name,
FOLDER: parsed.folder,
+ CHANNEL: parsed.channel,
TRIGGER: parsed.trigger,
REQUIRES_TRIGGER: parsed.requiresTrigger,
ASSISTANT_NAME: parsed.assistantName,
diff --git a/setup/service.ts b/setup/service.ts
index 9e7932a..643c8c9 100644
--- a/setup/service.ts
+++ b/setup/service.ts
@@ -161,7 +161,7 @@ function setupLinux(
/**
* Kill any orphaned nanoclaw node processes left from previous runs or debugging.
- * Prevents WhatsApp "conflict" disconnects when two instances connect simultaneously.
+ * Prevents connection conflicts when two instances connect to the same channel simultaneously.
*/
function killOrphanedProcesses(projectRoot: string): void {
try {
@@ -262,7 +262,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`;
);
}
- // Kill orphaned nanoclaw processes to avoid WhatsApp conflict errors
+ // Kill orphaned nanoclaw processes to avoid channel connection conflicts
killOrphanedProcesses(projectRoot);
// Enable and start
diff --git a/setup/verify.ts b/setup/verify.ts
index a08a431..f64e4d0 100644
--- a/setup/verify.ts
+++ b/setup/verify.ts
@@ -12,6 +12,7 @@ import path from 'path';
import Database from 'better-sqlite3';
import { STORE_DIR } from '../src/config.js';
+import { readEnvFile } from '../src/env.js';
import { logger } from '../src/logger.js';
import {
getPlatform,
@@ -105,13 +106,39 @@ export async function run(_args: string[]): Promise {
}
}
- // 4. Check WhatsApp auth
- let whatsappAuth = 'not_found';
+ // 4. Check channel auth (detect configured channels by credentials)
+ const envVars = readEnvFile([
+ 'TELEGRAM_BOT_TOKEN',
+ 'SLACK_BOT_TOKEN',
+ 'SLACK_APP_TOKEN',
+ 'DISCORD_BOT_TOKEN',
+ ]);
+
+ const channelAuth: Record = {};
+
+ // WhatsApp: check for auth credentials on disk
const authDir = path.join(projectRoot, 'store', 'auth');
if (fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0) {
- whatsappAuth = 'authenticated';
+ channelAuth.whatsapp = 'authenticated';
}
+ // Token-based channels: check .env
+ if (process.env.TELEGRAM_BOT_TOKEN || envVars.TELEGRAM_BOT_TOKEN) {
+ channelAuth.telegram = 'configured';
+ }
+ if (
+ (process.env.SLACK_BOT_TOKEN || envVars.SLACK_BOT_TOKEN) &&
+ (process.env.SLACK_APP_TOKEN || envVars.SLACK_APP_TOKEN)
+ ) {
+ channelAuth.slack = 'configured';
+ }
+ if (process.env.DISCORD_BOT_TOKEN || envVars.DISCORD_BOT_TOKEN) {
+ channelAuth.discord = 'configured';
+ }
+
+ const configuredChannels = Object.keys(channelAuth);
+ const anyChannelConfigured = configuredChannels.length > 0;
+
// 5. Check registered groups (using better-sqlite3, not sqlite3 CLI)
let registeredGroups = 0;
const dbPath = path.join(STORE_DIR, 'messages.db');
@@ -142,18 +169,19 @@ export async function run(_args: string[]): Promise {
const status =
service === 'running' &&
credentials !== 'missing' &&
- whatsappAuth !== 'not_found' &&
+ anyChannelConfigured &&
registeredGroups > 0
? 'success'
: 'failed';
- logger.info({ status }, 'Verification complete');
+ logger.info({ status, channelAuth }, 'Verification complete');
emitStatus('VERIFY', {
SERVICE: service,
CONTAINER_RUNTIME: containerRuntime,
CREDENTIALS: credentials,
- WHATSAPP_AUTH: whatsappAuth,
+ CONFIGURED_CHANNELS: configuredChannels.join(','),
+ CHANNEL_AUTH: JSON.stringify(channelAuth),
REGISTERED_GROUPS: registeredGroups,
MOUNT_ALLOWLIST: mountAllowlist,
STATUS: status,
diff --git a/src/channels/index.ts b/src/channels/index.ts
new file mode 100644
index 0000000..44f4f55
--- /dev/null
+++ b/src/channels/index.ts
@@ -0,0 +1,12 @@
+// Channel self-registration barrel file.
+// Each import triggers the channel module's registerChannel() call.
+
+// discord
+
+// gmail
+
+// slack
+
+// telegram
+
+// whatsapp
diff --git a/src/channels/registry.test.ts b/src/channels/registry.test.ts
new file mode 100644
index 0000000..e47b1bf
--- /dev/null
+++ b/src/channels/registry.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, beforeEach } from 'vitest';
+
+import {
+ registerChannel,
+ getChannelFactory,
+ getRegisteredChannelNames,
+} from './registry.js';
+
+// The registry is module-level state, so we need a fresh module per test.
+// We use dynamic import with cache-busting to isolate tests.
+// However, since vitest runs each file in its own context and we control
+// registration order, we can test the public API directly.
+
+describe('channel registry', () => {
+ // Note: registry is shared module state across tests in this file.
+ // Tests are ordered to account for cumulative registrations.
+
+ it('getChannelFactory returns undefined for unknown channel', () => {
+ expect(getChannelFactory('nonexistent')).toBeUndefined();
+ });
+
+ it('registerChannel and getChannelFactory round-trip', () => {
+ const factory = () => null;
+ registerChannel('test-channel', factory);
+ expect(getChannelFactory('test-channel')).toBe(factory);
+ });
+
+ it('getRegisteredChannelNames includes registered channels', () => {
+ registerChannel('another-channel', () => null);
+ const names = getRegisteredChannelNames();
+ expect(names).toContain('test-channel');
+ expect(names).toContain('another-channel');
+ });
+
+ it('later registration overwrites earlier one', () => {
+ const factory1 = () => null;
+ const factory2 = () => null;
+ registerChannel('overwrite-test', factory1);
+ registerChannel('overwrite-test', factory2);
+ expect(getChannelFactory('overwrite-test')).toBe(factory2);
+ });
+});
diff --git a/src/channels/registry.ts b/src/channels/registry.ts
new file mode 100644
index 0000000..ab871c3
--- /dev/null
+++ b/src/channels/registry.ts
@@ -0,0 +1,28 @@
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+
+export interface ChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export type ChannelFactory = (opts: ChannelOpts) => Channel | null;
+
+const registry = new Map();
+
+export function registerChannel(name: string, factory: ChannelFactory): void {
+ registry.set(name, factory);
+}
+
+export function getChannelFactory(name: string): ChannelFactory | undefined {
+ return registry.get(name);
+}
+
+export function getRegisteredChannelNames(): string[] {
+ return [...registry.keys()];
+}
diff --git a/src/config.ts b/src/config.ts
index 8a4cb92..d57205b 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -30,7 +30,6 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
-export const MAIN_GROUP_FOLDER = 'main';
export const CONTAINER_IMAGE =
process.env.CONTAINER_IMAGE || 'nanoclaw-agent:latest';
diff --git a/src/db.test.ts b/src/db.test.ts
index e7f772c..3051bce 100644
--- a/src/db.test.ts
+++ b/src/db.test.ts
@@ -5,9 +5,11 @@ import {
createTask,
deleteTask,
getAllChats,
+ getAllRegisteredGroups,
getMessagesSince,
getNewMessages,
getTaskById,
+ setRegisteredGroup,
storeChatMetadata,
storeMessage,
updateTask,
@@ -388,3 +390,37 @@ describe('task CRUD', () => {
expect(getTaskById('task-3')).toBeUndefined();
});
});
+
+// --- RegisteredGroup isMain round-trip ---
+
+describe('registered group isMain', () => {
+ it('persists isMain=true through set/get round-trip', () => {
+ setRegisteredGroup('main@s.whatsapp.net', {
+ name: 'Main Chat',
+ folder: 'whatsapp_main',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ isMain: true,
+ });
+
+ const groups = getAllRegisteredGroups();
+ const group = groups['main@s.whatsapp.net'];
+ expect(group).toBeDefined();
+ expect(group.isMain).toBe(true);
+ expect(group.folder).toBe('whatsapp_main');
+ });
+
+ it('omits isMain for non-main groups', () => {
+ setRegisteredGroup('group@g.us', {
+ name: 'Family Chat',
+ folder: 'whatsapp_family-chat',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ });
+
+ const groups = getAllRegisteredGroups();
+ const group = groups['group@g.us'];
+ expect(group).toBeDefined();
+ expect(group.isMain).toBeUndefined();
+ });
+});
diff --git a/src/db.ts b/src/db.ts
index 9d9a4d5..09d786f 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -106,6 +106,19 @@ function createSchema(database: Database.Database): void {
/* column already exists */
}
+ // Add is_main column if it doesn't exist (migration for existing DBs)
+ try {
+ database.exec(
+ `ALTER TABLE registered_groups ADD COLUMN is_main INTEGER DEFAULT 0`,
+ );
+ // Backfill: existing rows with folder = 'main' are the main group
+ database.exec(
+ `UPDATE registered_groups SET is_main = 1 WHERE folder = 'main'`,
+ );
+ } catch {
+ /* column already exists */
+ }
+
// Add channel and is_group columns if they don't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE chats ADD COLUMN channel TEXT`);
@@ -263,7 +276,7 @@ export function storeMessage(msg: NewMessage): void {
}
/**
- * Store a message directly (for non-WhatsApp channels that don't use Baileys proto).
+ * Store a message directly.
*/
export function storeMessageDirect(msg: {
id: string;
@@ -530,6 +543,7 @@ export function getRegisteredGroup(
added_at: string;
container_config: string | null;
requires_trigger: number | null;
+ is_main: number | null;
}
| undefined;
if (!row) return undefined;
@@ -551,6 +565,7 @@ export function getRegisteredGroup(
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
+ isMain: row.is_main === 1 ? true : undefined,
};
}
@@ -559,8 +574,8 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`);
}
db.prepare(
- `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger)
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
jid,
group.name,
@@ -569,6 +584,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void {
group.added_at,
group.containerConfig ? JSON.stringify(group.containerConfig) : null,
group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0,
+ group.isMain ? 1 : 0,
);
}
@@ -581,6 +597,7 @@ export function getAllRegisteredGroups(): Record {
added_at: string;
container_config: string | null;
requires_trigger: number | null;
+ is_main: number | null;
}>;
const result: Record = {};
for (const row of rows) {
@@ -601,6 +618,7 @@ export function getAllRegisteredGroups(): Record {
: undefined,
requiresTrigger:
row.requires_trigger === null ? undefined : row.requires_trigger === 1,
+ isMain: row.is_main === 1 ? true : undefined,
};
}
return result;
diff --git a/src/index.ts b/src/index.ts
index 278a7a7..234be79 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,11 +4,14 @@ import path from 'path';
import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
- MAIN_GROUP_FOLDER,
POLL_INTERVAL,
TRIGGER_PATTERN,
} from './config.js';
-import { WhatsAppChannel } from './channels/whatsapp.js';
+import './channels/index.js';
+import {
+ getChannelFactory,
+ getRegisteredChannelNames,
+} from './channels/registry.js';
import {
ContainerOutput,
runContainerAgent,
@@ -51,7 +54,6 @@ let registeredGroups: Record = {};
let lastAgentTimestamp: Record = {};
let messageLoopRunning = false;
-let whatsapp: WhatsAppChannel;
const channels: Channel[] = [];
const queue = new GroupQueue();
@@ -140,7 +142,7 @@ async function processGroupMessages(chatJid: string): Promise {
return true;
}
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const isMainGroup = group.isMain === true;
const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
const missedMessages = getMessagesSince(
@@ -250,7 +252,7 @@ async function runAgent(
chatJid: string,
onOutput?: (output: ContainerOutput) => Promise,
): Promise<'success' | 'error'> {
- const isMain = group.folder === MAIN_GROUP_FOLDER;
+ const isMain = group.isMain === true;
const sessionId = sessions[group.folder];
// Update tasks snapshot for container to read (filtered by group)
@@ -371,7 +373,7 @@ async function startMessageLoop(): Promise {
continue;
}
- const isMainGroup = group.folder === MAIN_GROUP_FOLDER;
+ const isMainGroup = group.isMain === true;
const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
// For non-main groups, only act on trigger messages.
@@ -474,10 +476,26 @@ async function main(): Promise {
registeredGroups: () => registeredGroups,
};
- // Create and connect channels
- whatsapp = new WhatsAppChannel(channelOpts);
- channels.push(whatsapp);
- await whatsapp.connect();
+ // Create and connect all registered channels.
+ // Each channel self-registers via the barrel import above.
+ // Factories return null when credentials are missing, so unconfigured channels are skipped.
+ for (const channelName of getRegisteredChannelNames()) {
+ const factory = getChannelFactory(channelName)!;
+ const channel = factory(channelOpts);
+ if (!channel) {
+ logger.warn(
+ { channel: channelName },
+ 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
+ );
+ continue;
+ }
+ channels.push(channel);
+ await channel.connect();
+ }
+ if (channels.length === 0) {
+ logger.fatal('No channels connected');
+ process.exit(1);
+ }
// Start subsystems (independently of connection handler)
startSchedulerLoop({
@@ -504,8 +522,13 @@ async function main(): Promise {
},
registeredGroups: () => registeredGroups,
registerGroup,
- syncGroupMetadata: (force) =>
- whatsapp?.syncGroupMetadata(force) ?? Promise.resolve(),
+ syncGroups: async (force: boolean) => {
+ await Promise.all(
+ channels
+ .filter((ch) => ch.syncGroups)
+ .map((ch) => ch.syncGroups!(force)),
+ );
+ },
getAvailableGroups,
writeGroupsSnapshot: (gf, im, ag, rj) =>
writeGroupsSnapshot(gf, im, ag, rj),
diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts
index e155d44..7edc7db 100644
--- a/src/ipc-auth.test.ts
+++ b/src/ipc-auth.test.ts
@@ -14,9 +14,10 @@ import { RegisteredGroup } from './types.js';
// Set up registered groups used across tests
const MAIN_GROUP: RegisteredGroup = {
name: 'Main',
- folder: 'main',
+ folder: 'whatsapp_main',
trigger: 'always',
added_at: '2024-01-01T00:00:00.000Z',
+ isMain: true,
};
const OTHER_GROUP: RegisteredGroup = {
@@ -58,7 +59,7 @@ beforeEach(() => {
setRegisteredGroup(jid, group);
// Mock the fs.mkdirSync that registerGroup does
},
- syncGroupMetadata: async () => {},
+ syncGroups: async () => {},
getAvailableGroups: () => [],
writeGroupsSnapshot: () => {},
};
@@ -76,7 +77,7 @@ describe('schedule_task authorization', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -133,7 +134,7 @@ describe('schedule_task authorization', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'unknown@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -149,7 +150,7 @@ describe('pause_task authorization', () => {
beforeEach(() => {
createTask({
id: 'task-main',
- group_folder: 'main',
+ group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
@@ -176,7 +177,7 @@ describe('pause_task authorization', () => {
it('main group can pause any task', async () => {
await processTaskIpc(
{ type: 'pause_task', taskId: 'task-other' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -225,7 +226,7 @@ describe('resume_task authorization', () => {
it('main group can resume any task', async () => {
await processTaskIpc(
{ type: 'resume_task', taskId: 'task-paused' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -272,7 +273,7 @@ describe('cancel_task authorization', () => {
await processTaskIpc(
{ type: 'cancel_task', taskId: 'task-to-cancel' },
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -305,7 +306,7 @@ describe('cancel_task authorization', () => {
it('non-main group cannot cancel another groups task', async () => {
createTask({
id: 'task-foreign',
- group_folder: 'main',
+ group_folder: 'whatsapp_main',
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
@@ -356,7 +357,7 @@ describe('register_group authorization', () => {
folder: '../../outside',
trigger: '@Andy',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -397,8 +398,12 @@ describe('IPC message authorization', () => {
}
it('main group can send to any group', () => {
- expect(isMessageAuthorized('main', true, 'other@g.us', groups)).toBe(true);
- expect(isMessageAuthorized('main', true, 'third@g.us', groups)).toBe(true);
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'other@g.us', groups),
+ ).toBe(true);
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'third@g.us', groups),
+ ).toBe(true);
});
it('non-main group can send to its own chat', () => {
@@ -424,9 +429,9 @@ describe('IPC message authorization', () => {
it('main group can send to unregistered JID', () => {
// Main is always authorized regardless of target
- expect(isMessageAuthorized('main', true, 'unknown@g.us', groups)).toBe(
- true,
- );
+ expect(
+ isMessageAuthorized('whatsapp_main', true, 'unknown@g.us', groups),
+ ).toBe(true);
});
});
@@ -442,7 +447,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '0 9 * * *', // every day at 9am
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -466,7 +471,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'not a cron',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -485,7 +490,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '3600000', // 1 hour
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -508,7 +513,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'abc',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -525,7 +530,7 @@ describe('schedule_task schedule types', () => {
schedule_value: '0',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -542,7 +547,7 @@ describe('schedule_task schedule types', () => {
schedule_value: 'not-a-date',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -564,7 +569,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'group',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -583,7 +588,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'isolated',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -602,7 +607,7 @@ describe('schedule_task context_mode', () => {
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -620,7 +625,7 @@ describe('schedule_task context_mode', () => {
schedule_value: '2025-06-01T00:00:00.000Z',
targetJid: 'other@g.us',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -642,7 +647,7 @@ describe('register_group success', () => {
folder: 'new-group',
trigger: '@Andy',
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
@@ -663,7 +668,7 @@ describe('register_group success', () => {
name: 'Partial',
// missing folder and trigger
},
- 'main',
+ 'whatsapp_main',
true,
deps,
);
diff --git a/src/ipc.ts b/src/ipc.ts
index 52cf7d7..d410685 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -3,12 +3,7 @@ import path from 'path';
import { CronExpressionParser } from 'cron-parser';
-import {
- DATA_DIR,
- IPC_POLL_INTERVAL,
- MAIN_GROUP_FOLDER,
- TIMEZONE,
-} from './config.js';
+import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
import { AvailableGroup } from './container-runner.js';
import { createTask, deleteTask, getTaskById, updateTask } from './db.js';
import { isValidGroupFolder } from './group-folder.js';
@@ -19,7 +14,7 @@ export interface IpcDeps {
sendMessage: (jid: string, text: string) => Promise;
registeredGroups: () => Record;
registerGroup: (jid: string, group: RegisteredGroup) => void;
- syncGroupMetadata: (force: boolean) => Promise;
+ syncGroups: (force: boolean) => Promise;
getAvailableGroups: () => AvailableGroup[];
writeGroupsSnapshot: (
groupFolder: string,
@@ -57,8 +52,14 @@ export function startIpcWatcher(deps: IpcDeps): void {
const registeredGroups = deps.registeredGroups();
+ // Build folder→isMain lookup from registered groups
+ const folderIsMain = new Map();
+ for (const group of Object.values(registeredGroups)) {
+ if (group.isMain) folderIsMain.set(group.folder, true);
+ }
+
for (const sourceGroup of groupFolders) {
- const isMain = sourceGroup === MAIN_GROUP_FOLDER;
+ const isMain = folderIsMain.get(sourceGroup) === true;
const messagesDir = path.join(ipcBaseDir, sourceGroup, 'messages');
const tasksDir = path.join(ipcBaseDir, sourceGroup, 'tasks');
@@ -331,7 +332,7 @@ export async function processTaskIpc(
{ sourceGroup },
'Group metadata refresh requested via IPC',
);
- await deps.syncGroupMetadata(true);
+ await deps.syncGroups(true);
// Write updated snapshot immediately
const availableGroups = deps.getAvailableGroups();
deps.writeGroupsSnapshot(
@@ -365,6 +366,7 @@ export async function processTaskIpc(
);
break;
}
+ // Defense in depth: agent cannot set isMain via IPC
deps.registerGroup(data.jid, {
name: data.name,
folder: data.folder,
diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts
index f6cfa72..e4f606f 100644
--- a/src/task-scheduler.ts
+++ b/src/task-scheduler.ts
@@ -2,12 +2,7 @@ import { ChildProcess } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import fs from 'fs';
-import {
- ASSISTANT_NAME,
- MAIN_GROUP_FOLDER,
- SCHEDULER_POLL_INTERVAL,
- TIMEZONE,
-} from './config.js';
+import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
import {
ContainerOutput,
runContainerAgent,
@@ -94,7 +89,7 @@ async function runTask(
}
// Update tasks snapshot for container to read (filtered by group)
- const isMain = task.group_folder === MAIN_GROUP_FOLDER;
+ const isMain = group.isMain === true;
const tasks = getAllTasks();
writeTasksSnapshot(
task.group_folder,
diff --git a/src/types.ts b/src/types.ts
index 7038b3a..acbb08a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -39,6 +39,7 @@ export interface RegisteredGroup {
added_at: string;
containerConfig?: ContainerConfig;
requiresTrigger?: boolean; // Default: true for groups, false for solo chats
+ isMain?: boolean; // True for the main control group (no trigger, elevated privileges)
}
export interface NewMessage {
@@ -87,6 +88,8 @@ export interface Channel {
disconnect(): Promise;
// Optional: typing indicator. Channels that support it implement it.
setTyping?(jid: string, isTyping: boolean): Promise;
+ // Optional: sync group/chat names from the platform.
+ syncGroups?(force: boolean): Promise;
}
// Callback type that channels use to deliver inbound messages
@@ -94,7 +97,7 @@ export type OnInboundMessage = (chatJid: string, message: NewMessage) => void;
// Callback for chat metadata discovery.
// name is optional — channels that deliver names inline (Telegram) pass it here;
-// channels that sync names separately (WhatsApp syncGroupMetadata) omit it.
+// channels that sync names separately (via syncGroups) omit it.
export type OnChatMetadata = (
chatJid: string,
timestamp: string,
From e7bad735153e08b2a97c15952fabd293551cb308 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 2 Mar 2026 22:35:57 +0000
Subject: [PATCH 016/246] chore: bump version to 1.2.1
---
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 baeffb5..b153e3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.0",
+ "version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.0",
+ "version": "1.2.1",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 6f78b4c..4083955 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.0",
+ "version": "1.2.1",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 21a4eaf6a00296fb42df1b011638c69fa800d525 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 2 Mar 2026 22:36:02 +0000
Subject: [PATCH 017/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?5.1k=20tokens=20=C2=B7=2018%=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 104f67a..949dc78 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 38.5k tokens, 19% of context window
+
+ 35.1k tokens, 18% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 38.5k
- 38.5k
+ 35.1k
+ 35.1k
From 8e664e6ba3abd157c5336bdcb8da2146edc4d101 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Wed, 4 Mar 2026 13:26:33 +0200
Subject: [PATCH 018/246] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 5655c61..a45a89e 100644
--- a/README.md
+++ b/README.md
@@ -160,7 +160,7 @@ We don't want configuration sprawl. Every user should customize NanoClaw so that
**Can I use third-party or open-source models?**
-Yes. NanoClaw supports any API-compatible model endpoint. Set these environment variables in your `.env` file:
+Yes. NanoClaw supports any Claude API-compatible model endpoint. Set these environment variables in your `.env` file:
```bash
ANTHROPIC_BASE_URL=https://your-api-endpoint.com
From 58dec06e4c2a4141bc9e14624aee2c6bae58ab47 Mon Sep 17 00:00:00 2001
From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com>
Date: Wed, 4 Mar 2026 20:59:45 +0800
Subject: [PATCH 019/246] docs: use canonical lowercase clone URL (#681)
Co-authored-by: Hao Wang
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index a45a89e..5300a02 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
## Quick Start
```bash
-git clone https://github.com/qwibitai/NanoClaw.git
+git clone https://github.com/qwibitai/nanoclaw.git
cd NanoClaw
claude
```
From 5e3d8b6c2c3b6976da6015fe8d8f07aa5e9113ff Mon Sep 17 00:00:00 2001
From: glifocat
Date: Wed, 4 Mar 2026 14:02:21 +0100
Subject: [PATCH 020/246] fix(whatsapp): add error handling to messages.upsert
handler (#695)
Wrap the inner message processing loop in a try-catch to prevent a
single malformed or edge-case message from crashing the entire handler.
Logs the error with remoteJid for debugging while continuing to process
remaining messages in the batch.
Co-authored-by: Ethan M
Co-authored-by: Claude Opus 4.6
---
.../add-whatsapp/add/src/channels/whatsapp.ts | 121 +++++++++---------
1 file changed, 64 insertions(+), 57 deletions(-)
diff --git a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
index 64fcf57..f7f27cb 100644
--- a/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
+++ b/.claude/skills/add-whatsapp/add/src/channels/whatsapp.ts
@@ -173,67 +173,74 @@ export class WhatsAppChannel implements Channel {
this.sock.ev.on('messages.upsert', async ({ messages }) => {
for (const msg of messages) {
- if (!msg.message) continue;
- // Unwrap container types (viewOnceMessageV2, ephemeralMessage,
- // editedMessage, etc.) so that conversation, extendedTextMessage,
- // imageMessage, etc. are accessible at the top level.
- const normalized = normalizeMessageContent(msg.message);
- if (!normalized) continue;
- const rawJid = msg.key.remoteJid;
- if (!rawJid || rawJid === 'status@broadcast') continue;
+ try {
+ if (!msg.message) continue;
+ // Unwrap container types (viewOnceMessageV2, ephemeralMessage,
+ // editedMessage, etc.) so that conversation, extendedTextMessage,
+ // imageMessage, etc. are accessible at the top level.
+ const normalized = normalizeMessageContent(msg.message);
+ if (!normalized) continue;
+ const rawJid = msg.key.remoteJid;
+ if (!rawJid || rawJid === 'status@broadcast') continue;
- // Translate LID JID to phone JID if applicable
- const chatJid = await this.translateJid(rawJid);
+ // Translate LID JID to phone JID if applicable
+ const chatJid = await this.translateJid(rawJid);
- const timestamp = new Date(
- Number(msg.messageTimestamp) * 1000,
- ).toISOString();
+ const timestamp = new Date(
+ Number(msg.messageTimestamp) * 1000,
+ ).toISOString();
- // Always notify about chat metadata for group discovery
- const isGroup = chatJid.endsWith('@g.us');
- this.opts.onChatMetadata(
- chatJid,
- timestamp,
- undefined,
- 'whatsapp',
- isGroup,
- );
-
- // Only deliver full message for registered groups
- const groups = this.opts.registeredGroups();
- if (groups[chatJid]) {
- const content =
- normalized.conversation ||
- normalized.extendedTextMessage?.text ||
- normalized.imageMessage?.caption ||
- normalized.videoMessage?.caption ||
- '';
-
- // Skip protocol messages with no text content (encryption keys, read receipts, etc.)
- if (!content) continue;
-
- const sender = msg.key.participant || msg.key.remoteJid || '';
- const senderName = msg.pushName || sender.split('@')[0];
-
- const fromMe = msg.key.fromMe || false;
- // Detect bot messages: with own number, fromMe is reliable
- // since only the bot sends from that number.
- // With shared number, bot messages carry the assistant name prefix
- // (even in DMs/self-chat) so we check for that.
- const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
- ? fromMe
- : content.startsWith(`${ASSISTANT_NAME}:`);
-
- this.opts.onMessage(chatJid, {
- id: msg.key.id || '',
- chat_jid: chatJid,
- sender,
- sender_name: senderName,
- content,
+ // Always notify about chat metadata for group discovery
+ const isGroup = chatJid.endsWith('@g.us');
+ this.opts.onChatMetadata(
+ chatJid,
timestamp,
- is_from_me: fromMe,
- is_bot_message: isBotMessage,
- });
+ undefined,
+ 'whatsapp',
+ isGroup,
+ );
+
+ // Only deliver full message for registered groups
+ const groups = this.opts.registeredGroups();
+ if (groups[chatJid]) {
+ const content =
+ normalized.conversation ||
+ normalized.extendedTextMessage?.text ||
+ normalized.imageMessage?.caption ||
+ normalized.videoMessage?.caption ||
+ '';
+
+ // Skip protocol messages with no text content (encryption keys, read receipts, etc.)
+ if (!content) continue;
+
+ const sender = msg.key.participant || msg.key.remoteJid || '';
+ const senderName = msg.pushName || sender.split('@')[0];
+
+ const fromMe = msg.key.fromMe || false;
+ // Detect bot messages: with own number, fromMe is reliable
+ // since only the bot sends from that number.
+ // With shared number, bot messages carry the assistant name prefix
+ // (even in DMs/self-chat) so we check for that.
+ const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
+ ? fromMe
+ : content.startsWith(`${ASSISTANT_NAME}:`);
+
+ this.opts.onMessage(chatJid, {
+ id: msg.key.id || '',
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: fromMe,
+ is_bot_message: isBotMessage,
+ });
+ }
+ } catch (err) {
+ logger.error(
+ { err, remoteJid: msg.key?.remoteJid },
+ 'Error processing incoming message',
+ );
}
}
});
From 03f792bfce2b12449ab2fcaffffc35ce346da8e4 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Wed, 4 Mar 2026 14:56:31 +0100
Subject: [PATCH 021/246] feat(skills): add use-local-whisper skill package
(#702)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thanks for the great contribution @glifocat! This is a really well-structured skill — clean package, thorough docs, and solid test coverage. Hope to see more skills like this from you!
---
.../modify/src/channels/whatsapp.test.ts | 4 +
.../src/channels/whatsapp.test.ts.intent.md | 1 +
.claude/skills/use-local-whisper/SKILL.md | 128 ++++++++++++++++++
.../skills/use-local-whisper/manifest.yaml | 12 ++
.../modify/src/transcription.ts | 95 +++++++++++++
.../modify/src/transcription.ts.intent.md | 39 ++++++
.../tests/use-local-whisper.test.ts | 115 ++++++++++++++++
7 files changed, 394 insertions(+)
create mode 100644 .claude/skills/use-local-whisper/SKILL.md
create mode 100644 .claude/skills/use-local-whisper/manifest.yaml
create mode 100644 .claude/skills/use-local-whisper/modify/src/transcription.ts
create mode 100644 .claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md
create mode 100644 .claude/skills/use-local-whisper/tests/use-local-whisper.test.ts
diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
index b56c6c4..b6ef502 100644
--- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
+++ b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts
@@ -90,6 +90,10 @@ vi.mock('@whiskeysockets/baileys', () => {
timedOut: 408,
restartRequired: 515,
},
+ fetchLatestWaWebVersion: vi
+ .fn()
+ .mockResolvedValue({ version: [2, 3000, 0] }),
+ normalizeMessageContent: vi.fn((content: unknown) => content),
makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
useMultiFileAuthState: vi.fn().mockResolvedValue({
state: {
diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
index 5856320..a07e7f0 100644
--- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
+++ b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.test.ts.intent.md
@@ -8,6 +8,7 @@ Added mock for the transcription module and 3 new test cases for voice message h
### Mocks (top of file)
- Added: `vi.mock('../transcription.js', ...)` with `isVoiceMessage` and `transcribeAudioMessage` mocks
- Added: `import { transcribeAudioMessage } from '../transcription.js'` for test assertions
+- Updated: Baileys mock to include `fetchLatestWaWebVersion` and `normalizeMessageContent` exports (required by current upstream whatsapp.ts)
### Test cases (inside "message handling" describe block)
- Changed: "handles message with no extractable text (e.g. voice note without caption)" → "transcribes voice messages"
diff --git a/.claude/skills/use-local-whisper/SKILL.md b/.claude/skills/use-local-whisper/SKILL.md
new file mode 100644
index 0000000..7620b0f
--- /dev/null
+++ b/.claude/skills/use-local-whisper/SKILL.md
@@ -0,0 +1,128 @@
+---
+name: use-local-whisper
+description: Use when the user wants local voice transcription instead of OpenAI Whisper API. Switches to whisper.cpp running on Apple Silicon. WhatsApp only for now. Requires voice-transcription skill to be applied first.
+---
+
+# Use Local Whisper
+
+Switches voice transcription from OpenAI's Whisper API to local whisper.cpp. Runs entirely on-device — no API key, no network, no cost.
+
+**Channel support:** Currently WhatsApp only. The transcription module (`src/transcription.ts`) uses Baileys types for audio download. Other channels (Telegram, Discord, etc.) would need their own audio-download logic before this skill can serve them.
+
+**Note:** The Homebrew package is `whisper-cpp`, but the CLI binary it installs is `whisper-cli`.
+
+## Prerequisites
+
+- `voice-transcription` skill must be applied first (WhatsApp channel)
+- macOS with Apple Silicon (M1+) recommended
+- `whisper-cpp` installed: `brew install whisper-cpp` (provides the `whisper-cli` binary)
+- `ffmpeg` installed: `brew install ffmpeg`
+- A GGML model file downloaded to `data/models/`
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `use-local-whisper` is in `applied_skills`, skip to Phase 3 (Verify).
+
+### Check dependencies are installed
+
+```bash
+whisper-cli --help >/dev/null 2>&1 && echo "WHISPER_OK" || echo "WHISPER_MISSING"
+ffmpeg -version >/dev/null 2>&1 && echo "FFMPEG_OK" || echo "FFMPEG_MISSING"
+```
+
+If missing, install via Homebrew:
+```bash
+brew install whisper-cpp ffmpeg
+```
+
+### Check for model file
+
+```bash
+ls data/models/ggml-*.bin 2>/dev/null || echo "NO_MODEL"
+```
+
+If no model exists, download the base model (148MB, good balance of speed and accuracy):
+```bash
+mkdir -p data/models
+curl -L -o data/models/ggml-base.bin "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin"
+```
+
+For better accuracy at the cost of speed, use `ggml-small.bin` (466MB) or `ggml-medium.bin` (1.5GB).
+
+## Phase 2: Apply Code Changes
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/use-local-whisper
+```
+
+This modifies `src/transcription.ts` to use the `whisper-cli` binary instead of the OpenAI API.
+
+### Validate
+
+```bash
+npm test
+npm run build
+```
+
+## Phase 3: Verify
+
+### Ensure launchd PATH includes Homebrew
+
+The NanoClaw launchd service runs with a restricted PATH. `whisper-cli` and `ffmpeg` are in `/opt/homebrew/bin/` (Apple Silicon) or `/usr/local/bin/` (Intel), which may not be in the plist's PATH.
+
+Check the current PATH:
+```bash
+grep -A1 'PATH' ~/Library/LaunchAgents/com.nanoclaw.plist
+```
+
+If `/opt/homebrew/bin` is missing, add it to the `` value inside the `PATH` key in the plist. Then reload:
+```bash
+launchctl unload ~/Library/LaunchAgents/com.nanoclaw.plist
+launchctl load ~/Library/LaunchAgents/com.nanoclaw.plist
+```
+
+### Build and restart
+
+```bash
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+```
+
+### Test
+
+Send a voice note in any registered group. The agent should receive it as `[Voice: ]`.
+
+### Check logs
+
+```bash
+tail -f logs/nanoclaw.log | grep -i -E "voice|transcri|whisper"
+```
+
+Look for:
+- `Transcribed voice message` — successful transcription
+- `whisper.cpp transcription failed` — check model path, ffmpeg, or PATH
+
+## Configuration
+
+Environment variables (optional, set in `.env`):
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `WHISPER_BIN` | `whisper-cli` | Path to whisper.cpp binary |
+| `WHISPER_MODEL` | `data/models/ggml-base.bin` | Path to GGML model file |
+
+## Troubleshooting
+
+**"whisper.cpp transcription failed"**: Ensure both `whisper-cli` and `ffmpeg` are in PATH. The launchd service uses a restricted PATH — see Phase 3 above. Test manually:
+```bash
+ffmpeg -f lavfi -i anullsrc=r=16000:cl=mono -t 1 -f wav /tmp/test.wav -y
+whisper-cli -m data/models/ggml-base.bin -f /tmp/test.wav --no-timestamps -nt
+```
+
+**Transcription works in dev but not as service**: The launchd plist PATH likely doesn't include `/opt/homebrew/bin`. See "Ensure launchd PATH includes Homebrew" in Phase 3.
+
+**Slow transcription**: The base model processes ~30s of audio in <1s on M1+. If slower, check CPU usage — another process may be competing.
+
+**Wrong language**: whisper.cpp auto-detects language. To force a language, you can set `WHISPER_LANG` and modify `src/transcription.ts` to pass `-l $WHISPER_LANG`.
diff --git a/.claude/skills/use-local-whisper/manifest.yaml b/.claude/skills/use-local-whisper/manifest.yaml
new file mode 100644
index 0000000..3ca356d
--- /dev/null
+++ b/.claude/skills/use-local-whisper/manifest.yaml
@@ -0,0 +1,12 @@
+skill: use-local-whisper
+version: 1.0.0
+description: "Switch voice transcription from OpenAI Whisper API to local whisper.cpp (WhatsApp only)"
+core_version: 0.1.0
+adds: []
+modifies:
+ - src/transcription.ts
+structured: {}
+conflicts: []
+depends:
+ - voice-transcription
+test: "npx vitest run src/channels/whatsapp.test.ts"
diff --git a/.claude/skills/use-local-whisper/modify/src/transcription.ts b/.claude/skills/use-local-whisper/modify/src/transcription.ts
new file mode 100644
index 0000000..45f39fc
--- /dev/null
+++ b/.claude/skills/use-local-whisper/modify/src/transcription.ts
@@ -0,0 +1,95 @@
+import { execFile } from 'child_process';
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { promisify } from 'util';
+
+import { downloadMediaMessage, WAMessage, WASocket } from '@whiskeysockets/baileys';
+
+const execFileAsync = promisify(execFile);
+
+const WHISPER_BIN = process.env.WHISPER_BIN || 'whisper-cli';
+const WHISPER_MODEL =
+ process.env.WHISPER_MODEL ||
+ path.join(process.cwd(), 'data', 'models', 'ggml-base.bin');
+
+const FALLBACK_MESSAGE = '[Voice Message - transcription unavailable]';
+
+async function transcribeWithWhisperCpp(
+ audioBuffer: Buffer,
+): Promise {
+ const tmpDir = os.tmpdir();
+ const id = `nanoclaw-voice-${Date.now()}`;
+ const tmpOgg = path.join(tmpDir, `${id}.ogg`);
+ const tmpWav = path.join(tmpDir, `${id}.wav`);
+
+ try {
+ fs.writeFileSync(tmpOgg, audioBuffer);
+
+ // Convert ogg/opus to 16kHz mono WAV (required by whisper.cpp)
+ await execFileAsync('ffmpeg', [
+ '-i', tmpOgg,
+ '-ar', '16000',
+ '-ac', '1',
+ '-f', 'wav',
+ '-y', tmpWav,
+ ], { timeout: 30_000 });
+
+ const { stdout } = await execFileAsync(WHISPER_BIN, [
+ '-m', WHISPER_MODEL,
+ '-f', tmpWav,
+ '--no-timestamps',
+ '-nt',
+ ], { timeout: 60_000 });
+
+ const transcript = stdout.trim();
+ return transcript || null;
+ } catch (err) {
+ console.error('whisper.cpp transcription failed:', err);
+ return null;
+ } finally {
+ for (const f of [tmpOgg, tmpWav]) {
+ try { fs.unlinkSync(f); } catch { /* best effort cleanup */ }
+ }
+ }
+}
+
+export async function transcribeAudioMessage(
+ msg: WAMessage,
+ sock: WASocket,
+): Promise {
+ try {
+ const buffer = (await downloadMediaMessage(
+ msg,
+ 'buffer',
+ {},
+ {
+ logger: console as any,
+ reuploadRequest: sock.updateMediaMessage,
+ },
+ )) as Buffer;
+
+ if (!buffer || buffer.length === 0) {
+ console.error('Failed to download audio message');
+ return FALLBACK_MESSAGE;
+ }
+
+ console.log(`Downloaded audio message: ${buffer.length} bytes`);
+
+ const transcript = await transcribeWithWhisperCpp(buffer);
+
+ if (!transcript) {
+ return FALLBACK_MESSAGE;
+ }
+
+ console.log(`Transcribed voice message: ${transcript.length} chars`);
+ return transcript.trim();
+ } catch (err) {
+ console.error('Transcription error:', err);
+ return FALLBACK_MESSAGE;
+ }
+}
+
+export function isVoiceMessage(msg: WAMessage): boolean {
+ return msg.message?.audioMessage?.ptt === true;
+}
diff --git a/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md b/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md
new file mode 100644
index 0000000..47dabf1
--- /dev/null
+++ b/.claude/skills/use-local-whisper/modify/src/transcription.ts.intent.md
@@ -0,0 +1,39 @@
+# Intent: src/transcription.ts modifications
+
+## What changed
+Replaced the OpenAI Whisper API backend with local whisper.cpp CLI execution. Audio is converted from ogg/opus to 16kHz mono WAV via ffmpeg, then transcribed locally using whisper-cpp. No API key or network required.
+
+## Key sections
+
+### Imports
+- Removed: `readEnvFile` from `./env.js` (no API key needed)
+- Added: `execFile` from `child_process`, `fs`, `os`, `path`, `promisify` from `util`
+
+### Configuration
+- Removed: `TranscriptionConfig` interface and `DEFAULT_CONFIG` (no model/enabled/fallback config)
+- Added: `WHISPER_BIN` constant (env `WHISPER_BIN` or `'whisper-cli'`)
+- Added: `WHISPER_MODEL` constant (env `WHISPER_MODEL` or `data/models/ggml-base.bin`)
+- Added: `FALLBACK_MESSAGE` constant
+
+### transcribeWithWhisperCpp (replaces transcribeWithOpenAI)
+- Writes audio buffer to temp .ogg file
+- Converts to 16kHz mono WAV via ffmpeg
+- Runs whisper-cpp CLI with `--no-timestamps -nt` flags
+- Cleans up temp files in finally block
+- Returns trimmed stdout or null on error
+
+### transcribeAudioMessage
+- Same signature: `(msg: WAMessage, sock: WASocket) => Promise`
+- Same download logic via `downloadMediaMessage`
+- Calls `transcribeWithWhisperCpp` instead of `transcribeWithOpenAI`
+- Same fallback behavior on error/null
+
+### isVoiceMessage
+- Unchanged: `msg.message?.audioMessage?.ptt === true`
+
+## Invariants (must-keep)
+- `transcribeAudioMessage` export signature unchanged
+- `isVoiceMessage` export unchanged
+- Fallback message strings unchanged: `[Voice Message - transcription unavailable]`
+- downloadMediaMessage call pattern unchanged
+- Error logging pattern unchanged
diff --git a/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts b/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts
new file mode 100644
index 0000000..580d44f
--- /dev/null
+++ b/.claude/skills/use-local-whisper/tests/use-local-whisper.test.ts
@@ -0,0 +1,115 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('use-local-whisper skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: use-local-whisper');
+ expect(content).toContain('version: 1.0.0');
+ expect(content).toContain('src/transcription.ts');
+ expect(content).toContain('voice-transcription');
+ });
+
+ it('declares voice-transcription as a dependency', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'manifest.yaml'),
+ 'utf-8',
+ );
+ expect(content).toContain('depends:');
+ expect(content).toContain('voice-transcription');
+ });
+
+ it('has no structured operations (no new npm deps needed)', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'manifest.yaml'),
+ 'utf-8',
+ );
+ expect(content).toContain('structured: {}');
+ });
+
+ it('has the modified transcription file', () => {
+ const filePath = path.join(skillDir, 'modify', 'src', 'transcription.ts');
+ expect(fs.existsSync(filePath)).toBe(true);
+ });
+
+ it('has an intent file for the modified file', () => {
+ const intentPath = path.join(skillDir, 'modify', 'src', 'transcription.ts.intent.md');
+ expect(fs.existsSync(intentPath)).toBe(true);
+
+ const content = fs.readFileSync(intentPath, 'utf-8');
+ expect(content).toContain('whisper.cpp');
+ expect(content).toContain('transcribeAudioMessage');
+ expect(content).toContain('isVoiceMessage');
+ expect(content).toContain('Invariants');
+ });
+
+ it('uses whisper-cli (not OpenAI) for transcription', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'transcription.ts'),
+ 'utf-8',
+ );
+
+ // Uses local whisper.cpp CLI
+ expect(content).toContain('whisper-cli');
+ expect(content).toContain('execFileAsync');
+ expect(content).toContain('WHISPER_BIN');
+ expect(content).toContain('WHISPER_MODEL');
+ expect(content).toContain('ggml-base.bin');
+
+ // Does NOT use OpenAI
+ expect(content).not.toContain('openai');
+ expect(content).not.toContain('OpenAI');
+ expect(content).not.toContain('OPENAI_API_KEY');
+ expect(content).not.toContain('readEnvFile');
+ });
+
+ it('preserves the public API (transcribeAudioMessage and isVoiceMessage)', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'transcription.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('export async function transcribeAudioMessage(');
+ expect(content).toContain('msg: WAMessage');
+ expect(content).toContain('sock: WASocket');
+ expect(content).toContain('Promise');
+ expect(content).toContain('export function isVoiceMessage(');
+ expect(content).toContain('downloadMediaMessage');
+ });
+
+ it('preserves fallback message strings', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'transcription.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('[Voice Message - transcription unavailable]');
+ });
+
+ it('includes ffmpeg conversion step', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'transcription.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('ffmpeg');
+ expect(content).toContain("'-ar', '16000'");
+ expect(content).toContain("'-ac', '1'");
+ });
+
+ it('cleans up temp files in finally block', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'transcription.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('finally');
+ expect(content).toContain('unlinkSync');
+ });
+});
From f794185c21de12cc89bd8fda35e0e2eb1c120fda Mon Sep 17 00:00:00 2001
From: Gabi Simons
Date: Wed, 4 Mar 2026 16:23:29 +0200
Subject: [PATCH 022/246] fix: atomic claim prevents scheduled tasks from
executing twice (#657)
* fix: atomic claim prevents scheduled tasks from executing twice (#138)
Replace the two-phase getDueTasks() + deferred updateTaskAfterRun() with
an atomic SQLite transaction (claimDueTasks) that advances next_run
BEFORE dispatching tasks to the queue. This eliminates the race window
where subsequent scheduler polls re-discover in-progress tasks.
Key changes:
- claimDueTasks(): SELECT + UPDATE in a single db.transaction(), so no
poll can read stale next_run values. Once-tasks get next_run=NULL;
recurring tasks get next_run advanced to the future.
- computeNextRun(): anchors interval tasks to the scheduled time (not
Date.now()) to prevent cumulative drift. Includes a while-loop to
skip missed intervals and a guard against invalid interval values.
- updateTaskAfterRun(): simplified to only record last_run/last_result
since next_run is already handled by the claim.
Closes #138, #211, #300, #578
Co-authored-by: @taslim (PR #601)
Co-authored-by: @baijunjie (Issue #138)
Co-authored-by: @Michaelliv (Issue #300)
Co-authored-by: Claude Opus 4.6
* style: apply prettier formatting
Co-Authored-By: Claude Opus 4.6
* fix: track running task ID in GroupQueue to prevent duplicate execution (#138)
Previous commits implemented an "atomic claim" approach (claimDueTasks)
that advanced next_run before execution. Per Gavriel's review, this
solved the symptom at the wrong layer and introduced crash-recovery
risks for once-tasks.
This commit reverts claimDueTasks and instead fixes the actual bug:
GroupQueue.enqueueTask() only checked pendingTasks for duplicates, but
running tasks had already been shifted out. Adding runningTaskId to
GroupState closes that gap with a 3-line fix at the correct layer.
The computeNextRun() drift fix is retained, applied post-execution
where it belongs.
Closes #138, #211, #300, #578
Co-authored-by: @taslim (PR #601)
Co-Authored-By: Claude Opus 4.6
* docs: add changelog entry for scheduler duplicate fix
Co-Authored-By: Claude Opus 4.6
* docs: add contributors for scheduler race condition fix
Co-Authored-By: Taslim <9999802+taslim@users.noreply.github.com>
Co-Authored-By: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-Authored-By: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-Authored-By: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: gavrielc
Co-authored-by: Taslim <9999802+taslim@users.noreply.github.com>
Co-authored-by: BaiJunjie <7956480+baijunjie@users.noreply.github.com>
Co-authored-by: Michael <13676242+Michaelliv@users.noreply.github.com>
Co-authored-by: Kyle Zhike Chen <3477852+kk17@users.noreply.github.com>
---
CHANGELOG.md | 1 +
CONTRIBUTORS.md | 4 ++
src/group-queue.test.ts | 35 ++++++++++++++++++
src/group-queue.ts | 10 ++++-
src/task-scheduler.test.ts | 76 ++++++++++++++++++++++++++++++++++++++
src/task-scheduler.ts | 54 +++++++++++++++++++++------
6 files changed, 167 insertions(+), 13 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c1a0f85..bcb6496 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,3 +5,4 @@ All notable changes to NanoClaw will be documented in this file.
## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0)
[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved).
+- **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669)
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index 7b08414..1d4a5de 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -9,3 +9,7 @@ Thanks to everyone who has contributed to NanoClaw!
- [AmaxGuan](https://github.com/AmaxGuan) — Lingfeng Guan
- [happydog-intj](https://github.com/happydog-intj) — happy dog
- [bindoon](https://github.com/bindoon) — 潕量
+- [taslim](https://github.com/taslim) — Taslim
+- [baijunjie](https://github.com/baijunjie) — BaiJunjie
+- [Michaelliv](https://github.com/Michaelliv) — Michael
+- [kk17](https://github.com/kk17) — Kyle Zhike Chen
diff --git a/src/group-queue.test.ts b/src/group-queue.test.ts
index b1a4f9c..ca2702a 100644
--- a/src/group-queue.test.ts
+++ b/src/group-queue.test.ts
@@ -243,6 +243,41 @@ describe('GroupQueue', () => {
expect(processed).toContain('group3@g.us');
});
+ // --- Running task dedup (Issue #138) ---
+
+ it('rejects duplicate enqueue of a currently-running task', async () => {
+ let resolveTask: () => void;
+ let taskCallCount = 0;
+
+ const taskFn = vi.fn(async () => {
+ taskCallCount++;
+ await new Promise((resolve) => {
+ resolveTask = resolve;
+ });
+ });
+
+ // Start the task (runs immediately — slot available)
+ queue.enqueueTask('group1@g.us', 'task-1', taskFn);
+ await vi.advanceTimersByTimeAsync(10);
+ expect(taskCallCount).toBe(1);
+
+ // Scheduler poll re-discovers the same task while it's running —
+ // this must be silently dropped
+ const dupFn = vi.fn(async () => {});
+ queue.enqueueTask('group1@g.us', 'task-1', dupFn);
+ await vi.advanceTimersByTimeAsync(10);
+
+ // Duplicate was NOT queued
+ expect(dupFn).not.toHaveBeenCalled();
+
+ // Complete the original task
+ resolveTask!();
+ await vi.advanceTimersByTimeAsync(10);
+
+ // Only one execution total
+ expect(taskCallCount).toBe(1);
+ });
+
// --- Idle preemption ---
it('does NOT preempt active container when not idle', async () => {
diff --git a/src/group-queue.ts b/src/group-queue.ts
index 06a56cc..f2984ce 100644
--- a/src/group-queue.ts
+++ b/src/group-queue.ts
@@ -18,6 +18,7 @@ interface GroupState {
active: boolean;
idleWaiting: boolean;
isTaskContainer: boolean;
+ runningTaskId: string | null;
pendingMessages: boolean;
pendingTasks: QueuedTask[];
process: ChildProcess | null;
@@ -41,6 +42,7 @@ export class GroupQueue {
active: false,
idleWaiting: false,
isTaskContainer: false,
+ runningTaskId: null,
pendingMessages: false,
pendingTasks: [],
process: null,
@@ -90,7 +92,11 @@ export class GroupQueue {
const state = this.getGroup(groupJid);
- // Prevent double-queuing of the same task
+ // Prevent double-queuing: check both pending and currently-running task
+ if (state.runningTaskId === taskId) {
+ logger.debug({ groupJid, taskId }, 'Task already running, skipping');
+ return;
+ }
if (state.pendingTasks.some((t) => t.id === taskId)) {
logger.debug({ groupJid, taskId }, 'Task already queued, skipping');
return;
@@ -230,6 +236,7 @@ export class GroupQueue {
state.active = true;
state.idleWaiting = false;
state.isTaskContainer = true;
+ state.runningTaskId = task.id;
this.activeCount++;
logger.debug(
@@ -244,6 +251,7 @@ export class GroupQueue {
} finally {
state.active = false;
state.isTaskContainer = false;
+ state.runningTaskId = null;
state.process = null;
state.containerName = null;
state.groupFolder = null;
diff --git a/src/task-scheduler.test.ts b/src/task-scheduler.test.ts
index 62129e8..2032b51 100644
--- a/src/task-scheduler.test.ts
+++ b/src/task-scheduler.test.ts
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { _initTestDatabase, createTask, getTaskById } from './db.js';
import {
_resetSchedulerLoopForTests,
+ computeNextRun,
startSchedulerLoop,
} from './task-scheduler.js';
@@ -50,4 +51,79 @@ describe('task scheduler', () => {
const task = getTaskById('task-invalid-folder');
expect(task?.status).toBe('paused');
});
+
+ it('computeNextRun anchors interval tasks to scheduled time to prevent drift', () => {
+ const scheduledTime = new Date(Date.now() - 2000).toISOString(); // 2s ago
+ const task = {
+ id: 'drift-test',
+ group_folder: 'test',
+ chat_jid: 'test@g.us',
+ prompt: 'test',
+ schedule_type: 'interval' as const,
+ schedule_value: '60000', // 1 minute
+ context_mode: 'isolated' as const,
+ next_run: scheduledTime,
+ last_run: null,
+ last_result: null,
+ status: 'active' as const,
+ created_at: '2026-01-01T00:00:00.000Z',
+ };
+
+ const nextRun = computeNextRun(task);
+ expect(nextRun).not.toBeNull();
+
+ // Should be anchored to scheduledTime + 60s, NOT Date.now() + 60s
+ const expected = new Date(scheduledTime).getTime() + 60000;
+ expect(new Date(nextRun!).getTime()).toBe(expected);
+ });
+
+ it('computeNextRun returns null for once-tasks', () => {
+ const task = {
+ id: 'once-test',
+ group_folder: 'test',
+ chat_jid: 'test@g.us',
+ prompt: 'test',
+ schedule_type: 'once' as const,
+ schedule_value: '2026-01-01T00:00:00.000Z',
+ context_mode: 'isolated' as const,
+ next_run: new Date(Date.now() - 1000).toISOString(),
+ last_run: null,
+ last_result: null,
+ status: 'active' as const,
+ created_at: '2026-01-01T00:00:00.000Z',
+ };
+
+ expect(computeNextRun(task)).toBeNull();
+ });
+
+ it('computeNextRun skips missed intervals without infinite loop', () => {
+ // Task was due 10 intervals ago (missed)
+ const ms = 60000;
+ const missedBy = ms * 10;
+ const scheduledTime = new Date(Date.now() - missedBy).toISOString();
+
+ const task = {
+ id: 'skip-test',
+ group_folder: 'test',
+ chat_jid: 'test@g.us',
+ prompt: 'test',
+ schedule_type: 'interval' as const,
+ schedule_value: String(ms),
+ context_mode: 'isolated' as const,
+ next_run: scheduledTime,
+ last_run: null,
+ last_result: null,
+ status: 'active' as const,
+ created_at: '2026-01-01T00:00:00.000Z',
+ };
+
+ const nextRun = computeNextRun(task);
+ expect(nextRun).not.toBeNull();
+ // Must be in the future
+ expect(new Date(nextRun!).getTime()).toBeGreaterThan(Date.now());
+ // Must be aligned to the original schedule grid
+ const offset =
+ (new Date(nextRun!).getTime() - new Date(scheduledTime).getTime()) % ms;
+ expect(offset).toBe(0);
+ });
});
diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts
index e4f606f..8c533c7 100644
--- a/src/task-scheduler.ts
+++ b/src/task-scheduler.ts
@@ -21,6 +21,47 @@ import { resolveGroupFolderPath } from './group-folder.js';
import { logger } from './logger.js';
import { RegisteredGroup, ScheduledTask } from './types.js';
+/**
+ * Compute the next run time for a recurring task, anchored to the
+ * task's scheduled time rather than Date.now() to prevent cumulative
+ * drift on interval-based tasks.
+ *
+ * Co-authored-by: @community-pr-601
+ */
+export function computeNextRun(task: ScheduledTask): string | null {
+ if (task.schedule_type === 'once') return null;
+
+ const now = Date.now();
+
+ if (task.schedule_type === 'cron') {
+ const interval = CronExpressionParser.parse(task.schedule_value, {
+ tz: TIMEZONE,
+ });
+ return interval.next().toISOString();
+ }
+
+ if (task.schedule_type === 'interval') {
+ const ms = parseInt(task.schedule_value, 10);
+ if (!ms || ms <= 0) {
+ // Guard against malformed interval that would cause an infinite loop
+ logger.warn(
+ { taskId: task.id, value: task.schedule_value },
+ 'Invalid interval value',
+ );
+ return new Date(now + 60_000).toISOString();
+ }
+ // Anchor to the scheduled time, not now, to prevent drift.
+ // Skip past any missed intervals so we always land in the future.
+ let next = new Date(task.next_run!).getTime() + ms;
+ while (next <= now) {
+ next += ms;
+ }
+ return new Date(next).toISOString();
+ }
+
+ return null;
+}
+
export interface SchedulerDependencies {
registeredGroups: () => Record;
getSessions: () => Record;
@@ -187,18 +228,7 @@ async function runTask(
error,
});
- let nextRun: string | null = null;
- if (task.schedule_type === 'cron') {
- const interval = CronExpressionParser.parse(task.schedule_value, {
- tz: TIMEZONE,
- });
- nextRun = interval.next().toISOString();
- } else if (task.schedule_type === 'interval') {
- const ms = parseInt(task.schedule_value, 10);
- nextRun = new Date(Date.now() + ms).toISOString();
- }
- // 'once' tasks have no next run
-
+ const nextRun = computeNextRun(task);
const resultSummary = error
? `Error: ${error}`
: result
From 32d5827fe4b1e8903e42871ee829d24117cdfcf9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 14:23:42 +0000
Subject: [PATCH 023/246] chore: bump version to 1.2.2
---
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 b153e3c..d7de188 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.1",
+ "version": "1.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.1",
+ "version": "1.2.2",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 4083955..1fdbf19 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.1",
+ "version": "1.2.2",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From a5845a5cf47a475e64180e04d351b25b43a9f199 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 14:23:46 +0000
Subject: [PATCH 024/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?5.3k=20tokens=20=C2=B7=2018%=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 949dc78..fb4ce0d 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 35.1k tokens, 18% of context window
+
+ 35.3k tokens, 18% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 35.1k
- 35.1k
+ 35.3k
+ 35.3k
From 4de981b9b91fa217a313f96f4d17dbcb6e19416d Mon Sep 17 00:00:00 2001
From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com>
Date: Wed, 4 Mar 2026 08:05:45 -0800
Subject: [PATCH 025/246] add sender allowlist for per-chat access control
(#705)
* feat: add sender allowlist for per-chat access control
* style: fix prettier formatting
---
groups/main/CLAUDE.md | 31 +++++
src/config.ts | 6 +
src/db.ts | 4 +-
src/index.ts | 45 +++++++-
src/sender-allowlist.test.ts | 216 +++++++++++++++++++++++++++++++++++
src/sender-allowlist.ts | 128 +++++++++++++++++++++
6 files changed, 423 insertions(+), 7 deletions(-)
create mode 100644 src/sender-allowlist.test.ts
create mode 100644 src/sender-allowlist.ts
diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md
index e7aa25a..11e846b 100644
--- a/groups/main/CLAUDE.md
+++ b/groups/main/CLAUDE.md
@@ -188,6 +188,37 @@ Groups can have extra directories mounted. Add `containerConfig` to their entry:
The directory will appear at `/workspace/extra/webapp` in that group's container.
+#### Sender Allowlist
+
+After registering a group, explain the sender allowlist feature to the user:
+
+> This group can be configured with a sender allowlist to control who can interact with me. There are two modes:
+>
+> - **Trigger mode** (default): Everyone's messages are stored for context, but only allowed senders can trigger me with @{AssistantName}.
+> - **Drop mode**: Messages from non-allowed senders are not stored at all.
+>
+> For closed groups with trusted members, I recommend setting up an allow-only list so only specific people can trigger me. Want me to configure that?
+
+If the user wants to set up an allowlist, edit `~/.config/nanoclaw/sender-allowlist.json` on the host:
+
+```json
+{
+ "default": { "allow": "*", "mode": "trigger" },
+ "chats": {
+ "": {
+ "allow": ["sender-id-1", "sender-id-2"],
+ "mode": "trigger"
+ }
+ },
+ "logDenied": true
+}
+```
+
+Notes:
+- Your own messages (`is_from_me`) explicitly bypass the allowlist in trigger checks. Bot messages are filtered out by the database query before trigger evaluation, so they never reach the allowlist.
+- If the config file doesn't exist or is invalid, all senders are allowed (fail-open)
+- The config file is on the host at `~/.config/nanoclaw/sender-allowlist.json`, not inside the container
+
### Removing a Group
1. Read `/workspace/project/data/registered_groups.json`
diff --git a/src/config.ts b/src/config.ts
index d57205b..c438b70 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -27,6 +27,12 @@ export const MOUNT_ALLOWLIST_PATH = path.join(
'nanoclaw',
'mount-allowlist.json',
);
+export const SENDER_ALLOWLIST_PATH = path.join(
+ HOME_DIR,
+ '.config',
+ 'nanoclaw',
+ 'sender-allowlist.json',
+);
export const STORE_DIR = path.resolve(PROJECT_ROOT, 'store');
export const GROUPS_DIR = path.resolve(PROJECT_ROOT, 'groups');
export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data');
diff --git a/src/db.ts b/src/db.ts
index 09d786f..be1f605 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -313,7 +313,7 @@ export function getNewMessages(
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
const sql = `
- SELECT id, chat_jid, sender, sender_name, content, timestamp
+ SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
FROM messages
WHERE timestamp > ? AND chat_jid IN (${placeholders})
AND is_bot_message = 0 AND content NOT LIKE ?
@@ -341,7 +341,7 @@ export function getMessagesSince(
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
const sql = `
- SELECT id, chat_jid, sender, sender_name, content, timestamp
+ SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
FROM messages
WHERE chat_jid = ? AND timestamp > ?
AND is_bot_message = 0 AND content NOT LIKE ?
diff --git a/src/index.ts b/src/index.ts
index 234be79..8abf660 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -41,6 +41,12 @@ 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 {
+ isSenderAllowed,
+ isTriggerAllowed,
+ loadSenderAllowlist,
+ shouldDropMessage,
+} from './sender-allowlist.js';
import { startSchedulerLoop } from './task-scheduler.js';
import { Channel, NewMessage, RegisteredGroup } from './types.js';
import { logger } from './logger.js';
@@ -155,8 +161,11 @@ async function processGroupMessages(chatJid: string): Promise {
// For non-main groups, check if trigger is required and present
if (!isMainGroup && group.requiresTrigger !== false) {
- const hasTrigger = missedMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
+ const allowlistCfg = loadSenderAllowlist();
+ const hasTrigger = missedMessages.some(
+ (m) =>
+ TRIGGER_PATTERN.test(m.content.trim()) &&
+ (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) return true;
}
@@ -380,8 +389,12 @@ async function startMessageLoop(): Promise {
// Non-trigger messages accumulate in DB and get pulled as
// context when a trigger eventually arrives.
if (needsTrigger) {
- const hasTrigger = groupMessages.some((m) =>
- TRIGGER_PATTERN.test(m.content.trim()),
+ const allowlistCfg = loadSenderAllowlist();
+ const hasTrigger = groupMessages.some(
+ (m) =>
+ TRIGGER_PATTERN.test(m.content.trim()) &&
+ (m.is_from_me ||
+ isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
);
if (!hasTrigger) continue;
}
@@ -465,7 +478,29 @@ async function main(): Promise {
// Channel callbacks (shared by all channels)
const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => storeMessage(msg),
+ onMessage: (_chatJid: string, msg: NewMessage) => {
+ // 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();
+ if (
+ shouldDropMessage(_chatJid, cfg) &&
+ !isSenderAllowed(_chatJid, msg.sender, cfg)
+ ) {
+ if (cfg.logDenied) {
+ logger.debug(
+ { chatJid: _chatJid, sender: msg.sender },
+ 'sender-allowlist: dropping message (drop mode)',
+ );
+ }
+ return;
+ }
+ }
+ storeMessage(msg);
+ },
onChatMetadata: (
chatJid: string,
timestamp: string,
diff --git a/src/sender-allowlist.test.ts b/src/sender-allowlist.test.ts
new file mode 100644
index 0000000..9e2513f
--- /dev/null
+++ b/src/sender-allowlist.test.ts
@@ -0,0 +1,216 @@
+import fs from 'fs';
+import os from 'os';
+import path from 'path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import {
+ isSenderAllowed,
+ isTriggerAllowed,
+ loadSenderAllowlist,
+ SenderAllowlistConfig,
+ shouldDropMessage,
+} from './sender-allowlist.js';
+
+let tmpDir: string;
+
+function cfgPath(name = 'sender-allowlist.json'): string {
+ return path.join(tmpDir, name);
+}
+
+function writeConfig(config: unknown, name?: string): string {
+ const p = cfgPath(name);
+ fs.writeFileSync(p, JSON.stringify(config));
+ return p;
+}
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'allowlist-test-'));
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+describe('loadSenderAllowlist', () => {
+ it('returns allow-all defaults when file is missing', () => {
+ const cfg = loadSenderAllowlist(cfgPath());
+ expect(cfg.default.allow).toBe('*');
+ expect(cfg.default.mode).toBe('trigger');
+ expect(cfg.logDenied).toBe(true);
+ });
+
+ it('loads allow=* config', () => {
+ const p = writeConfig({
+ default: { allow: '*', mode: 'trigger' },
+ chats: {},
+ logDenied: false,
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toBe('*');
+ expect(cfg.logDenied).toBe(false);
+ });
+
+ it('loads allow=[] (deny all)', () => {
+ const p = writeConfig({
+ default: { allow: [], mode: 'trigger' },
+ chats: {},
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toEqual([]);
+ });
+
+ it('loads allow=[list]', () => {
+ const p = writeConfig({
+ default: { allow: ['alice', 'bob'], mode: 'drop' },
+ chats: {},
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toEqual(['alice', 'bob']);
+ expect(cfg.default.mode).toBe('drop');
+ });
+
+ it('per-chat override beats default', () => {
+ const p = writeConfig({
+ default: { allow: '*', mode: 'trigger' },
+ chats: { 'group-a': { allow: ['alice'], mode: 'drop' } },
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.chats['group-a'].allow).toEqual(['alice']);
+ expect(cfg.chats['group-a'].mode).toBe('drop');
+ });
+
+ it('returns allow-all on invalid JSON', () => {
+ const p = cfgPath();
+ fs.writeFileSync(p, '{ not valid json }}}');
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toBe('*');
+ });
+
+ it('returns allow-all on invalid schema', () => {
+ const p = writeConfig({ default: { oops: true } });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toBe('*');
+ });
+
+ it('rejects non-string allow array items', () => {
+ const p = writeConfig({
+ default: { allow: [123, null, true], mode: 'trigger' },
+ chats: {},
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.default.allow).toBe('*'); // falls back to default
+ });
+
+ it('skips invalid per-chat entries', () => {
+ const p = writeConfig({
+ default: { allow: '*', mode: 'trigger' },
+ chats: {
+ good: { allow: ['alice'], mode: 'trigger' },
+ bad: { allow: 123 },
+ },
+ });
+ const cfg = loadSenderAllowlist(p);
+ expect(cfg.chats['good']).toBeDefined();
+ expect(cfg.chats['bad']).toBeUndefined();
+ });
+});
+
+describe('isSenderAllowed', () => {
+ it('allow=* allows any sender', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+ };
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(true);
+ });
+
+ it('allow=[] denies any sender', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: [], mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+ };
+ expect(isSenderAllowed('g1', 'anyone', cfg)).toBe(false);
+ });
+
+ it('allow=[list] allows exact match only', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: ['alice', 'bob'], mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+ };
+ expect(isSenderAllowed('g1', 'alice', cfg)).toBe(true);
+ expect(isSenderAllowed('g1', 'eve', cfg)).toBe(false);
+ });
+
+ it('uses per-chat entry over default', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'trigger' },
+ chats: { g1: { allow: ['alice'], mode: 'trigger' } },
+ logDenied: true,
+ };
+ expect(isSenderAllowed('g1', 'bob', cfg)).toBe(false);
+ expect(isSenderAllowed('g2', 'bob', cfg)).toBe(true);
+ });
+});
+
+describe('shouldDropMessage', () => {
+ it('returns false for trigger mode', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+ };
+ expect(shouldDropMessage('g1', cfg)).toBe(false);
+ });
+
+ it('returns true for drop mode', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'drop' },
+ chats: {},
+ logDenied: true,
+ };
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
+ });
+
+ it('per-chat mode override', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'trigger' },
+ chats: { g1: { allow: '*', mode: 'drop' } },
+ logDenied: true,
+ };
+ expect(shouldDropMessage('g1', cfg)).toBe(true);
+ expect(shouldDropMessage('g2', cfg)).toBe(false);
+ });
+});
+
+describe('isTriggerAllowed', () => {
+ it('allows trigger for allowed sender', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: ['alice'], mode: 'trigger' },
+ chats: {},
+ logDenied: false,
+ };
+ expect(isTriggerAllowed('g1', 'alice', cfg)).toBe(true);
+ });
+
+ it('denies trigger for disallowed sender', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: ['alice'], mode: 'trigger' },
+ chats: {},
+ logDenied: false,
+ };
+ expect(isTriggerAllowed('g1', 'eve', cfg)).toBe(false);
+ });
+
+ it('logs when logDenied is true', () => {
+ const cfg: SenderAllowlistConfig = {
+ default: { allow: ['alice'], mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+ };
+ isTriggerAllowed('g1', 'eve', cfg);
+ // Logger.debug is called — we just verify no crash; logger is a real pino instance
+ });
+});
diff --git a/src/sender-allowlist.ts b/src/sender-allowlist.ts
new file mode 100644
index 0000000..9cc2bde
--- /dev/null
+++ b/src/sender-allowlist.ts
@@ -0,0 +1,128 @@
+import fs from 'fs';
+
+import { SENDER_ALLOWLIST_PATH } from './config.js';
+import { logger } from './logger.js';
+
+export interface ChatAllowlistEntry {
+ allow: '*' | string[];
+ mode: 'trigger' | 'drop';
+}
+
+export interface SenderAllowlistConfig {
+ default: ChatAllowlistEntry;
+ chats: Record;
+ logDenied: boolean;
+}
+
+const DEFAULT_CONFIG: SenderAllowlistConfig = {
+ default: { allow: '*', mode: 'trigger' },
+ chats: {},
+ logDenied: true,
+};
+
+function isValidEntry(entry: unknown): entry is ChatAllowlistEntry {
+ if (!entry || typeof entry !== 'object') return false;
+ const e = entry as Record;
+ const validAllow =
+ e.allow === '*' ||
+ (Array.isArray(e.allow) && e.allow.every((v) => typeof v === 'string'));
+ const validMode = e.mode === 'trigger' || e.mode === 'drop';
+ return validAllow && validMode;
+}
+
+export function loadSenderAllowlist(
+ pathOverride?: string,
+): SenderAllowlistConfig {
+ const filePath = pathOverride ?? SENDER_ALLOWLIST_PATH;
+
+ let raw: string;
+ try {
+ raw = fs.readFileSync(filePath, 'utf-8');
+ } catch (err: unknown) {
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return DEFAULT_CONFIG;
+ logger.warn(
+ { err, path: filePath },
+ 'sender-allowlist: cannot read config',
+ );
+ return DEFAULT_CONFIG;
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ logger.warn({ path: filePath }, 'sender-allowlist: invalid JSON');
+ return DEFAULT_CONFIG;
+ }
+
+ const obj = parsed as Record;
+
+ if (!isValidEntry(obj.default)) {
+ logger.warn(
+ { path: filePath },
+ 'sender-allowlist: invalid or missing default entry',
+ );
+ return DEFAULT_CONFIG;
+ }
+
+ const chats: Record = {};
+ if (obj.chats && typeof obj.chats === 'object') {
+ for (const [jid, entry] of Object.entries(
+ obj.chats as Record,
+ )) {
+ if (isValidEntry(entry)) {
+ chats[jid] = entry;
+ } else {
+ logger.warn(
+ { jid, path: filePath },
+ 'sender-allowlist: skipping invalid chat entry',
+ );
+ }
+ }
+ }
+
+ return {
+ default: obj.default as ChatAllowlistEntry,
+ chats,
+ logDenied: obj.logDenied !== false,
+ };
+}
+
+function getEntry(
+ chatJid: string,
+ cfg: SenderAllowlistConfig,
+): ChatAllowlistEntry {
+ return cfg.chats[chatJid] ?? cfg.default;
+}
+
+export function isSenderAllowed(
+ chatJid: string,
+ sender: string,
+ cfg: SenderAllowlistConfig,
+): boolean {
+ const entry = getEntry(chatJid, cfg);
+ if (entry.allow === '*') return true;
+ return entry.allow.includes(sender);
+}
+
+export function shouldDropMessage(
+ chatJid: string,
+ cfg: SenderAllowlistConfig,
+): boolean {
+ return getEntry(chatJid, cfg).mode === 'drop';
+}
+
+export function isTriggerAllowed(
+ chatJid: string,
+ sender: string,
+ cfg: SenderAllowlistConfig,
+): boolean {
+ const allowed = isSenderAllowed(chatJid, sender, cfg);
+ if (!allowed && cfg.logDenied) {
+ logger.debug(
+ { chatJid, sender },
+ 'sender-allowlist: trigger denied for sender',
+ );
+ }
+ return allowed;
+}
From cd3c2f06d4575e365972bbb1b84fb2e33bf995fc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 16:05:59 +0000
Subject: [PATCH 026/246] chore: bump version to 1.2.3
---
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 d7de188..692b81c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.2",
+ "version": "1.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.2",
+ "version": "1.2.3",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 1fdbf19..85ea37f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.2",
+ "version": "1.2.3",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 3d17e95c1ba17de32475130cfc04e8fd4fcfb23a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 16:06:04 +0000
Subject: [PATCH 027/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?6.4k=20tokens=20=C2=B7=2018%=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 fb4ce0d..7ace3d0 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 35.3k tokens, 18% of context window
+
+ 36.4k tokens, 18% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 35.3k
- 35.3k
+ 36.4k
+ 36.4k
From 1436186c75dd9cd6c85d60ad359e520ee06c630d Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Wed, 4 Mar 2026 18:07:13 +0200
Subject: [PATCH 028/246] fix: rename _chatJid to chatJid in onMessage callback
The underscore prefix convention signals an unused parameter, but it's
now actively used by the sender allowlist logic.
Co-Authored-By: Claude Opus 4.6
---
src/index.ts | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 8abf660..50d790d 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -478,21 +478,21 @@ async function main(): Promise {
// Channel callbacks (shared by all channels)
const channelOpts = {
- onMessage: (_chatJid: string, msg: NewMessage) => {
+ onMessage: (chatJid: string, msg: NewMessage) => {
// Sender allowlist drop mode: discard messages from denied senders before storing
if (
!msg.is_from_me &&
!msg.is_bot_message &&
- registeredGroups[_chatJid]
+ registeredGroups[chatJid]
) {
const cfg = loadSenderAllowlist();
if (
- shouldDropMessage(_chatJid, cfg) &&
- !isSenderAllowed(_chatJid, msg.sender, cfg)
+ shouldDropMessage(chatJid, cfg) &&
+ !isSenderAllowed(chatJid, msg.sender, cfg)
) {
if (cfg.logDenied) {
logger.debug(
- { chatJid: _chatJid, sender: msg.sender },
+ { chatJid, sender: msg.sender },
'sender-allowlist: dropping message (drop mode)',
);
}
From 5aed615ac534e212120eab549c177edd647bfce6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 16:07:35 +0000
Subject: [PATCH 029/246] chore: bump version to 1.2.4
---
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 692b81c..0d96409 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.3",
+ "version": "1.2.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.3",
+ "version": "1.2.4",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 85ea37f..7ff7491 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From df2bac61f0b3b468ea68e9c279461eea942bbdf9 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Wed, 4 Mar 2026 19:51:40 +0100
Subject: [PATCH 030/246] fix: format src/index.ts to pass CI prettier check
(#711)
Closes #710
Co-authored-by: Claude Opus 4.6
---
src/index.ts | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/src/index.ts b/src/index.ts
index 50d790d..85aba50 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -480,11 +480,7 @@ async function main(): Promise {
const channelOpts = {
onMessage: (chatJid: string, msg: NewMessage) => {
// Sender allowlist drop mode: discard messages from denied senders before storing
- if (
- !msg.is_from_me &&
- !msg.is_bot_message &&
- registeredGroups[chatJid]
- ) {
+ if (!msg.is_from_me && !msg.is_bot_message && registeredGroups[chatJid]) {
const cfg = loadSenderAllowlist();
if (
shouldDropMessage(chatJid, cfg) &&
From 590dc2193c94aa645c406724424db86200258e4e Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 18:51:57 +0000
Subject: [PATCH 031/246] chore: bump version to 1.2.5
---
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 0d96409..5c9635e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.4",
+ "version": "1.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.4",
+ "version": "1.2.5",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 7ff7491..74ee784 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.4",
+ "version": "1.2.5",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 5955cd6ee59014b87e8324272295f726b485d174 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Wed, 4 Mar 2026 22:02:11 +0200
Subject: [PATCH 032/246] chore: update claude-agent-sdk to 0.2.68
Co-Authored-By: Claude Opus 4.6
---
container/agent-runner/package-lock.json | 146 +++++++++++++----------
1 file changed, 83 insertions(+), 63 deletions(-)
diff --git a/container/agent-runner/package-lock.json b/container/agent-runner/package-lock.json
index cbd51ca..89cee2c 100644
--- a/container/agent-runner/package-lock.json
+++ b/container/agent-runner/package-lock.json
@@ -19,22 +19,23 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
- "version": "0.2.34",
- "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.34.tgz",
- "integrity": "sha512-QLHd3Nt7bGU7/YH71fXFaztM9fNxGGruzTMrTYJkbm5gYJl5ZyU2zGyoE5VpWC0e1QU0yYdNdBVgqSYDcJGufg==",
+ "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==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
- "@img/sharp-darwin-arm64": "^0.33.5",
- "@img/sharp-darwin-x64": "^0.33.5",
- "@img/sharp-linux-arm": "^0.33.5",
- "@img/sharp-linux-arm64": "^0.33.5",
- "@img/sharp-linux-x64": "^0.33.5",
- "@img/sharp-linuxmusl-arm64": "^0.33.5",
- "@img/sharp-linuxmusl-x64": "^0.33.5",
- "@img/sharp-win32-x64": "^0.33.5"
+ "@img/sharp-darwin-arm64": "^0.34.2",
+ "@img/sharp-darwin-x64": "^0.34.2",
+ "@img/sharp-linux-arm": "^0.34.2",
+ "@img/sharp-linux-arm64": "^0.34.2",
+ "@img/sharp-linux-x64": "^0.34.2",
+ "@img/sharp-linuxmusl-arm64": "^0.34.2",
+ "@img/sharp-linuxmusl-x64": "^0.34.2",
+ "@img/sharp-win32-arm64": "^0.34.2",
+ "@img/sharp-win32-x64": "^0.34.2"
},
"peerDependencies": {
"zod": "^4.0.0"
@@ -53,9 +54,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
- "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
@@ -71,13 +72,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-arm64": "1.0.4"
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
- "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
@@ -93,13 +94,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-darwin-x64": "1.0.4"
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
- "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
@@ -113,9 +114,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
- "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
@@ -129,9 +130,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
- "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
@@ -145,9 +146,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
- "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
@@ -161,9 +162,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
- "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
@@ -177,9 +178,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
- "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
@@ -193,9 +194,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
- "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
@@ -209,9 +210,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
- "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
@@ -227,13 +228,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm": "1.0.5"
+ "@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
- "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
@@ -249,13 +250,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-arm64": "1.0.4"
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
- "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
@@ -271,13 +272,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linux-x64": "1.0.4"
+ "@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
- "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
@@ -293,13 +294,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
- "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
@@ -315,13 +316,32 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
- "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
- "version": "0.33.5",
- "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
- "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
From 6ad6328885aec739f495c74a3efb047894bff11b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 4 Mar 2026 20:02:30 +0000
Subject: [PATCH 033/246] chore: bump version to 1.2.6
---
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 5c9635e..05f8f66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.5",
+ "version": "1.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.5",
+ "version": "1.2.6",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 74ee784..6569299 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.5",
+ "version": "1.2.6",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 298c3eade4a8497264844aa29e71bee7dadf3a89 Mon Sep 17 00:00:00 2001
From: daniviber
Date: Wed, 4 Mar 2026 22:48:23 +0100
Subject: [PATCH 034/246] feat: add /add-ollama skill for local model inference
(#712)
* feat: add /add-ollama skill for local model inference
Adds a skill that integrates Ollama as an MCP server, allowing the
container agent to offload tasks to local models (summarization,
translation, general queries) while keeping Claude as orchestrator.
Skill contents:
- ollama-mcp-stdio.ts: stdio MCP server with ollama_list_models and
ollama_generate tools
- ollama-watch.sh: macOS notification watcher for Ollama activity
- Modifications to index.ts (MCP config) and container-runner.ts
(log surfacing)
Co-Authored-By: Claude Opus 4.6
* chore: rename skill from /add-ollama to /add-ollama-tool
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
Co-authored-by: gavrielc
---
.claude/skills/add-ollama-tool/SKILL.md | 152 ++++
.../agent-runner/src/ollama-mcp-stdio.ts | 147 ++++
.../add/scripts/ollama-watch.sh | 41 +
.claude/skills/add-ollama-tool/manifest.yaml | 17 +
.../container/agent-runner/src/index.ts | 593 +++++++++++++++
.../agent-runner/src/index.ts.intent.md | 23 +
.../modify/src/container-runner.ts | 708 ++++++++++++++++++
.../modify/src/container-runner.ts.intent.md | 18 +
8 files changed, 1699 insertions(+)
create mode 100644 .claude/skills/add-ollama-tool/SKILL.md
create mode 100644 .claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts
create mode 100755 .claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh
create mode 100644 .claude/skills/add-ollama-tool/manifest.yaml
create mode 100644 .claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts
create mode 100644 .claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md
create mode 100644 .claude/skills/add-ollama-tool/modify/src/container-runner.ts
create mode 100644 .claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md
diff --git a/.claude/skills/add-ollama-tool/SKILL.md b/.claude/skills/add-ollama-tool/SKILL.md
new file mode 100644
index 0000000..2205a58
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/SKILL.md
@@ -0,0 +1,152 @@
+---
+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.
+---
+
+# 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.
+
+Tools added:
+- `ollama_list_models` — lists installed Ollama models
+- `ollama_generate` — sends a prompt to a specified model and returns the response
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `ollama` is in `applied_skills`, skip to Phase 3 (Configure). The code changes are already in place.
+
+### Check prerequisites
+
+Verify Ollama is installed and running on the host:
+
+```bash
+ollama list
+```
+
+If Ollama is not installed, direct the user to https://ollama.com/download.
+
+If no models are installed, suggest pulling one:
+
+> You need at least one model. I recommend:
+>
+> ```bash
+> ollama pull gemma3:1b # Small, fast (1GB)
+> ollama pull llama3.2 # Good general purpose (2GB)
+> ollama pull qwen3-coder:30b # Best for code tasks (18GB)
+> ```
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package.
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/add-ollama-tool
+```
+
+This deterministically:
+- Adds `container/agent-runner/src/ollama-mcp-stdio.ts` (Ollama MCP server)
+- Adds `scripts/ollama-watch.sh` (macOS notification watcher)
+- Three-way merges Ollama MCP config into `container/agent-runner/src/index.ts` (allowedTools + mcpServers)
+- Three-way merges `[OLLAMA]` log surfacing into `src/container-runner.ts`
+- Records the application in `.nanoclaw/state.yaml`
+
+If the apply reports merge conflicts, read the intent files:
+- `modify/container/agent-runner/src/index.ts.intent.md` — what changed and invariants
+- `modify/src/container-runner.ts.intent.md` — what changed and invariants
+
+### Copy to per-group agent-runner
+
+Existing groups have a cached copy of the agent-runner source. Copy the new files:
+
+```bash
+for dir in data/sessions/*/agent-runner-src; do
+ cp container/agent-runner/src/ollama-mcp-stdio.ts "$dir/"
+ cp container/agent-runner/src/index.ts "$dir/"
+done
+```
+
+### Validate code changes
+
+```bash
+npm run build
+./container/build.sh
+```
+
+Build must be clean before proceeding.
+
+## Phase 3: Configure
+
+### 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`:
+
+```bash
+OLLAMA_HOST=http://your-ollama-host:11434
+```
+
+### Restart the service
+
+```bash
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+## Phase 4: Verify
+
+### Test via WhatsApp
+
+Tell the user:
+
+> Send a message like: "use ollama to tell me the capital of France"
+>
+> The agent should use `ollama_list_models` to find available models, then `ollama_generate` to get a response.
+
+### Monitor activity (optional)
+
+Run the watcher script for macOS notifications when Ollama is used:
+
+```bash
+./scripts/ollama-watch.sh
+```
+
+### Check logs if needed
+
+```bash
+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] <<< Done` — generation completed
+
+## Troubleshooting
+
+### Agent says "Ollama is not installed"
+
+The agent is trying to run `ollama` CLI inside the container instead of using the MCP tools. This means:
+1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `ollama` entry in `mcpServers`
+2. The per-group source wasn't updated — re-copy files (see Phase 2)
+3. The container wasn't rebuilt — run `./container/build.sh`
+
+### "Failed to connect to Ollama"
+
+1. Verify Ollama is running: `ollama list`
+2. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:11434/api/tags`
+3. If using a custom host, check `OLLAMA_HOST` in `.env`
+
+### 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: ..."
diff --git a/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts b/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts
new file mode 100644
index 0000000..7d29bb2
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/add/container/agent-runner/src/ollama-mcp-stdio.ts
@@ -0,0 +1,147 @@
+/**
+ * Ollama MCP Server for NanoClaw
+ * Exposes local Ollama models as tools for the container agent.
+ * Uses host.docker.internal to reach the host's Ollama instance from Docker.
+ */
+
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
+import { z } from 'zod';
+
+import fs from 'fs';
+import path from 'path';
+
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://host.docker.internal:11434';
+const OLLAMA_STATUS_FILE = '/workspace/ipc/ollama_status.json';
+
+function log(msg: string): void {
+ console.error(`[OLLAMA] ${msg}`);
+}
+
+function writeStatus(status: string, detail?: string): void {
+ try {
+ const data = { status, detail, timestamp: new Date().toISOString() };
+ const tmpPath = `${OLLAMA_STATUS_FILE}.tmp`;
+ fs.mkdirSync(path.dirname(OLLAMA_STATUS_FILE), { recursive: true });
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
+ fs.renameSync(tmpPath, OLLAMA_STATUS_FILE);
+ } catch { /* best-effort */ }
+}
+
+async function ollamaFetch(path: string, options?: RequestInit): Promise {
+ const url = `${OLLAMA_HOST}${path}`;
+ try {
+ return await fetch(url, options);
+ } catch (err) {
+ // Fallback to localhost if host.docker.internal fails
+ if (OLLAMA_HOST.includes('host.docker.internal')) {
+ const fallbackUrl = url.replace('host.docker.internal', 'localhost');
+ return await fetch(fallbackUrl, options);
+ }
+ throw err;
+ }
+}
+
+const server = new McpServer({
+ name: 'ollama',
+ version: '1.0.0',
+});
+
+server.tool(
+ 'ollama_list_models',
+ 'List all locally installed Ollama models. Use this to see which models are available before calling ollama_generate.',
+ {},
+ async () => {
+ log('Listing models...');
+ writeStatus('listing', 'Listing available models');
+ try {
+ const res = await ollamaFetch('/api/tags');
+ if (!res.ok) {
+ return {
+ content: [{ type: 'text' as const, text: `Ollama API error: ${res.status} ${res.statusText}` }],
+ isError: true,
+ };
+ }
+
+ const data = await res.json() as { models?: Array<{ name: string; size: number; modified_at: string }> };
+ const models = data.models || [];
+
+ if (models.length === 0) {
+ return { content: [{ type: 'text' as const, text: 'No models installed. Run `ollama pull ` on the host to install one.' }] };
+ }
+
+ const list = models
+ .map(m => `- ${m.name} (${(m.size / 1e9).toFixed(1)}GB)`)
+ .join('\n');
+
+ log(`Found ${models.length} models`);
+ return { content: [{ type: 'text' as const, text: `Installed models:\n${list}` }] };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Failed to connect to Ollama at ${OLLAMA_HOST}: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+);
+
+server.tool(
+ 'ollama_generate',
+ 'Send a prompt to a local Ollama model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use ollama_list_models first to see available models.',
+ {
+ model: z.string().describe('The model name (e.g., "llama3.2", "mistral", "gemma2")'),
+ prompt: z.string().describe('The prompt to send to the model'),
+ system: z.string().optional().describe('Optional system prompt to set model behavior'),
+ },
+ async (args) => {
+ log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`);
+ writeStatus('generating', `Generating with ${args.model}`);
+ try {
+ const body: Record = {
+ model: args.model,
+ prompt: args.prompt,
+ stream: false,
+ };
+ if (args.system) {
+ body.system = args.system;
+ }
+
+ const res = await ollamaFetch('/api/generate', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ return {
+ content: [{ type: 'text' as const, text: `Ollama error (${res.status}): ${errorText}` }],
+ isError: true,
+ };
+ }
+
+ const data = await res.json() as { response: string; total_duration?: number; eval_count?: number };
+
+ let meta = '';
+ if (data.total_duration) {
+ const secs = (data.total_duration / 1e9).toFixed(1);
+ meta = `\n\n[${args.model} | ${secs}s${data.eval_count ? ` | ${data.eval_count} tokens` : ''}]`;
+ log(`<<< Done: ${args.model} | ${secs}s | ${data.eval_count || '?'} tokens | ${data.response.length} chars`);
+ writeStatus('done', `${args.model} | ${secs}s | ${data.eval_count || '?'} tokens`);
+ } else {
+ log(`<<< Done: ${args.model} | ${data.response.length} chars`);
+ writeStatus('done', `${args.model} | ${data.response.length} chars`);
+ }
+
+ return { content: [{ type: 'text' as const, text: data.response + meta }] };
+ } catch (err) {
+ return {
+ content: [{ type: 'text' as const, text: `Failed to call Ollama: ${err instanceof Error ? err.message : String(err)}` }],
+ isError: true,
+ };
+ }
+ },
+);
+
+const transport = new StdioServerTransport();
+await server.connect(transport);
diff --git a/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh b/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh
new file mode 100755
index 0000000..1aa4a93
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/add/scripts/ollama-watch.sh
@@ -0,0 +1,41 @@
+#!/bin/bash
+# Watch NanoClaw IPC for Ollama activity and show macOS notifications
+# Usage: ./scripts/ollama-watch.sh
+
+cd "$(dirname "$0")/.." || exit 1
+
+echo "Watching for Ollama activity..."
+echo "Press Ctrl+C to stop"
+echo ""
+
+LAST_TIMESTAMP=""
+
+while true; do
+ # Check all group IPC dirs for ollama_status.json
+ for status_file in data/ipc/*/ollama_status.json; do
+ [ -f "$status_file" ] || continue
+
+ TIMESTAMP=$(python3 -c "import json; print(json.load(open('$status_file'))['timestamp'])" 2>/dev/null)
+ [ -z "$TIMESTAMP" ] && continue
+ [ "$TIMESTAMP" = "$LAST_TIMESTAMP" ] && continue
+
+ LAST_TIMESTAMP="$TIMESTAMP"
+ STATUS=$(python3 -c "import json; d=json.load(open('$status_file')); print(d['status'])" 2>/dev/null)
+ DETAIL=$(python3 -c "import json; d=json.load(open('$status_file')); print(d.get('detail',''))" 2>/dev/null)
+
+ case "$STATUS" in
+ generating)
+ osascript -e "display notification \"$DETAIL\" with title \"NanoClaw → Ollama\" sound name \"Submarine\"" 2>/dev/null
+ echo "$(date +%H:%M:%S) 🔄 $DETAIL"
+ ;;
+ done)
+ osascript -e "display notification \"$DETAIL\" with title \"NanoClaw ← Ollama ✓\" sound name \"Glass\"" 2>/dev/null
+ echo "$(date +%H:%M:%S) ✅ $DETAIL"
+ ;;
+ listing)
+ echo "$(date +%H:%M:%S) 📋 Listing models..."
+ ;;
+ esac
+ done
+ sleep 0.5
+done
diff --git a/.claude/skills/add-ollama-tool/manifest.yaml b/.claude/skills/add-ollama-tool/manifest.yaml
new file mode 100644
index 0000000..6ce813a
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/manifest.yaml
@@ -0,0 +1,17 @@
+skill: ollama
+version: 1.0.0
+description: "Local Ollama model inference via MCP server"
+core_version: 0.1.0
+adds:
+ - container/agent-runner/src/ollama-mcp-stdio.ts
+ - scripts/ollama-watch.sh
+modifies:
+ - container/agent-runner/src/index.ts
+ - src/container-runner.ts
+structured:
+ npm_dependencies: {}
+ env_additions:
+ - OLLAMA_HOST
+conflicts: []
+depends: []
+test: "npm run build"
diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts
new file mode 100644
index 0000000..7432393
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts
@@ -0,0 +1,593 @@
+/**
+ * NanoClaw Agent Runner
+ * Runs inside a container, receives config via stdin, outputs result to stdout
+ *
+ * Input protocol:
+ * Stdin: Full ContainerInput JSON (read until EOF, like before)
+ * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
+ * Files: {type:"message", text:"..."}.json — polled and consumed
+ * Sentinel: /workspace/ipc/input/_close — signals session end
+ *
+ * Stdout protocol:
+ * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
+ * Multiple results may be emitted (one per agent teams result).
+ * Final marker after loop ends signals completion.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
+import { fileURLToPath } from 'url';
+
+interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+}
+
+interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface SessionEntry {
+ sessionId: string;
+ fullPath: string;
+ summary: string;
+ firstPrompt: string;
+}
+
+interface SessionsIndex {
+ entries: SessionEntry[];
+}
+
+interface SDKUserMessage {
+ type: 'user';
+ message: { role: 'user'; content: string };
+ parent_tool_use_id: null;
+ session_id: string;
+}
+
+const IPC_INPUT_DIR = '/workspace/ipc/input';
+const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
+const IPC_POLL_MS = 500;
+
+/**
+ * Push-based async iterable for streaming user messages to the SDK.
+ * Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
+ */
+class MessageStream {
+ private queue: SDKUserMessage[] = [];
+ private waiting: (() => void) | null = null;
+ private done = false;
+
+ push(text: string): void {
+ this.queue.push({
+ type: 'user',
+ message: { role: 'user', content: text },
+ parent_tool_use_id: null,
+ session_id: '',
+ });
+ this.waiting?.();
+ }
+
+ end(): void {
+ this.done = true;
+ this.waiting?.();
+ }
+
+ async *[Symbol.asyncIterator](): AsyncGenerator {
+ while (true) {
+ while (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ }
+ if (this.done) return;
+ await new Promise(r => { this.waiting = r; });
+ this.waiting = null;
+ }
+ }
+}
+
+async function readStdin(): Promise {
+ return new Promise((resolve, reject) => {
+ let data = '';
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', chunk => { data += chunk; });
+ process.stdin.on('end', () => resolve(data));
+ process.stdin.on('error', reject);
+ });
+}
+
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+function writeOutput(output: ContainerOutput): void {
+ console.log(OUTPUT_START_MARKER);
+ console.log(JSON.stringify(output));
+ console.log(OUTPUT_END_MARKER);
+}
+
+function log(message: string): void {
+ console.error(`[agent-runner] ${message}`);
+}
+
+function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
+ const projectDir = path.dirname(transcriptPath);
+ const indexPath = path.join(projectDir, 'sessions-index.json');
+
+ if (!fs.existsSync(indexPath)) {
+ log(`Sessions index not found at ${indexPath}`);
+ return null;
+ }
+
+ try {
+ const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
+ const entry = index.entries.find(e => e.sessionId === sessionId);
+ if (entry?.summary) {
+ return entry.summary;
+ }
+ } catch (err) {
+ log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ return null;
+}
+
+/**
+ * Archive the full transcript to conversations/ before compaction.
+ */
+function createPreCompactHook(assistantName?: string): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preCompact = input as PreCompactHookInput;
+ const transcriptPath = preCompact.transcript_path;
+ const sessionId = preCompact.session_id;
+
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
+ log('No transcript found for archiving');
+ return {};
+ }
+
+ try {
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
+ const messages = parseTranscript(content);
+
+ if (messages.length === 0) {
+ log('No messages to archive');
+ return {};
+ }
+
+ const summary = getSessionSummary(sessionId, transcriptPath);
+ const name = summary ? sanitizeFilename(summary) : generateFallbackName();
+
+ const conversationsDir = '/workspace/group/conversations';
+ fs.mkdirSync(conversationsDir, { recursive: true });
+
+ const date = new Date().toISOString().split('T')[0];
+ const filename = `${date}-${name}.md`;
+ const filePath = path.join(conversationsDir, filename);
+
+ const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
+ fs.writeFileSync(filePath, markdown);
+
+ log(`Archived conversation to ${filePath}`);
+ } catch (err) {
+ log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ return {};
+ };
+}
+
+// Secrets to strip from Bash tool subprocess environments.
+// These are needed by claude-code for API auth but should never
+// be visible to commands Kit runs.
+const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
+
+function createSanitizeBashHook(): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preInput = input as PreToolUseHookInput;
+ const command = (preInput.tool_input as { command?: string })?.command;
+ if (!command) return {};
+
+ const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
+ return {
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ updatedInput: {
+ ...(preInput.tool_input as Record),
+ command: unsetPrefix + command,
+ },
+ },
+ };
+ };
+}
+
+function sanitizeFilename(summary: string): string {
+ return summary
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 50);
+}
+
+function generateFallbackName(): string {
+ const time = new Date();
+ return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
+}
+
+interface ParsedMessage {
+ role: 'user' | 'assistant';
+ content: string;
+}
+
+function parseTranscript(content: string): ParsedMessage[] {
+ const messages: ParsedMessage[] = [];
+
+ for (const line of content.split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type === 'user' && entry.message?.content) {
+ const text = typeof entry.message.content === 'string'
+ ? entry.message.content
+ : entry.message.content.map((c: { text?: string }) => c.text || '').join('');
+ if (text) messages.push({ role: 'user', content: text });
+ } else if (entry.type === 'assistant' && entry.message?.content) {
+ const textParts = entry.message.content
+ .filter((c: { type: string }) => c.type === 'text')
+ .map((c: { text: string }) => c.text);
+ const text = textParts.join('');
+ if (text) messages.push({ role: 'assistant', content: text });
+ }
+ } catch {
+ }
+ }
+
+ return messages;
+}
+
+function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
+ const now = new Date();
+ const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ });
+
+ const lines: string[] = [];
+ lines.push(`# ${title || 'Conversation'}`);
+ lines.push('');
+ lines.push(`Archived: ${formatDateTime(now)}`);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+
+ for (const msg of messages) {
+ const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
+ const content = msg.content.length > 2000
+ ? msg.content.slice(0, 2000) + '...'
+ : msg.content;
+ lines.push(`**${sender}**: ${content}`);
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Check for _close sentinel.
+ */
+function shouldClose(): boolean {
+ if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
+ try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Drain all pending IPC input messages.
+ * Returns messages found, or empty array.
+ */
+function drainIpcInput(): string[] {
+ try {
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+ const files = fs.readdirSync(IPC_INPUT_DIR)
+ .filter(f => f.endsWith('.json'))
+ .sort();
+
+ const messages: string[] = [];
+ for (const file of files) {
+ const filePath = path.join(IPC_INPUT_DIR, file);
+ try {
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ fs.unlinkSync(filePath);
+ if (data.type === 'message' && data.text) {
+ messages.push(data.text);
+ }
+ } catch (err) {
+ log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
+ }
+ }
+ return messages;
+ } catch (err) {
+ log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
+ return [];
+ }
+}
+
+/**
+ * Wait for a new IPC message or _close sentinel.
+ * Returns the messages as a single string, or null if _close.
+ */
+function waitForIpcMessage(): Promise {
+ return new Promise((resolve) => {
+ const poll = () => {
+ if (shouldClose()) {
+ resolve(null);
+ return;
+ }
+ const messages = drainIpcInput();
+ if (messages.length > 0) {
+ resolve(messages.join('\n'));
+ return;
+ }
+ setTimeout(poll, IPC_POLL_MS);
+ };
+ poll();
+ });
+}
+
+/**
+ * Run a single query and stream results via writeOutput.
+ * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
+ * allowing agent teams subagents to run to completion.
+ * Also pipes IPC messages into the stream during the query.
+ */
+async function runQuery(
+ prompt: string,
+ sessionId: string | undefined,
+ mcpServerPath: string,
+ containerInput: ContainerInput,
+ sdkEnv: Record,
+ resumeAt?: string,
+): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
+ const stream = new MessageStream();
+ stream.push(prompt);
+
+ // Poll IPC for follow-up messages and _close sentinel during the query
+ let ipcPolling = true;
+ let closedDuringQuery = false;
+ const pollIpcDuringQuery = () => {
+ if (!ipcPolling) return;
+ if (shouldClose()) {
+ log('Close sentinel detected during query, ending stream');
+ closedDuringQuery = true;
+ stream.end();
+ ipcPolling = false;
+ return;
+ }
+ const messages = drainIpcInput();
+ for (const text of messages) {
+ log(`Piping IPC message into active query (${text.length} chars)`);
+ stream.push(text);
+ }
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+ };
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+
+ let newSessionId: string | undefined;
+ let lastAssistantUuid: string | undefined;
+ let messageCount = 0;
+ let resultCount = 0;
+
+ // Load global CLAUDE.md as additional system context (shared across all groups)
+ const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
+ let globalClaudeMd: string | undefined;
+ if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
+ globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
+ }
+
+ // Discover additional directories mounted at /workspace/extra/*
+ // These are passed to the SDK so their CLAUDE.md files are loaded automatically
+ const extraDirs: string[] = [];
+ const extraBase = '/workspace/extra';
+ if (fs.existsSync(extraBase)) {
+ for (const entry of fs.readdirSync(extraBase)) {
+ const fullPath = path.join(extraBase, entry);
+ if (fs.statSync(fullPath).isDirectory()) {
+ extraDirs.push(fullPath);
+ }
+ }
+ }
+ if (extraDirs.length > 0) {
+ log(`Additional directories: ${extraDirs.join(', ')}`);
+ }
+
+ for await (const message of query({
+ prompt: stream,
+ options: {
+ cwd: '/workspace/group',
+ additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
+ resume: sessionId,
+ resumeSessionAt: resumeAt,
+ systemPrompt: globalClaudeMd
+ ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
+ : undefined,
+ allowedTools: [
+ 'Bash',
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep',
+ 'WebSearch', 'WebFetch',
+ 'Task', 'TaskOutput', 'TaskStop',
+ 'TeamCreate', 'TeamDelete', 'SendMessage',
+ 'TodoWrite', 'ToolSearch', 'Skill',
+ 'NotebookEdit',
+ 'mcp__nanoclaw__*',
+ 'mcp__ollama__*'
+ ],
+ env: sdkEnv,
+ permissionMode: 'bypassPermissions',
+ allowDangerouslySkipPermissions: true,
+ settingSources: ['project', 'user'],
+ mcpServers: {
+ nanoclaw: {
+ command: 'node',
+ args: [mcpServerPath],
+ env: {
+ NANOCLAW_CHAT_JID: containerInput.chatJid,
+ NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
+ NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
+ },
+ },
+ ollama: {
+ command: 'node',
+ args: [path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')],
+ },
+ },
+ hooks: {
+ PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
+ PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
+ },
+ }
+ })) {
+ messageCount++;
+ const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
+ log(`[msg #${messageCount}] type=${msgType}`);
+
+ if (message.type === 'assistant' && 'uuid' in message) {
+ lastAssistantUuid = (message as { uuid: string }).uuid;
+ }
+
+ if (message.type === 'system' && message.subtype === 'init') {
+ newSessionId = message.session_id;
+ log(`Session initialized: ${newSessionId}`);
+ }
+
+ if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
+ const tn = message as { task_id: string; status: string; summary: string };
+ log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
+ }
+
+ if (message.type === 'result') {
+ resultCount++;
+ const textResult = 'result' in message ? (message as { result?: string }).result : null;
+ log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
+ writeOutput({
+ status: 'success',
+ result: textResult || null,
+ newSessionId
+ });
+ }
+ }
+
+ ipcPolling = false;
+ log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
+ return { newSessionId, lastAssistantUuid, closedDuringQuery };
+}
+
+async function main(): Promise {
+ let containerInput: ContainerInput;
+
+ try {
+ const stdinData = await readStdin();
+ containerInput = JSON.parse(stdinData);
+ // Delete the temp file the entrypoint wrote — it contains secrets
+ try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
+ log(`Received input for group: ${containerInput.groupFolder}`);
+ } catch (err) {
+ writeOutput({
+ status: 'error',
+ result: null,
+ error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
+ });
+ process.exit(1);
+ }
+
+ // Build SDK env: merge secrets into process.env for the SDK only.
+ // Secrets never touch process.env itself, so Bash subprocesses can't see them.
+ const sdkEnv: Record = { ...process.env };
+ for (const [key, value] of Object.entries(containerInput.secrets || {})) {
+ sdkEnv[key] = value;
+ }
+
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
+
+ let sessionId = containerInput.sessionId;
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+
+ // Clean up stale _close sentinel from previous container runs
+ try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
+
+ // Build initial prompt (drain any pending IPC messages too)
+ let prompt = containerInput.prompt;
+ if (containerInput.isScheduledTask) {
+ prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
+ }
+ const pending = drainIpcInput();
+ if (pending.length > 0) {
+ log(`Draining ${pending.length} pending IPC messages into initial prompt`);
+ prompt += '\n' + pending.join('\n');
+ }
+
+ // Query loop: run query → wait for IPC message → run new query → repeat
+ let resumeAt: string | undefined;
+ try {
+ while (true) {
+ log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
+
+ const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
+ if (queryResult.newSessionId) {
+ sessionId = queryResult.newSessionId;
+ }
+ if (queryResult.lastAssistantUuid) {
+ resumeAt = queryResult.lastAssistantUuid;
+ }
+
+ // If _close was consumed during the query, exit immediately.
+ // Don't emit a session-update marker (it would reset the host's
+ // idle timer and cause a 30-min delay before the next _close).
+ if (queryResult.closedDuringQuery) {
+ log('Close sentinel consumed during query, exiting');
+ break;
+ }
+
+ // Emit session update so host can track it
+ writeOutput({ status: 'success', result: null, newSessionId: sessionId });
+
+ log('Query ended, waiting for next IPC message...');
+
+ // Wait for the next message or _close sentinel
+ const nextMessage = await waitForIpcMessage();
+ if (nextMessage === null) {
+ log('Close sentinel received, exiting');
+ break;
+ }
+
+ log(`Got new message (${nextMessage.length} chars), starting new query`);
+ prompt = nextMessage;
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ log(`Agent error: ${errorMessage}`);
+ writeOutput({
+ status: 'error',
+ result: null,
+ newSessionId: sessionId,
+ error: errorMessage
+ });
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md
new file mode 100644
index 0000000..a657ef5
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/modify/container/agent-runner/src/index.ts.intent.md
@@ -0,0 +1,23 @@
+# Intent: container/agent-runner/src/index.ts modifications
+
+## What changed
+Added Ollama MCP server configuration so the container agent can call local Ollama models as tools.
+
+## Key sections
+
+### allowedTools array (inside runQuery → options)
+- Added: `'mcp__ollama__*'` to the allowedTools array (after `'mcp__nanoclaw__*'`)
+
+### mcpServers object (inside runQuery → options)
+- Added: `ollama` entry as a stdio MCP server
+ - command: `'node'`
+ - args: resolves to `ollama-mcp-stdio.js` in the same directory as `ipc-mcp-stdio.js`
+ - Uses `path.join(path.dirname(mcpServerPath), 'ollama-mcp-stdio.js')` to compute the path
+
+## Invariants (must-keep)
+- All existing allowedTools entries unchanged
+- nanoclaw MCP server config unchanged
+- All other query options (permissionMode, hooks, env, etc.) unchanged
+- MessageStream class unchanged
+- IPC polling logic unchanged
+- Session management unchanged
diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts
new file mode 100644
index 0000000..2324cde
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts
@@ -0,0 +1,708 @@
+/**
+ * Container Runner for NanoClaw
+ * Spawns agent execution in containers and handles IPC
+ */
+import { ChildProcess, exec, spawn } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import {
+ CONTAINER_IMAGE,
+ CONTAINER_MAX_OUTPUT_SIZE,
+ CONTAINER_TIMEOUT,
+ DATA_DIR,
+ GROUPS_DIR,
+ IDLE_TIMEOUT,
+ TIMEZONE,
+} from './config.js';
+import { readEnvFile } from './env.js';
+import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
+import { logger } from './logger.js';
+import {
+ CONTAINER_RUNTIME_BIN,
+ readonlyMountArgs,
+ stopContainer,
+} from './container-runtime.js';
+import { validateAdditionalMounts } from './mount-security.js';
+import { RegisteredGroup } from './types.js';
+
+// Sentinel markers for robust output parsing (must match agent-runner)
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+export interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+}
+
+export interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface VolumeMount {
+ hostPath: string;
+ containerPath: string;
+ readonly: boolean;
+}
+
+function buildVolumeMounts(
+ group: RegisteredGroup,
+ isMain: boolean,
+): VolumeMount[] {
+ const mounts: VolumeMount[] = [];
+ const projectRoot = process.cwd();
+ const groupDir = resolveGroupFolderPath(group.folder);
+
+ if (isMain) {
+ // Main gets the project root read-only. Writable paths the agent needs
+ // (group folder, IPC, .claude/) are mounted separately below.
+ // Read-only prevents the agent from modifying host application code
+ // (src/, dist/, package.json, etc.) which would bypass the sandbox
+ // entirely on next restart.
+ mounts.push({
+ hostPath: projectRoot,
+ containerPath: '/workspace/project',
+ readonly: true,
+ });
+
+ // Shadow .env so the agent cannot read secrets from the mounted project root.
+ // Secrets are passed via stdin instead (see readSecrets()).
+ const envFile = path.join(projectRoot, '.env');
+ if (fs.existsSync(envFile)) {
+ mounts.push({
+ hostPath: '/dev/null',
+ containerPath: '/workspace/project/.env',
+ readonly: true,
+ });
+ }
+
+ // Main also gets its group folder as the working directory
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+ } else {
+ // Other groups only get their own folder
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+
+ // Global memory directory (read-only for non-main)
+ // Only directory mounts are supported, not file mounts
+ const globalDir = path.join(GROUPS_DIR, 'global');
+ if (fs.existsSync(globalDir)) {
+ mounts.push({
+ hostPath: globalDir,
+ containerPath: '/workspace/global',
+ readonly: true,
+ });
+ }
+ }
+
+ // Per-group Claude sessions directory (isolated from other groups)
+ // Each group gets their own .claude/ to prevent cross-group session access
+ const groupSessionsDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ '.claude',
+ );
+ fs.mkdirSync(groupSessionsDir, { recursive: true });
+ const settingsFile = path.join(groupSessionsDir, 'settings.json');
+ if (!fs.existsSync(settingsFile)) {
+ fs.writeFileSync(
+ settingsFile,
+ JSON.stringify(
+ {
+ env: {
+ // Enable agent swarms (subagent orchestration)
+ // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
+ // Load CLAUDE.md from additional mounted directories
+ // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
+ CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
+ // Enable Claude's memory feature (persists user preferences between sessions)
+ // https://code.claude.com/docs/en/memory#manage-auto-memory
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
+ },
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ }
+
+ // Sync skills from container/skills/ into each group's .claude/skills/
+ const skillsSrc = path.join(process.cwd(), 'container', 'skills');
+ const skillsDst = path.join(groupSessionsDir, 'skills');
+ if (fs.existsSync(skillsSrc)) {
+ for (const skillDir of fs.readdirSync(skillsSrc)) {
+ const srcDir = path.join(skillsSrc, skillDir);
+ if (!fs.statSync(srcDir).isDirectory()) continue;
+ const dstDir = path.join(skillsDst, skillDir);
+ fs.cpSync(srcDir, dstDir, { recursive: true });
+ }
+ }
+ mounts.push({
+ hostPath: groupSessionsDir,
+ containerPath: '/home/node/.claude',
+ readonly: false,
+ });
+
+ // Per-group IPC namespace: each group gets its own IPC directory
+ // This prevents cross-group privilege escalation via IPC
+ const groupIpcDir = resolveGroupIpcPath(group.folder);
+ fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
+ mounts.push({
+ hostPath: groupIpcDir,
+ containerPath: '/workspace/ipc',
+ readonly: false,
+ });
+
+ // Copy agent-runner source into a per-group writable location so agents
+ // can customize it (add tools, change behavior) without affecting other
+ // groups. Recompiled on container startup via entrypoint.sh.
+ const agentRunnerSrc = path.join(
+ projectRoot,
+ 'container',
+ 'agent-runner',
+ 'src',
+ );
+ const groupAgentRunnerDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ 'agent-runner-src',
+ );
+ if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
+ fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
+ }
+ mounts.push({
+ hostPath: groupAgentRunnerDir,
+ containerPath: '/app/src',
+ readonly: false,
+ });
+
+ // Additional mounts validated against external allowlist (tamper-proof from containers)
+ if (group.containerConfig?.additionalMounts) {
+ const validatedMounts = validateAdditionalMounts(
+ group.containerConfig.additionalMounts,
+ group.name,
+ isMain,
+ );
+ mounts.push(...validatedMounts);
+ }
+
+ return mounts;
+}
+
+/**
+ * Read allowed secrets from .env for passing to the container via stdin.
+ * Secrets are never written to disk or mounted as files.
+ */
+function readSecrets(): Record {
+ return readEnvFile([
+ 'CLAUDE_CODE_OAUTH_TOKEN',
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_BASE_URL',
+ 'ANTHROPIC_AUTH_TOKEN',
+ ]);
+}
+
+function buildContainerArgs(
+ mounts: VolumeMount[],
+ containerName: string,
+): string[] {
+ const args: string[] = ['run', '-i', '--rm', '--name', containerName];
+
+ // Pass host timezone so container's local time matches the user's
+ args.push('-e', `TZ=${TIMEZONE}`);
+
+ // Run as host user so bind-mounted files are accessible.
+ // Skip when running as root (uid 0), as the container's node user (uid 1000),
+ // or when getuid is unavailable (native Windows without WSL).
+ const hostUid = process.getuid?.();
+ const hostGid = process.getgid?.();
+ if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
+ args.push('--user', `${hostUid}:${hostGid}`);
+ args.push('-e', 'HOME=/home/node');
+ }
+
+ for (const mount of mounts) {
+ if (mount.readonly) {
+ args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
+ } else {
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
+ }
+ }
+
+ args.push(CONTAINER_IMAGE);
+
+ return args;
+}
+
+export async function runContainerAgent(
+ group: RegisteredGroup,
+ input: ContainerInput,
+ onProcess: (proc: ChildProcess, containerName: string) => void,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise {
+ const startTime = Date.now();
+
+ const groupDir = resolveGroupFolderPath(group.folder);
+ fs.mkdirSync(groupDir, { recursive: true });
+
+ const mounts = buildVolumeMounts(group, input.isMain);
+ const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
+ const containerName = `nanoclaw-${safeName}-${Date.now()}`;
+ const containerArgs = buildContainerArgs(mounts, containerName);
+
+ logger.debug(
+ {
+ group: group.name,
+ containerName,
+ mounts: mounts.map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ ),
+ containerArgs: containerArgs.join(' '),
+ },
+ 'Container mount configuration',
+ );
+
+ logger.info(
+ {
+ group: group.name,
+ containerName,
+ mountCount: mounts.length,
+ isMain: input.isMain,
+ },
+ 'Spawning container agent',
+ );
+
+ const logsDir = path.join(groupDir, 'logs');
+ fs.mkdirSync(logsDir, { recursive: true });
+
+ return new Promise((resolve) => {
+ const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ onProcess(container, containerName);
+
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+
+ // Pass secrets via stdin (never written to disk or mounted as files)
+ input.secrets = readSecrets();
+ container.stdin.write(JSON.stringify(input));
+ container.stdin.end();
+ // Remove secrets from input so they don't appear in logs
+ delete input.secrets;
+
+ // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
+ let parseBuffer = '';
+ let newSessionId: string | undefined;
+ let outputChain = Promise.resolve();
+
+ container.stdout.on('data', (data) => {
+ const chunk = data.toString();
+
+ // Always accumulate for logging
+ if (!stdoutTruncated) {
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
+ if (chunk.length > remaining) {
+ stdout += chunk.slice(0, remaining);
+ stdoutTruncated = true;
+ logger.warn(
+ { group: group.name, size: stdout.length },
+ 'Container stdout truncated due to size limit',
+ );
+ } else {
+ stdout += chunk;
+ }
+ }
+
+ // Stream-parse for output markers
+ if (onOutput) {
+ parseBuffer += chunk;
+ let startIdx: number;
+ while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
+ const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
+ if (endIdx === -1) break; // Incomplete pair, wait for more data
+
+ const jsonStr = parseBuffer
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
+
+ try {
+ const parsed: ContainerOutput = JSON.parse(jsonStr);
+ if (parsed.newSessionId) {
+ newSessionId = parsed.newSessionId;
+ }
+ hadStreamingOutput = true;
+ // Activity detected — reset the hard timeout
+ resetTimeout();
+ // Call onOutput for all markers (including null results)
+ // so idle timers start even for "silent" query completions.
+ outputChain = outputChain.then(() => onOutput(parsed));
+ } catch (err) {
+ logger.warn(
+ { group: group.name, error: err },
+ 'Failed to parse streamed output chunk',
+ );
+ }
+ }
+ }
+ });
+
+ container.stderr.on('data', (data) => {
+ const chunk = data.toString();
+ const lines = chunk.trim().split('\n');
+ for (const line of lines) {
+ if (!line) continue;
+ // Surface Ollama MCP activity at info level for visibility
+ if (line.includes('[OLLAMA]')) {
+ logger.info({ container: group.folder }, line);
+ } else {
+ logger.debug({ container: group.folder }, line);
+ }
+ }
+ // Don't reset timeout on stderr — SDK writes debug logs continuously.
+ // Timeout only resets on actual output (OUTPUT_MARKER in stdout).
+ if (stderrTruncated) return;
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
+ if (chunk.length > remaining) {
+ stderr += chunk.slice(0, remaining);
+ stderrTruncated = true;
+ logger.warn(
+ { group: group.name, size: stderr.length },
+ 'Container stderr truncated due to size limit',
+ );
+ } else {
+ stderr += chunk;
+ }
+ });
+
+ let timedOut = false;
+ let hadStreamingOutput = false;
+ const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
+ // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
+ // graceful _close sentinel has time to trigger before the hard kill fires.
+ const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
+
+ const killOnTimeout = () => {
+ timedOut = true;
+ logger.error(
+ { 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');
+ }
+ });
+ };
+
+ let timeout = setTimeout(killOnTimeout, timeoutMs);
+
+ // Reset the timeout whenever there's activity (streaming output)
+ const resetTimeout = () => {
+ clearTimeout(timeout);
+ timeout = setTimeout(killOnTimeout, timeoutMs);
+ };
+
+ container.on('close', (code) => {
+ clearTimeout(timeout);
+ const duration = Date.now() - startTime;
+
+ if (timedOut) {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const timeoutLog = path.join(logsDir, `container-${ts}.log`);
+ fs.writeFileSync(
+ timeoutLog,
+ [
+ `=== Container Run Log (TIMEOUT) ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `Container: ${containerName}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Had Streaming Output: ${hadStreamingOutput}`,
+ ].join('\n'),
+ );
+
+ // Timeout after output = idle cleanup, not failure.
+ // The agent already sent its response; this is just the
+ // container being reaped after the idle period expired.
+ if (hadStreamingOutput) {
+ logger.info(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out after output (idle cleanup)',
+ );
+ outputChain.then(() => {
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ logger.error(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out with no output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container timed out after ${configTimeout}ms`,
+ });
+ return;
+ }
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const logFile = path.join(logsDir, `container-${timestamp}.log`);
+ const isVerbose =
+ process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
+
+ const logLines = [
+ `=== Container Run Log ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `IsMain: ${input.isMain}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Stdout Truncated: ${stdoutTruncated}`,
+ `Stderr Truncated: ${stderrTruncated}`,
+ ``,
+ ];
+
+ const isError = code !== 0;
+
+ if (isVerbose || isError) {
+ logLines.push(
+ `=== Input ===`,
+ JSON.stringify(input, null, 2),
+ ``,
+ `=== Container Args ===`,
+ containerArgs.join(' '),
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ )
+ .join('\n'),
+ ``,
+ `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stderr,
+ ``,
+ `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stdout,
+ );
+ } else {
+ logLines.push(
+ `=== Input Summary ===`,
+ `Prompt length: ${input.prompt.length} chars`,
+ `Session ID: ${input.sessionId || 'new'}`,
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
+ .join('\n'),
+ ``,
+ );
+ }
+
+ fs.writeFileSync(logFile, logLines.join('\n'));
+ logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
+
+ if (code !== 0) {
+ logger.error(
+ {
+ group: group.name,
+ code,
+ duration,
+ stderr,
+ stdout,
+ logFile,
+ },
+ 'Container exited with error',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
+ });
+ return;
+ }
+
+ // Streaming mode: wait for output chain to settle, return completion marker
+ if (onOutput) {
+ outputChain.then(() => {
+ logger.info(
+ { group: group.name, duration, newSessionId },
+ 'Container completed (streaming mode)',
+ );
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ // Legacy mode: parse the last output marker pair from accumulated stdout
+ try {
+ // Extract JSON between sentinel markers for robust parsing
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
+
+ let jsonLine: string;
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
+ jsonLine = stdout
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ } else {
+ // Fallback: last non-empty line (backwards compatibility)
+ const lines = stdout.trim().split('\n');
+ jsonLine = lines[lines.length - 1];
+ }
+
+ const output: ContainerOutput = JSON.parse(jsonLine);
+
+ logger.info(
+ {
+ group: group.name,
+ duration,
+ status: output.status,
+ hasResult: !!output.result,
+ },
+ 'Container completed',
+ );
+
+ resolve(output);
+ } catch (err) {
+ logger.error(
+ {
+ group: group.name,
+ stdout,
+ stderr,
+ error: err,
+ },
+ 'Failed to parse container output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ });
+
+ container.on('error', (err) => {
+ clearTimeout(timeout);
+ logger.error(
+ { group: group.name, containerName, error: err },
+ 'Container spawn error',
+ );
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container spawn error: ${err.message}`,
+ });
+ });
+ });
+}
+
+export function writeTasksSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ tasks: Array<{
+ id: string;
+ groupFolder: string;
+ prompt: string;
+ schedule_type: string;
+ schedule_value: string;
+ status: string;
+ next_run: string | null;
+ }>,
+): void {
+ // Write filtered tasks to the group's IPC directory
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all tasks, others only see their own
+ const filteredTasks = isMain
+ ? tasks
+ : tasks.filter((t) => t.groupFolder === groupFolder);
+
+ const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
+ fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
+}
+
+export interface AvailableGroup {
+ jid: string;
+ name: string;
+ lastActivity: string;
+ isRegistered: boolean;
+}
+
+/**
+ * Write available groups snapshot for the container to read.
+ * Only main group can see all available groups (for activation).
+ * Non-main groups only see their own registration status.
+ */
+export function writeGroupsSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ groups: AvailableGroup[],
+ registeredJids: Set,
+): void {
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all groups; others see nothing (they can't activate groups)
+ const visibleGroups = isMain ? groups : [];
+
+ const groupsFile = path.join(groupIpcDir, 'available_groups.json');
+ fs.writeFileSync(
+ groupsFile,
+ JSON.stringify(
+ {
+ groups: visibleGroups,
+ lastSync: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+}
diff --git a/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md
new file mode 100644
index 0000000..498ac6c
--- /dev/null
+++ b/.claude/skills/add-ollama-tool/modify/src/container-runner.ts.intent.md
@@ -0,0 +1,18 @@
+# Intent: src/container-runner.ts modifications
+
+## What changed
+Surface Ollama MCP server log lines at info level so they appear in `nanoclaw.log` for the monitoring watcher script.
+
+## Key sections
+
+### container.stderr handler (inside runContainerAgent)
+- Changed: empty line check from `if (line)` to `if (!line) continue;`
+- Added: `[OLLAMA]` tag detection — lines containing `[OLLAMA]` are logged at `logger.info` instead of `logger.debug`
+- All other stderr lines remain at `logger.debug` level
+
+## Invariants (must-keep)
+- Stderr truncation logic unchanged
+- Timeout reset logic unchanged (stderr doesn't reset timeout)
+- Stdout parsing logic unchanged
+- Volume mount logic unchanged
+- All other container lifecycle unchanged
From e14c8580af7026355aabf03d9894bcfc097c68d1 Mon Sep 17 00:00:00 2001
From: gavrielc
Date: Fri, 6 Mar 2026 10:49:40 +0200
Subject: [PATCH 035/246] Update README.md
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 5300a02..19b99e7 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ NanoClaw provides that same core functionality, but in a codebase small enough t
```bash
git clone https://github.com/qwibitai/nanoclaw.git
-cd NanoClaw
+cd nanoclaw
claude
```
From 68123fdd81c259f3379c21bd9e6b1eb0e29b9a8d Mon Sep 17 00:00:00 2001
From: Gavriel Cohen
Date: Fri, 6 Mar 2026 12:15:36 +0200
Subject: [PATCH 036/246] feat: add update_task tool and return task ID from
schedule_task
schedule_task was creating duplicate tasks when users asked to modify
a schedule, because the agent had no way to update an existing task
and didn't know the ID of the task it created. Now schedule_task
generates and returns the task ID, and a new update_task tool allows
modifying prompt, schedule_type, and schedule_value in place.
Co-Authored-By: Claude Opus 4.6
---
container/agent-runner/src/ipc-mcp-stdio.ts | 59 +++++++++++++++++-
src/ipc.ts | 68 ++++++++++++++++++++-
2 files changed, 123 insertions(+), 4 deletions(-)
diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts
index fc1236d..d95abb6 100644
--- a/container/agent-runner/src/ipc-mcp-stdio.ts
+++ b/container/agent-runner/src/ipc-mcp-stdio.ts
@@ -64,7 +64,7 @@ server.tool(
server.tool(
'schedule_task',
- `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools.
+ `Schedule a recurring or one-time task. The task will run as a full agent with access to all tools. Returns the task ID for future reference. To modify an existing task, use update_task instead.
CONTEXT MODE - Choose based on task type:
\u2022 "group": Task runs in the group's conversation context, with access to chat history. Use for tasks that need context about ongoing discussions, user preferences, or recent interactions.
@@ -130,8 +130,11 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
// Non-main groups can only schedule for themselves
const targetJid = isMain && args.target_group_jid ? args.target_group_jid : chatJid;
+ const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+
const data = {
type: 'schedule_task',
+ taskId,
prompt: args.prompt,
schedule_type: args.schedule_type,
schedule_value: args.schedule_value,
@@ -141,10 +144,10 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
timestamp: new Date().toISOString(),
};
- const filename = writeIpcFile(TASKS_DIR, data);
+ writeIpcFile(TASKS_DIR, data);
return {
- content: [{ type: 'text' as const, text: `Task scheduled (${filename}): ${args.schedule_type} - ${args.schedule_value}` }],
+ content: [{ type: 'text' as const, text: `Task ${taskId} scheduled: ${args.schedule_type} - ${args.schedule_value}` }],
};
},
);
@@ -244,6 +247,56 @@ server.tool(
},
);
+server.tool(
+ 'update_task',
+ 'Update an existing scheduled task. Only provided fields are changed; omitted fields stay the same.',
+ {
+ task_id: z.string().describe('The task ID to update'),
+ prompt: z.string().optional().describe('New prompt for the task'),
+ schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
+ schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
+ },
+ async (args) => {
+ // Validate schedule_value if provided
+ if (args.schedule_type === 'cron' || (!args.schedule_type && args.schedule_value)) {
+ if (args.schedule_value) {
+ try {
+ CronExpressionParser.parse(args.schedule_value);
+ } catch {
+ return {
+ content: [{ type: 'text' as const, text: `Invalid cron: "${args.schedule_value}".` }],
+ isError: true,
+ };
+ }
+ }
+ }
+ if (args.schedule_type === 'interval' && args.schedule_value) {
+ const ms = parseInt(args.schedule_value, 10);
+ if (isNaN(ms) || ms <= 0) {
+ return {
+ content: [{ type: 'text' as const, text: `Invalid interval: "${args.schedule_value}".` }],
+ isError: true,
+ };
+ }
+ }
+
+ const data: Record = {
+ type: 'update_task',
+ taskId: args.task_id,
+ groupFolder,
+ isMain: String(isMain),
+ timestamp: new Date().toISOString(),
+ };
+ if (args.prompt !== undefined) data.prompt = args.prompt;
+ if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
+ if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;
+
+ writeIpcFile(TASKS_DIR, data);
+
+ return { content: [{ type: 'text' as const, text: `Task ${args.task_id} update requested.` }] };
+ },
+);
+
server.tool(
'register_group',
`Register a new chat/group so the agent can respond to messages there. Main group only.
diff --git a/src/ipc.ts b/src/ipc.ts
index d410685..e5614ce 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -247,7 +247,9 @@ export async function processTaskIpc(
nextRun = scheduled.toISOString();
}
- const taskId = `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ const taskId =
+ data.taskId ||
+ `task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const contextMode =
data.context_mode === 'group' || data.context_mode === 'isolated'
? data.context_mode
@@ -325,6 +327,70 @@ export async function processTaskIpc(
}
break;
+ case 'update_task':
+ if (data.taskId) {
+ const task = getTaskById(data.taskId);
+ if (!task) {
+ logger.warn(
+ { taskId: data.taskId, sourceGroup },
+ 'Task not found for update',
+ );
+ break;
+ }
+ if (!isMain && task.group_folder !== sourceGroup) {
+ logger.warn(
+ { taskId: data.taskId, sourceGroup },
+ 'Unauthorized task update attempt',
+ );
+ break;
+ }
+
+ const updates: Parameters[1] = {};
+ if (data.prompt !== undefined) updates.prompt = data.prompt;
+ if (data.schedule_type !== undefined)
+ updates.schedule_type = data.schedule_type as
+ | 'cron'
+ | 'interval'
+ | 'once';
+ if (data.schedule_value !== undefined)
+ updates.schedule_value = data.schedule_value;
+
+ // Recompute next_run if schedule changed
+ if (data.schedule_type || data.schedule_value) {
+ const updatedTask = {
+ ...task,
+ ...updates,
+ };
+ if (updatedTask.schedule_type === 'cron') {
+ try {
+ const interval = CronExpressionParser.parse(
+ updatedTask.schedule_value,
+ { tz: TIMEZONE },
+ );
+ updates.next_run = interval.next().toISOString();
+ } catch {
+ logger.warn(
+ { taskId: data.taskId, value: updatedTask.schedule_value },
+ 'Invalid cron in task update',
+ );
+ break;
+ }
+ } else if (updatedTask.schedule_type === 'interval') {
+ const ms = parseInt(updatedTask.schedule_value, 10);
+ if (!isNaN(ms) && ms > 0) {
+ updates.next_run = new Date(Date.now() + ms).toISOString();
+ }
+ }
+ }
+
+ updateTask(data.taskId, updates);
+ logger.info(
+ { taskId: data.taskId, sourceGroup, updates },
+ 'Task updated via IPC',
+ );
+ }
+ break;
+
case 'refresh_groups':
// Only main group can request a refresh
if (isMain) {
From c48314d813b715bdbaa288ed36d4f439b48879cf Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 10:17:06 +0000
Subject: [PATCH 037/246] chore: bump version to 1.2.7
---
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 05f8f66..36ecc1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.6",
+ "version": "1.2.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.6",
+ "version": "1.2.7",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 6569299..9969d3b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.6",
+ "version": "1.2.7",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From ced0068738a3dd7143d0e2e95dab2f304b3344a8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 10:17:11 +0000
Subject: [PATCH 038/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?7.3k=20tokens=20=C2=B7=2019%=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 7ace3d0..caf6333 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 36.4k tokens, 18% of context window
+
+ 37.3k tokens, 19% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 36.4k
- 36.4k
+ 37.3k
+ 37.3k
From ec0e42b03413d7b8af7daa61f0e200fb7dd106a1 Mon Sep 17 00:00:00 2001
From: Minwoo Kim <158142406+IT-Gentleman@users.noreply.github.com>
Date: Fri, 6 Mar 2026 19:23:09 +0900
Subject: [PATCH 039/246] fix: correct misleading send_message tool description
for scheduled tasks (#729)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The send_message tool description incorrectly stated that a scheduled
task's final output is not delivered to the user, instructing agents to
use the MCP tool for any communication. In reality, task-scheduler.ts
unconditionally forwards the agent's result to the user via a streaming
output callback (deps.sendMessage), which is a direct call to the
channel layer — entirely separate from the MCP tool path.
This caused agents following the description to call send_message
explicitly, resulting in duplicate messages: once via MCP and once via
the native streaming callback.
- Remove the incorrect note from the send_message tool description
- Fix the misleading comment at task-scheduler.ts which attributed
result delivery to the MCP tool rather than the streaming callback
---
container/agent-runner/src/ipc-mcp-stdio.ts | 2 +-
src/task-scheduler.ts | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts
index d95abb6..9de0138 100644
--- a/container/agent-runner/src/ipc-mcp-stdio.ts
+++ b/container/agent-runner/src/ipc-mcp-stdio.ts
@@ -41,7 +41,7 @@ const server = new McpServer({
server.tool(
'send_message',
- "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times. Note: when running as a scheduled task, your final output is NOT sent to the user — use this tool if you need to communicate with the user or group.",
+ "Send a message to the user or group immediately while you're still running. Use this for progress updates or to send multiple messages. You can call this multiple times.",
{
text: z.string().describe('The message text to send'),
sender: z.string().optional().describe('Your role/identity name (e.g. "Researcher"). When set, messages appear from a dedicated bot in Telegram.'),
diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts
index 8c533c7..f216e12 100644
--- a/src/task-scheduler.ts
+++ b/src/task-scheduler.ts
@@ -203,7 +203,7 @@ async function runTask(
if (output.status === 'error') {
error = output.error || 'Unknown error';
} else if (output.result) {
- // Messages are sent via MCP tool (IPC), result text is just logged
+ // Result was already forwarded to the user via the streaming callback above
result = output.result;
}
From 5a4e32623f30e3970a4b91ff14bcb4a352fa3421 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 10:23:23 +0000
Subject: [PATCH 040/246] chore: bump version to 1.2.8
---
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 36ecc1d..d812500 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.7",
+ "version": "1.2.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.7",
+ "version": "1.2.8",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 9969d3b..caaf99b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.7",
+ "version": "1.2.8",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 47ad2e654c7aeb408e5a5fcb24ffb2a9d83df3c3 Mon Sep 17 00:00:00 2001
From: vsabavat <50385532+vsabavat@users.noreply.github.com>
Date: Fri, 6 Mar 2026 02:27:25 -0800
Subject: [PATCH 041/246] fix: add-voice-transcription skill drops WhatsApp
registerChannel call (#766)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The modify/src/channels/whatsapp.ts patch in the add-voice-transcription
skill was missing the registerChannel() call and its registry import.
When the three-way merge ran, this caused the WhatsApp channel to silently
skip registration on every service restart — messages were never received.
Added the missing import and registerChannel factory with a creds.json
guard, matching the pattern used by the add-whatsapp skill template.
Co-authored-by: Claude Sonnet 4.6
---
.../modify/src/channels/whatsapp.ts | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
index 0781185..025e905 100644
--- a/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
+++ b/.claude/skills/add-voice-transcription/modify/src/channels/whatsapp.ts
@@ -20,6 +20,7 @@ import {
import { logger } from '../logger.js';
import { isVoiceMessage, transcribeAudioMessage } from '../transcription.js';
import { Channel, OnInboundMessage, OnChatMetadata, RegisteredGroup } from '../types.js';
+import { registerChannel, ChannelOpts } from './registry.js';
const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
@@ -354,3 +355,12 @@ export class WhatsAppChannel implements Channel {
}
}
}
+
+registerChannel('whatsapp', (opts: ChannelOpts) => {
+ const authDir = path.join(STORE_DIR, 'auth');
+ if (!fs.existsSync(path.join(authDir, 'creds.json'))) {
+ logger.warn('WhatsApp: credentials not found. Run /add-whatsapp to authenticate.');
+ return null;
+ }
+ return new WhatsAppChannel(opts);
+});
From 632713b20806db2342b7f4359dd11c38866f0ec4 Mon Sep 17 00:00:00 2001
From: Gabi Simons
Date: Fri, 6 Mar 2026 18:28:29 +0200
Subject: [PATCH 042/246] feat: timezone-aware context injection for agent
prompts (#691)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: per-group timezone architecture with context injection (#483)
Implement a comprehensive timezone consistency layer so the AI agent always
receives timestamps in the user's local timezone. The framework handles all
UTC↔local conversion transparently — the agent never performs manual timezone
math.
Key changes:
- Per-group timezone stored in containerConfig (no DB migration needed)
- Context injection: header
prepended to every agent prompt with local time and IANA timezone
- Message timestamps converted from UTC to local display in formatMessages()
- schedule_task translation layer: agent writes local times, framework
converts to UTC using per-group timezone for cron, once, and interval types
- Container TZ env var now uses per-group timezone instead of global constant
- New set_timezone MCP tool for users to update their timezone dynamically
- NANOCLAW_TIMEZONE passed to MCP server environment for tool confirmations
Architecture: Store UTC everywhere, convert at boundaries (display to agent,
parse from agent). Groups without timezone configured fall back to the server
TIMEZONE constant for full backward compatibility.
Closes #483
Closes #526
Co-authored-by: shawnYJ
Co-authored-by: Adrian
Co-authored-by: Claude Opus 4.6
* style: apply prettier formatting
Co-authored-by: Claude Opus 4.6
* refactor: strip to minimalist context injection — global TIMEZONE only
Remove per-group timezone support, set_timezone MCP tool, and all
related IPC handlers. The implementation now uses the global system
TIMEZONE for all groups, keeping the diff focused on the message
formatting layer: mandatory timezone param in formatMessages(),
header injection, and formatLocalTime/formatCurrentTime
helpers.
Co-Authored-By: Claude Opus 4.6
* refactor: drop formatCurrentTime and simplify context header
Address PR review: remove redundant formatCurrentTime() since message
timestamps already carry localized times. Simplify header to
only include timezone name.
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: shawnYJ
Co-authored-by: Adrian
Co-authored-by: Claude Opus 4.6
---
src/formatting.test.ts | 51 +++++++++++++++++++++++++++++-------------
src/index.ts | 6 +++--
src/ipc-auth.test.ts | 28 +++++++++++------------
src/ipc.ts | 6 ++---
src/router.ts | 19 +++++++++++-----
src/timezone.test.ts | 29 ++++++++++++++++++++++++
src/timezone.ts | 16 +++++++++++++
7 files changed, 114 insertions(+), 41 deletions(-)
create mode 100644 src/timezone.test.ts
create mode 100644 src/timezone.ts
diff --git a/src/formatting.test.ts b/src/formatting.test.ts
index ea85b9d..8a2160c 100644
--- a/src/formatting.test.ts
+++ b/src/formatting.test.ts
@@ -58,13 +58,14 @@ describe('escapeXml', () => {
// --- formatMessages ---
describe('formatMessages', () => {
- it('formats a single message as XML', () => {
- const result = formatMessages([makeMsg()]);
- expect(result).toBe(
- '\n' +
- 'hello\n' +
- '',
- );
+ const TZ = 'UTC';
+
+ it('formats a single message as XML with context header', () => {
+ const result = formatMessages([makeMsg()], TZ);
+ expect(result).toContain('');
+ expect(result).toContain('hello');
+ expect(result).toContain('Jan 1, 2024');
});
it('formats multiple messages', () => {
@@ -73,11 +74,16 @@ describe('formatMessages', () => {
id: '1',
sender_name: 'Alice',
content: 'hi',
- timestamp: 't1',
+ timestamp: '2024-01-01T00:00:00.000Z',
+ }),
+ makeMsg({
+ id: '2',
+ sender_name: 'Bob',
+ content: 'hey',
+ timestamp: '2024-01-01T01:00:00.000Z',
}),
- makeMsg({ id: '2', sender_name: 'Bob', content: 'hey', timestamp: 't2' }),
];
- const result = formatMessages(msgs);
+ const result = formatMessages(msgs, TZ);
expect(result).toContain('sender="Alice"');
expect(result).toContain('sender="Bob"');
expect(result).toContain('>hi');
@@ -85,22 +91,35 @@ describe('formatMessages', () => {
});
it('escapes special characters in sender names', () => {
- const result = formatMessages([makeMsg({ sender_name: 'A & B ' })]);
+ const result = formatMessages([makeMsg({ sender_name: 'A & B ' })], TZ);
expect(result).toContain('sender="A & B <Co>"');
});
it('escapes special characters in content', () => {
- const result = formatMessages([
- makeMsg({ content: '' }),
- ]);
+ const result = formatMessages(
+ [makeMsg({ content: '' })],
+ TZ,
+ );
expect(result).toContain(
'<script>alert("xss")</script>',
);
});
it('handles empty array', () => {
- const result = formatMessages([]);
- expect(result).toBe('\n\n');
+ const result = formatMessages([], TZ);
+ expect(result).toContain('');
+ expect(result).toContain('\n\n');
+ });
+
+ it('converts timestamps to local time for given timezone', () => {
+ // 2024-01-01T18:30:00Z in America/New_York (EST) = 1:30 PM
+ const result = formatMessages(
+ [makeMsg({ timestamp: '2024-01-01T18:30:00.000Z' })],
+ 'America/New_York',
+ );
+ expect(result).toContain('1:30');
+ expect(result).toContain('PM');
+ expect(result).toContain('');
});
});
diff --git a/src/index.ts b/src/index.ts
index 85aba50..c35261e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@ import {
ASSISTANT_NAME,
IDLE_TIMEOUT,
POLL_INTERVAL,
+ TIMEZONE,
TRIGGER_PATTERN,
} from './config.js';
import './channels/index.js';
@@ -29,6 +30,7 @@ import {
getAllTasks,
getMessagesSince,
getNewMessages,
+ getRegisteredGroup,
getRouterState,
initDatabase,
setRegisteredGroup,
@@ -170,7 +172,7 @@ async function processGroupMessages(chatJid: string): Promise {
if (!hasTrigger) return true;
}
- const prompt = formatMessages(missedMessages);
+ const prompt = formatMessages(missedMessages, TIMEZONE);
// Advance cursor so the piping path in startMessageLoop won't re-fetch
// these messages. Save the old cursor so we can roll back on error.
@@ -408,7 +410,7 @@ async function startMessageLoop(): Promise {
);
const messagesToSend =
allPending.length > 0 ? allPending : groupMessages;
- const formatted = formatMessages(messagesToSend);
+ const formatted = formatMessages(messagesToSend, TIMEZONE);
if (queue.sendMessage(chatJid, formatted)) {
logger.debug(
diff --git a/src/ipc-auth.test.ts b/src/ipc-auth.test.ts
index 7edc7db..1aa681e 100644
--- a/src/ipc-auth.test.ts
+++ b/src/ipc-auth.test.ts
@@ -74,7 +74,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'do something',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
@@ -94,7 +94,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'self task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'other-group',
@@ -113,7 +113,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'unauthorized',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'main@g.us',
},
'other-group',
@@ -131,7 +131,7 @@ describe('schedule_task authorization', () => {
type: 'schedule_task',
prompt: 'no target',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'unknown@g.us',
},
'whatsapp_main',
@@ -154,7 +154,7 @@ describe('pause_task authorization', () => {
chat_jid: 'main@g.us',
prompt: 'main task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
@@ -166,7 +166,7 @@ describe('pause_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'other task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'active',
@@ -215,7 +215,7 @@ describe('resume_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'paused task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: '2025-06-01T00:00:00.000Z',
status: 'paused',
@@ -264,7 +264,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'cancel me',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -287,7 +287,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'other@g.us',
prompt: 'my task',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -310,7 +310,7 @@ describe('cancel_task authorization', () => {
chat_jid: 'main@g.us',
prompt: 'not yours',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
next_run: null,
status: 'active',
@@ -565,7 +565,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'group context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'group',
targetJid: 'other@g.us',
},
@@ -584,7 +584,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'isolated context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'isolated',
targetJid: 'other@g.us',
},
@@ -603,7 +603,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'bad context',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
context_mode: 'bogus' as any,
targetJid: 'other@g.us',
},
@@ -622,7 +622,7 @@ describe('schedule_task context_mode', () => {
type: 'schedule_task',
prompt: 'no context mode',
schedule_type: 'once',
- schedule_value: '2025-06-01T00:00:00.000Z',
+ schedule_value: '2025-06-01T00:00:00',
targetJid: 'other@g.us',
},
'whatsapp_main',
diff --git a/src/ipc.ts b/src/ipc.ts
index e5614ce..7a972c0 100644
--- a/src/ipc.ts
+++ b/src/ipc.ts
@@ -236,15 +236,15 @@ export async function processTaskIpc(
}
nextRun = new Date(Date.now() + ms).toISOString();
} else if (scheduleType === 'once') {
- const scheduled = new Date(data.schedule_value);
- if (isNaN(scheduled.getTime())) {
+ const date = new Date(data.schedule_value);
+ if (isNaN(date.getTime())) {
logger.warn(
{ scheduleValue: data.schedule_value },
'Invalid timestamp',
);
break;
}
- nextRun = scheduled.toISOString();
+ nextRun = date.toISOString();
}
const taskId =
diff --git a/src/router.ts b/src/router.ts
index 3c9fbc0..c14ca89 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -1,4 +1,5 @@
import { Channel, NewMessage } from './types.js';
+import { formatLocalTime } from './timezone.js';
export function escapeXml(s: string): string {
if (!s) return '';
@@ -9,12 +10,18 @@ export function escapeXml(s: string): string {
.replace(/"/g, '"');
}
-export function formatMessages(messages: NewMessage[]): string {
- const lines = messages.map(
- (m) =>
- `${escapeXml(m.content)}`,
- );
- return `\n${lines.join('\n')}\n`;
+export function formatMessages(
+ messages: NewMessage[],
+ timezone: string,
+): string {
+ const lines = messages.map((m) => {
+ const displayTime = formatLocalTime(m.timestamp, timezone);
+ return `${escapeXml(m.content)}`;
+ });
+
+ const header = `\n`;
+
+ return `${header}\n${lines.join('\n')}\n`;
}
export function stripInternalTags(text: string): string {
diff --git a/src/timezone.test.ts b/src/timezone.test.ts
new file mode 100644
index 0000000..df0525f
--- /dev/null
+++ b/src/timezone.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+
+import { formatLocalTime } from './timezone.js';
+
+// --- formatLocalTime ---
+
+describe('formatLocalTime', () => {
+ it('converts UTC to local time display', () => {
+ // 2026-02-04T18:30:00Z in America/New_York (EST, UTC-5) = 1:30 PM
+ const result = formatLocalTime(
+ '2026-02-04T18:30:00.000Z',
+ 'America/New_York',
+ );
+ expect(result).toContain('1:30');
+ expect(result).toContain('PM');
+ expect(result).toContain('Feb');
+ expect(result).toContain('2026');
+ });
+
+ it('handles different timezones', () => {
+ // Same UTC time should produce different local times
+ const utc = '2026-06-15T12:00:00.000Z';
+ const ny = formatLocalTime(utc, 'America/New_York');
+ const tokyo = formatLocalTime(utc, 'Asia/Tokyo');
+ // NY is UTC-4 in summer (EDT), Tokyo is UTC+9
+ expect(ny).toContain('8:00');
+ expect(tokyo).toContain('9:00');
+ });
+});
diff --git a/src/timezone.ts b/src/timezone.ts
new file mode 100644
index 0000000..e7569f4
--- /dev/null
+++ b/src/timezone.ts
@@ -0,0 +1,16 @@
+/**
+ * Convert a UTC ISO timestamp to a localized display string.
+ * Uses the Intl API (no external dependencies).
+ */
+export function formatLocalTime(utcIso: string, timezone: string): string {
+ const date = new Date(utcIso);
+ return date.toLocaleString('en-US', {
+ timeZone: timezone,
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true,
+ });
+}
From 24c6fa25b1ef861192d5e42a270f369f29586ecc Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 16:28:41 +0000
Subject: [PATCH 043/246] chore: bump version to 1.2.9
---
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 d812500..dc8c9c7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.8",
+ "version": "1.2.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.8",
+ "version": "1.2.9",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index caaf99b..5f77bb0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.8",
+ "version": "1.2.9",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 8c38a8c7ffbab108b926e1c6b54861a6fde4f39f Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 16:28:45 +0000
Subject: [PATCH 044/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?7.4k=20tokens=20=C2=B7=2019%=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 caf6333..5942933 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 37.3k tokens, 19% of context window
+
+ 37.4k tokens, 19% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 37.3k
- 37.3k
+ 37.4k
+ 37.4k
From 74b02c87159f3b27ed11d07573d4ca2fb283ea12 Mon Sep 17 00:00:00 2001
From: Gabi Simons
Date: Fri, 6 Mar 2026 18:34:55 +0200
Subject: [PATCH 045/246] fix(db): add LIMIT to unbounded message history
queries (#692) (#735)
getNewMessages() and getMessagesSince() loaded all rows after a
checkpoint with no cap, causing growing memory and token costs.
Both queries now use a DESC LIMIT subquery to return only the
most recent N messages, re-sorted chronologically.
Co-authored-by: Claude Opus 4.6
---
src/db.test.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++
src/db.ts | 38 +++++++++++++++++++++------------
2 files changed, 82 insertions(+), 14 deletions(-)
diff --git a/src/db.test.ts b/src/db.test.ts
index 3051bce..a40d376 100644
--- a/src/db.test.ts
+++ b/src/db.test.ts
@@ -391,6 +391,64 @@ describe('task CRUD', () => {
});
});
+// --- LIMIT behavior ---
+
+describe('message query LIMIT', () => {
+ beforeEach(() => {
+ storeChatMetadata('group@g.us', '2024-01-01T00:00:00.000Z');
+
+ for (let i = 1; i <= 10; i++) {
+ store({
+ id: `lim-${i}`,
+ chat_jid: 'group@g.us',
+ sender: 'user@s.whatsapp.net',
+ sender_name: 'User',
+ content: `message ${i}`,
+ timestamp: `2024-01-01T00:00:${String(i).padStart(2, '0')}.000Z`,
+ });
+ }
+ });
+
+ it('getNewMessages caps to limit and returns most recent in chronological order', () => {
+ const { messages, newTimestamp } = getNewMessages(
+ ['group@g.us'],
+ '2024-01-01T00:00:00.000Z',
+ 'Andy',
+ 3,
+ );
+ expect(messages).toHaveLength(3);
+ expect(messages[0].content).toBe('message 8');
+ expect(messages[2].content).toBe('message 10');
+ // Chronological order preserved
+ expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
+ // newTimestamp reflects latest returned row
+ expect(newTimestamp).toBe('2024-01-01T00:00:10.000Z');
+ });
+
+ it('getMessagesSince caps to limit and returns most recent in chronological order', () => {
+ const messages = getMessagesSince(
+ 'group@g.us',
+ '2024-01-01T00:00:00.000Z',
+ 'Andy',
+ 3,
+ );
+ expect(messages).toHaveLength(3);
+ expect(messages[0].content).toBe('message 8');
+ expect(messages[2].content).toBe('message 10');
+ expect(messages[1].timestamp > messages[0].timestamp).toBe(true);
+ });
+
+ it('returns all messages when count is under the limit', () => {
+ const { messages } = getNewMessages(
+ ['group@g.us'],
+ '2024-01-01T00:00:00.000Z',
+ 'Andy',
+ 50,
+ );
+ expect(messages).toHaveLength(10);
+ });
+});
+
// --- RegisteredGroup isMain round-trip ---
describe('registered group isMain', () => {
diff --git a/src/db.ts b/src/db.ts
index be1f605..0896f41 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -306,24 +306,29 @@ export function getNewMessages(
jids: string[],
lastTimestamp: string,
botPrefix: string,
+ limit: number = 200,
): { messages: NewMessage[]; newTimestamp: string } {
if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp };
const placeholders = jids.map(() => '?').join(',');
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
+ // Subquery takes the N most recent, outer query re-sorts chronologically.
const sql = `
- SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
- FROM messages
- WHERE timestamp > ? AND chat_jid IN (${placeholders})
- AND is_bot_message = 0 AND content NOT LIKE ?
- AND content != '' AND content IS NOT NULL
- ORDER BY timestamp
+ SELECT * FROM (
+ SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
+ FROM messages
+ WHERE timestamp > ? AND chat_jid IN (${placeholders})
+ AND is_bot_message = 0 AND content NOT LIKE ?
+ AND content != '' AND content IS NOT NULL
+ ORDER BY timestamp DESC
+ LIMIT ?
+ ) ORDER BY timestamp
`;
const rows = db
.prepare(sql)
- .all(lastTimestamp, ...jids, `${botPrefix}:%`) as NewMessage[];
+ .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[];
let newTimestamp = lastTimestamp;
for (const row of rows) {
@@ -337,20 +342,25 @@ export function getMessagesSince(
chatJid: string,
sinceTimestamp: string,
botPrefix: string,
+ limit: number = 200,
): NewMessage[] {
// Filter bot messages using both the is_bot_message flag AND the content
// prefix as a backstop for messages written before the migration ran.
+ // Subquery takes the N most recent, outer query re-sorts chronologically.
const sql = `
- SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
- FROM messages
- WHERE chat_jid = ? AND timestamp > ?
- AND is_bot_message = 0 AND content NOT LIKE ?
- AND content != '' AND content IS NOT NULL
- ORDER BY timestamp
+ SELECT * FROM (
+ SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me
+ FROM messages
+ WHERE chat_jid = ? AND timestamp > ?
+ AND is_bot_message = 0 AND content NOT LIKE ?
+ AND content != '' AND content IS NOT NULL
+ ORDER BY timestamp DESC
+ LIMIT ?
+ ) ORDER BY timestamp
`;
return db
.prepare(sql)
- .all(chatJid, sinceTimestamp, `${botPrefix}:%`) as NewMessage[];
+ .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[];
}
export function createTask(
From cf99b833b0c572ccf1d007be689d2d8d75d911ee Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 16:35:08 +0000
Subject: [PATCH 046/246] chore: bump version to 1.2.10
---
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 dc8c9c7..4e6b681 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
- "version": "1.2.9",
+ "version": "1.2.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
- "version": "1.2.9",
+ "version": "1.2.10",
"dependencies": {
"better-sqlite3": "^11.8.1",
"cron-parser": "^5.5.0",
diff --git a/package.json b/package.json
index 5f77bb0..f4863e4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
- "version": "1.2.9",
+ "version": "1.2.10",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",
From 1e89d619281e0ef60011b678466d9954b1ec3e07 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Fri, 6 Mar 2026 16:35:14 +0000
Subject: [PATCH 047/246] =?UTF-8?q?docs:=20update=20token=20count=20to=203?=
=?UTF-8?q?7.5k=20tokens=20=C2=B7=2019%=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 5942933..182aaa2 100644
--- a/repo-tokens/badge.svg
+++ b/repo-tokens/badge.svg
@@ -1,5 +1,5 @@
-
- 37.4k tokens, 19% of context window
+
+ 37.5k tokens, 19% of context window
@@ -15,8 +15,8 @@
tokens
tokens
- 37.4k
- 37.4k
+ 37.5k
+ 37.5k
From 0b260ece5721f31d756e55abdf3f5b71757c90a9 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Fri, 6 Mar 2026 17:47:12 +0100
Subject: [PATCH 048/246] feat(skills): add pdf-reader skill (#772)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Thanks @glifocat! Clean skill package — good docs, solid tests, nice intent files. Pushed a small fix for path traversal on the PDF filename before merging.
---
.claude/skills/add-pdf-reader/SKILL.md | 100 ++
.../add/container/skills/pdf-reader/SKILL.md | 94 ++
.../container/skills/pdf-reader/pdf-reader | 203 ++++
.claude/skills/add-pdf-reader/manifest.yaml | 17 +
.../modify/container/Dockerfile | 74 ++
.../modify/container/Dockerfile.intent.md | 23 +
.../modify/src/channels/whatsapp.test.ts | 1069 +++++++++++++++++
.../src/channels/whatsapp.test.ts.intent.md | 22 +
.../modify/src/channels/whatsapp.ts | 429 +++++++
.../modify/src/channels/whatsapp.ts.intent.md | 29 +
.../add-pdf-reader/tests/pdf-reader.test.ts | 171 +++
vitest.skills.config.ts | 7 +
12 files changed, 2238 insertions(+)
create mode 100644 .claude/skills/add-pdf-reader/SKILL.md
create mode 100644 .claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md
create mode 100755 .claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader
create mode 100644 .claude/skills/add-pdf-reader/manifest.yaml
create mode 100644 .claude/skills/add-pdf-reader/modify/container/Dockerfile
create mode 100644 .claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md
create mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts
create mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md
create mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts
create mode 100644 .claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md
create mode 100644 .claude/skills/add-pdf-reader/tests/pdf-reader.test.ts
create mode 100644 vitest.skills.config.ts
diff --git a/.claude/skills/add-pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/SKILL.md
new file mode 100644
index 0000000..a394125
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/SKILL.md
@@ -0,0 +1,100 @@
+---
+name: add-pdf-reader
+description: Add PDF reading to NanoClaw agents. Extracts text from PDFs via pdftotext CLI. Handles WhatsApp attachments, URLs, and local files.
+---
+
+# Add PDF Reader
+
+Adds PDF reading capability to all container agents using poppler-utils (pdftotext/pdfinfo). PDFs sent as WhatsApp attachments are auto-downloaded to the group workspace.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `add-pdf-reader` is in `applied_skills`, skip to Phase 3 (Verify).
+
+## Phase 2: Apply Code Changes
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/add-pdf-reader
+```
+
+This deterministically:
+- Adds `container/skills/pdf-reader/SKILL.md` (agent-facing documentation)
+- Adds `container/skills/pdf-reader/pdf-reader` (CLI script)
+- Three-way merges `poppler-utils` + COPY into `container/Dockerfile`
+- Three-way merges PDF attachment download into `src/channels/whatsapp.ts`
+- Three-way merges PDF tests into `src/channels/whatsapp.test.ts`
+- Records application in `.nanoclaw/state.yaml`
+
+If merge conflicts occur, read the intent files:
+- `modify/container/Dockerfile.intent.md`
+- `modify/src/channels/whatsapp.ts.intent.md`
+- `modify/src/channels/whatsapp.test.ts.intent.md`
+
+### Validate
+
+```bash
+npm test
+npm run build
+```
+
+### Rebuild container
+
+```bash
+./container/build.sh
+```
+
+### Restart service
+
+```bash
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS
+# Linux: systemctl --user restart nanoclaw
+```
+
+## Phase 3: Verify
+
+### Test PDF extraction
+
+Send a PDF file in any registered WhatsApp chat. The agent should:
+1. Download the PDF to `attachments/`
+2. Respond acknowledging the PDF
+3. Be able to extract text when asked
+
+### Test URL fetching
+
+Ask the agent to read a PDF from a URL. It should use `pdf-reader fetch `.
+
+### Check logs if needed
+
+```bash
+tail -f logs/nanoclaw.log | grep -i pdf
+```
+
+Look for:
+- `Downloaded PDF attachment` — successful download
+- `Failed to download PDF attachment` — media download issue
+
+## Troubleshooting
+
+### Agent says pdf-reader command not found
+
+Container needs rebuilding. Run `./container/build.sh` and restart the service.
+
+### PDF text extraction is empty
+
+The PDF may be scanned (image-based). pdftotext only handles text-based PDFs. Consider using the agent-browser to open the PDF visually instead.
+
+### WhatsApp PDF not detected
+
+Verify the message has `documentMessage` with `mimetype: application/pdf`. Some file-sharing apps send PDFs as generic files without the correct mimetype.
diff --git a/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md
new file mode 100644
index 0000000..01fe2ca
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/SKILL.md
@@ -0,0 +1,94 @@
+---
+name: pdf-reader
+description: Read and extract text from PDF files — documents, reports, contracts, spreadsheets. Use whenever you need to read PDF content, not just when explicitly asked. Handles local files, URLs, and WhatsApp attachments.
+allowed-tools: Bash(pdf-reader:*)
+---
+
+# PDF Reader
+
+## Quick start
+
+```bash
+pdf-reader extract report.pdf # Extract all text
+pdf-reader extract report.pdf --layout # Preserve tables/columns
+pdf-reader fetch https://example.com/doc.pdf # Download and extract
+pdf-reader info report.pdf # Show metadata + size
+pdf-reader list # List all PDFs in directory tree
+```
+
+## Commands
+
+### extract — Extract text from PDF
+
+```bash
+pdf-reader extract # Full text to stdout
+pdf-reader extract --layout # Preserve layout (tables, columns)
+pdf-reader extract --pages 1-5 # Pages 1 through 5
+pdf-reader extract --pages 3-3 # Single page (page 3)
+pdf-reader extract --layout --pages 2-10 # Layout + page range
+```
+
+Options:
+- `--layout` — Maintains spatial positioning. Essential for tables, spreadsheets, multi-column docs.
+- `--pages N-M` — Extract only pages N through M (1-based, inclusive).
+
+### fetch — Download and extract PDF from URL
+
+```bash
+pdf-reader fetch # Download, verify, extract with layout
+pdf-reader fetch report.pdf # Also save a local copy
+```
+
+Downloads the PDF, verifies it has a valid `%PDF` header, then extracts text with layout preservation. Temporary files are cleaned up automatically.
+
+### info — PDF metadata and file size
+
+```bash
+pdf-reader info
+```
+
+Shows title, author, page count, page size, PDF version, and file size on disk.
+
+### list — Find all PDFs in directory tree
+
+```bash
+pdf-reader list
+```
+
+Recursively lists all `.pdf` files with page count and file size.
+
+## WhatsApp PDF attachments
+
+When a user sends a PDF on WhatsApp, it is automatically saved to the `attachments/` directory. The message will include a path hint like:
+
+> [PDF attached: attachments/document.pdf]
+
+To read the attached PDF:
+
+```bash
+pdf-reader extract attachments/document.pdf --layout
+```
+
+## Example workflows
+
+### Read a contract and summarize key terms
+
+```bash
+pdf-reader info attachments/contract.pdf
+pdf-reader extract attachments/contract.pdf --layout
+```
+
+### Extract specific pages from a long report
+
+```bash
+pdf-reader info report.pdf # Check total pages
+pdf-reader extract report.pdf --pages 1-3 # Executive summary
+pdf-reader extract report.pdf --pages 15-20 # Financial tables
+```
+
+### Fetch and analyze a public document
+
+```bash
+pdf-reader fetch https://example.com/annual-report.pdf report.pdf
+pdf-reader info report.pdf
+```
diff --git a/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader
new file mode 100755
index 0000000..be413c2
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/add/container/skills/pdf-reader/pdf-reader
@@ -0,0 +1,203 @@
+#!/bin/bash
+set -euo pipefail
+
+# pdf-reader — CLI wrapper around poppler-utils (pdftotext, pdfinfo)
+# Provides extract, fetch, info, list commands for PDF processing.
+
+VERSION="1.0.0"
+
+usage() {
+ cat <<'USAGE'
+pdf-reader — Extract text and metadata from PDF files
+
+Usage:
+ pdf-reader extract [--layout] [--pages N-M]
+ pdf-reader fetch [filename]
+ pdf-reader info
+ pdf-reader list
+ pdf-reader help
+
+Commands:
+ extract Extract text from a PDF file to stdout
+ fetch Download a PDF from a URL and extract text
+ info Show PDF metadata and file size
+ list List all PDFs in current directory tree
+ help Show this help message
+
+Extract options:
+ --layout Preserve original layout (tables, columns)
+ --pages Page range to extract (e.g. 1-5, 3-3 for single page)
+USAGE
+}
+
+cmd_extract() {
+ local file=""
+ local layout=false
+ local first_page=""
+ local last_page=""
+
+ # Parse arguments
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --layout)
+ layout=true
+ shift
+ ;;
+ --pages)
+ if [[ -z "${2:-}" ]]; then
+ echo "Error: --pages requires a range argument (e.g. 1-5)" >&2
+ exit 1
+ fi
+ local range="$2"
+ first_page="${range%-*}"
+ last_page="${range#*-}"
+ shift 2
+ ;;
+ -*)
+ echo "Error: Unknown option: $1" >&2
+ exit 1
+ ;;
+ *)
+ if [[ -z "$file" ]]; then
+ file="$1"
+ else
+ echo "Error: Unexpected argument: $1" >&2
+ exit 1
+ fi
+ shift
+ ;;
+ esac
+ done
+
+ if [[ -z "$file" ]]; then
+ echo "Error: No file specified" >&2
+ echo "Usage: pdf-reader extract [--layout] [--pages N-M]" >&2
+ exit 1
+ fi
+
+ if [[ ! -f "$file" ]]; then
+ echo "Error: File not found: $file" >&2
+ exit 1
+ fi
+
+ # Build pdftotext arguments
+ local args=()
+ if [[ "$layout" == true ]]; then
+ args+=(-layout)
+ fi
+ if [[ -n "$first_page" ]]; then
+ args+=(-f "$first_page")
+ fi
+ if [[ -n "$last_page" ]]; then
+ args+=(-l "$last_page")
+ fi
+
+ pdftotext ${args[@]+"${args[@]}"} "$file" -
+}
+
+cmd_fetch() {
+ local url="${1:-}"
+ local filename="${2:-}"
+
+ if [[ -z "$url" ]]; then
+ echo "Error: No URL specified" >&2
+ echo "Usage: pdf-reader fetch [filename]" >&2
+ exit 1
+ fi
+
+ # Create temporary file
+ local tmpfile
+ tmpfile="$(mktemp /tmp/pdf-reader-XXXXXX.pdf)"
+ trap 'rm -f "$tmpfile"' EXIT
+
+ # Download
+ echo "Downloading: $url" >&2
+ if ! curl -sL -o "$tmpfile" "$url"; then
+ echo "Error: Failed to download: $url" >&2
+ exit 1
+ fi
+
+ # Verify PDF header
+ local header
+ header="$(head -c 4 "$tmpfile")"
+ if [[ "$header" != "%PDF" ]]; then
+ echo "Error: Downloaded file is not a valid PDF (header: $header)" >&2
+ exit 1
+ fi
+
+ # Save with name if requested
+ if [[ -n "$filename" ]]; then
+ cp "$tmpfile" "$filename"
+ echo "Saved to: $filename" >&2
+ fi
+
+ # Extract with layout
+ pdftotext -layout "$tmpfile" -
+}
+
+cmd_info() {
+ local file="${1:-}"
+
+ if [[ -z "$file" ]]; then
+ echo "Error: No file specified" >&2
+ echo "Usage: pdf-reader info " >&2
+ exit 1
+ fi
+
+ if [[ ! -f "$file" ]]; then
+ echo "Error: File not found: $file" >&2
+ exit 1
+ fi
+
+ pdfinfo "$file"
+ echo ""
+ echo "File size: $(du -h "$file" | cut -f1)"
+}
+
+cmd_list() {
+ local found=false
+
+ # Use globbing to find PDFs (globstar makes **/ match recursively)
+ shopt -s nullglob globstar
+
+ # Use associative array to deduplicate (*.pdf overlaps with **/*.pdf)
+ declare -A seen
+ for pdf in *.pdf **/*.pdf; do
+ [[ -v seen["$pdf"] ]] && continue
+ seen["$pdf"]=1
+ found=true
+
+ local pages="?"
+ local size
+ size="$(du -h "$pdf" | cut -f1)"
+
+ # Try to get page count from pdfinfo
+ if page_line="$(pdfinfo "$pdf" 2>/dev/null | grep '^Pages:')"; then
+ pages="$(echo "$page_line" | awk '{print $2}')"
+ fi
+
+ printf "%-60s %5s pages %8s\n" "$pdf" "$pages" "$size"
+ done
+
+ if [[ "$found" == false ]]; then
+ echo "No PDF files found in current directory tree." >&2
+ fi
+}
+
+# Main dispatch
+command="${1:-help}"
+shift || true
+
+case "$command" in
+ extract) cmd_extract "$@" ;;
+ fetch) cmd_fetch "$@" ;;
+ info) cmd_info "$@" ;;
+ list) cmd_list ;;
+ help|--help|-h) usage ;;
+ version|--version|-v) echo "pdf-reader $VERSION" ;;
+ *)
+ echo "Error: Unknown command: $command" >&2
+ echo "Run 'pdf-reader help' for usage." >&2
+ exit 1
+ ;;
+esac
diff --git a/.claude/skills/add-pdf-reader/manifest.yaml b/.claude/skills/add-pdf-reader/manifest.yaml
new file mode 100644
index 0000000..83bf114
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/manifest.yaml
@@ -0,0 +1,17 @@
+skill: add-pdf-reader
+version: 1.1.0
+description: "Add PDF reading capability to container agents via pdftotext CLI"
+core_version: 1.2.8
+adds:
+ - container/skills/pdf-reader/SKILL.md
+ - container/skills/pdf-reader/pdf-reader
+modifies:
+ - container/Dockerfile
+ - src/channels/whatsapp.ts
+ - src/channels/whatsapp.test.ts
+structured:
+ npm_dependencies: {}
+ env_additions: []
+conflicts: []
+depends: []
+test: "npx vitest run --config vitest.skills.config.ts .claude/skills/add-pdf-reader/tests/pdf-reader.test.ts"
diff --git a/.claude/skills/add-pdf-reader/modify/container/Dockerfile b/.claude/skills/add-pdf-reader/modify/container/Dockerfile
new file mode 100644
index 0000000..0654503
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/container/Dockerfile
@@ -0,0 +1,74 @@
+# NanoClaw Agent Container
+# Runs Claude Agent SDK in isolated Linux VM with browser automation
+
+FROM node:22-slim
+
+# Install system dependencies for Chromium and PDF tools
+RUN apt-get update && apt-get install -y \
+ chromium \
+ fonts-liberation \
+ fonts-noto-cjk \
+ fonts-noto-color-emoji \
+ libgbm1 \
+ libnss3 \
+ libatk-bridge2.0-0 \
+ libgtk-3-0 \
+ libx11-xcb1 \
+ libxcomposite1 \
+ libxdamage1 \
+ libxrandr2 \
+ libasound2 \
+ libpangocairo-1.0-0 \
+ libcups2 \
+ libdrm2 \
+ libxshmfence1 \
+ curl \
+ git \
+ poppler-utils \
+ && rm -rf /var/lib/apt/lists/*
+
+# Set Chromium path for agent-browser
+ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium
+ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
+
+# Install agent-browser and claude-code globally
+RUN npm install -g agent-browser @anthropic-ai/claude-code
+
+# Create app directory
+WORKDIR /app
+
+# Copy package files first for better caching
+COPY agent-runner/package*.json ./
+
+# Install dependencies
+RUN npm install
+
+# Copy source code
+COPY agent-runner/ ./
+
+# Build TypeScript
+RUN npm run build
+
+# Install pdf-reader CLI
+COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader
+RUN chmod +x /usr/local/bin/pdf-reader
+
+# Create workspace directories
+RUN mkdir -p /workspace/group /workspace/global /workspace/extra /workspace/ipc/messages /workspace/ipc/tasks /workspace/ipc/input
+
+# Create entrypoint script
+# Secrets are passed via stdin JSON — temp file is deleted immediately after Node reads it
+# Follow-up messages arrive via IPC files in /workspace/ipc/input/
+RUN printf '#!/bin/bash\nset -e\ncd /app && npx tsc --outDir /tmp/dist 2>&1 >&2\nln -s /app/node_modules /tmp/dist/node_modules\nchmod -R a-w /tmp/dist\ncat > /tmp/input.json\nnode /tmp/dist/index.js < /tmp/input.json\n' > /app/entrypoint.sh && chmod +x /app/entrypoint.sh
+
+# Set ownership to node user (non-root) for writable directories
+RUN chown -R node:node /workspace && chmod 777 /home/node
+
+# Switch to non-root user (required for --dangerously-skip-permissions)
+USER node
+
+# Set working directory to group workspace
+WORKDIR /workspace/group
+
+# Entry point reads JSON from stdin, outputs JSON to stdout
+ENTRYPOINT ["/app/entrypoint.sh"]
diff --git a/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md b/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md
new file mode 100644
index 0000000..c20958d
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/container/Dockerfile.intent.md
@@ -0,0 +1,23 @@
+# Intent: container/Dockerfile modifications
+
+## What changed
+Added PDF reading capability via poppler-utils and a custom pdf-reader CLI script.
+
+## Key sections
+
+### apt-get install (system dependencies block)
+- Added: `poppler-utils` to the package list (provides pdftotext, pdfinfo, pdftohtml)
+- Changed: Comment updated to mention PDF tools
+
+### After npm global installs
+- Added: `COPY skills/pdf-reader/pdf-reader /usr/local/bin/pdf-reader` to copy CLI script
+- Added: `RUN chmod +x /usr/local/bin/pdf-reader` to make it executable
+
+## Invariants (must-keep)
+- All Chromium dependencies unchanged
+- agent-browser and claude-code npm global installs unchanged
+- WORKDIR, COPY agent-runner, npm install, npm run build sequence unchanged
+- Workspace directory creation unchanged
+- Entrypoint script unchanged
+- User switching (node user) unchanged
+- ENTRYPOINT unchanged
diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts
new file mode 100644
index 0000000..3e68b85
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts
@@ -0,0 +1,1069 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { EventEmitter } from 'events';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ STORE_DIR: '/tmp/nanoclaw-test-store',
+ ASSISTANT_NAME: 'Andy',
+ ASSISTANT_HAS_OWN_NUMBER: false,
+ GROUPS_DIR: '/tmp/test-groups',
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock db
+vi.mock('../db.js', () => ({
+ getLastGroupSync: vi.fn(() => null),
+ setLastGroupSync: vi.fn(),
+ updateChatName: vi.fn(),
+}));
+
+// Mock fs
+vi.mock('fs', async () => {
+ const actual = await vi.importActual('fs');
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ existsSync: vi.fn(() => true),
+ mkdirSync: vi.fn(),
+ writeFileSync: vi.fn(),
+ },
+ };
+});
+
+// Mock child_process (used for osascript notification)
+vi.mock('child_process', () => ({
+ exec: vi.fn(),
+}));
+
+// Build a fake WASocket that's an EventEmitter with the methods we need
+function createFakeSocket() {
+ const ev = new EventEmitter();
+ const sock = {
+ ev: {
+ on: (event: string, handler: (...args: unknown[]) => void) => {
+ ev.on(event, handler);
+ },
+ },
+ user: {
+ id: '1234567890:1@s.whatsapp.net',
+ lid: '9876543210:1@lid',
+ },
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
+ groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
+ updateMediaMessage: vi.fn(),
+ end: vi.fn(),
+ // Expose the event emitter for triggering events in tests
+ _ev: ev,
+ };
+ return sock;
+}
+
+let fakeSocket: ReturnType;
+
+// Mock Baileys
+vi.mock('@whiskeysockets/baileys', () => {
+ return {
+ default: vi.fn(() => fakeSocket),
+ Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
+ DisconnectReason: {
+ loggedOut: 401,
+ badSession: 500,
+ connectionClosed: 428,
+ connectionLost: 408,
+ connectionReplaced: 440,
+ timedOut: 408,
+ restartRequired: 515,
+ },
+ downloadMediaMessage: vi
+ .fn()
+ .mockResolvedValue(Buffer.from('pdf-data')),
+ fetchLatestWaWebVersion: vi
+ .fn()
+ .mockResolvedValue({ version: [2, 3000, 0] }),
+ normalizeMessageContent: vi.fn((content: unknown) => content),
+ makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
+ useMultiFileAuthState: vi.fn().mockResolvedValue({
+ state: {
+ creds: {},
+ keys: {},
+ },
+ saveCreds: vi.fn(),
+ }),
+ };
+});
+
+import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
+import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
+import { downloadMediaMessage } from '@whiskeysockets/baileys';
+
+// --- Test helpers ---
+
+function createTestOpts(
+ overrides?: Partial,
+): WhatsAppChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'registered@g.us': {
+ name: 'Test Group',
+ folder: 'test-group',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function triggerConnection(state: string, extra?: Record) {
+ fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
+}
+
+function triggerDisconnect(statusCode: number) {
+ fakeSocket._ev.emit('connection.update', {
+ connection: 'close',
+ lastDisconnect: {
+ error: { output: { statusCode } },
+ },
+ });
+}
+
+async function triggerMessages(messages: unknown[]) {
+ fakeSocket._ev.emit('messages.upsert', { messages });
+ // Flush microtasks so the async messages.upsert handler completes
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+// --- Tests ---
+
+describe('WhatsAppChannel', () => {
+ beforeEach(() => {
+ fakeSocket = createFakeSocket();
+ vi.mocked(getLastGroupSync).mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ /**
+ * Helper: start connect, flush microtasks so event handlers are registered,
+ * then trigger the connection open event. Returns the resolved promise.
+ */
+ async function connectChannel(channel: WhatsAppChannel): Promise {
+ const p = channel.connect();
+ // Flush microtasks so connectInternal completes its await and registers handlers
+ await new Promise((r) => setTimeout(r, 0));
+ triggerConnection('open');
+ return p;
+ }
+
+ // --- Version fetch ---
+
+ describe('version fetch', () => {
+ it('connects with fetched version', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+ await connectChannel(channel);
+
+ const { fetchLatestWaWebVersion } =
+ await import('@whiskeysockets/baileys');
+ expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({});
+ });
+
+ it('falls back gracefully when version fetch fails', async () => {
+ const { fetchLatestWaWebVersion } =
+ await import('@whiskeysockets/baileys');
+ vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce(
+ new Error('network error'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+ await connectChannel(channel);
+
+ // Should still connect successfully despite fetch failure
+ expect(channel.isConnected()).toBe(true);
+ });
+ });
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when connection opens', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('sets up LID to phone mapping on open', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The channel should have mapped the LID from sock.user
+ // We can verify by sending a message from a LID JID
+ // and checking the translated JID in the callback
+ });
+
+ it('flushes outgoing queue on reconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect
+ (channel as any).connected = false;
+
+ // Queue a message while disconnected
+ await channel.sendMessage('test@g.us', 'Queued message');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+
+ // Reconnect
+ (channel as any).connected = true;
+ await (channel as any).flushOutgoingQueue();
+
+ // Group messages get prefixed when flushed
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
+ text: 'Andy: Queued message',
+ });
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ expect(fakeSocket.end).toHaveBeenCalled();
+ });
+ });
+
+ // --- QR code and auth ---
+
+ describe('authentication', () => {
+ it('exits process when QR code is emitted (no auth state)', async () => {
+ vi.useFakeTimers();
+ const mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Start connect but don't await (it won't resolve - process exits)
+ channel.connect().catch(() => {});
+
+ // Flush microtasks so connectInternal registers handlers
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Emit QR code event
+ fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
+
+ // Advance timer past the 1000ms setTimeout before exit
+ await vi.advanceTimersByTimeAsync(1500);
+
+ expect(mockExit).toHaveBeenCalledWith(1);
+ mockExit.mockRestore();
+ vi.useRealTimers();
+ });
+ });
+
+ // --- Reconnection behavior ---
+
+ describe('reconnection', () => {
+ it('reconnects on non-loggedOut disconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+
+ // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
+ triggerDisconnect(428);
+
+ expect(channel.isConnected()).toBe(false);
+ // The channel should attempt to reconnect (calls connectInternal again)
+ });
+
+ it('exits on loggedOut disconnect', async () => {
+ const mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with loggedOut reason (401)
+ triggerDisconnect(401);
+
+ expect(channel.isConnected()).toBe(false);
+ expect(mockExit).toHaveBeenCalledWith(0);
+ mockExit.mockRestore();
+ });
+
+ it('retries reconnection after 5s on failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with stream error 515
+ triggerDisconnect(515);
+
+ // The channel sets a 5s retry — just verify it doesn't crash
+ await new Promise((r) => setTimeout(r, 100));
+ });
+ });
+
+ // --- Message handling ---
+
+ describe('message handling', () => {
+ it('delivers message for registered group', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-1',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello Andy' },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ id: 'msg-1',
+ content: 'Hello Andy',
+ sender_name: 'Alice',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered groups', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-2',
+ remoteJid: 'unregistered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello' },
+ pushName: 'Bob',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'unregistered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores status@broadcast messages', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-3',
+ remoteJid: 'status@broadcast',
+ fromMe: false,
+ },
+ message: { conversation: 'Status update' },
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores messages with no content', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-4',
+ remoteJid: 'registered@g.us',
+ fromMe: false,
+ },
+ message: null,
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('extracts text from extendedTextMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-5',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ extendedTextMessage: { text: 'A reply message' },
+ },
+ pushName: 'Charlie',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'A reply message' }),
+ );
+ });
+
+ it('extracts caption from imageMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-6',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ caption: 'Check this photo',
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Diana',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Check this photo' }),
+ );
+ });
+
+ it('extracts caption from videoMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-7',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
+ },
+ pushName: 'Eve',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Watch this' }),
+ );
+ });
+
+ it('handles message with no extractable text (e.g. voice note without caption)', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-8',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
+ },
+ pushName: 'Frank',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Skipped — no text content to process
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('uses sender JID when pushName is absent', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-9',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'No push name' },
+ // pushName is undefined
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ sender_name: '5551234' }),
+ );
+ });
+
+ it('downloads and injects PDF attachment path', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-pdf',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ documentMessage: {
+ mimetype: 'application/pdf',
+ fileName: 'report.pdf',
+ },
+ },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(downloadMediaMessage).toHaveBeenCalled();
+
+ const fs = await import('fs');
+ expect(fs.default.writeFileSync).toHaveBeenCalled();
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: expect.stringContaining('[PDF: attachments/report.pdf'),
+ }),
+ );
+ });
+
+ it('preserves document caption alongside PDF info', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-pdf-caption',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ documentMessage: {
+ mimetype: 'application/pdf',
+ fileName: 'report.pdf',
+ caption: 'Here is the monthly report',
+ },
+ },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: expect.stringContaining('Here is the monthly report'),
+ }),
+ );
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: expect.stringContaining('[PDF: attachments/report.pdf'),
+ }),
+ );
+ });
+
+ it('handles PDF download failure gracefully', async () => {
+ vi.mocked(downloadMediaMessage).mockRejectedValueOnce(
+ new Error('Download failed'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-pdf-fail',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ documentMessage: {
+ mimetype: 'application/pdf',
+ fileName: 'report.pdf',
+ },
+ },
+ pushName: 'Bob',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Message skipped since content remains empty after failed download
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+ });
+
+ // --- LID ↔ JID translation ---
+
+ describe('LID to JID translation', () => {
+ it('translates known LID to phone JID', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ '1234567890@s.whatsapp.net': {
+ name: 'Self Chat',
+ folder: 'self-chat',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
+ // Send a message from the LID
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-lid',
+ remoteJid: '9876543210@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'From LID' },
+ pushName: 'Self',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Should be translated to phone JID
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '1234567890@s.whatsapp.net',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+
+ it('passes through non-LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-normal',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Normal JID' },
+ pushName: 'Grace',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ });
+
+ it('passes through unknown LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-unknown-lid',
+ remoteJid: '0000000000@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'Unknown LID' },
+ pushName: 'Unknown',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Unknown LID passes through unchanged
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '0000000000@lid',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+ });
+
+ // --- Outgoing message queue ---
+
+ describe('outgoing message queue', () => {
+ it('sends message directly when connected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('test@g.us', 'Hello');
+ // Group messages get prefixed with assistant name
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
+ text: 'Andy: Hello',
+ });
+ });
+
+ it('prefixes direct chat messages on shared number', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('123@s.whatsapp.net', 'Hello');
+ // Shared number: DMs also get prefixed (needed for self-chat distinction)
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
+ '123@s.whatsapp.net',
+ { text: 'Andy: Hello' },
+ );
+ });
+
+ it('queues message when disconnected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Don't connect — channel starts disconnected
+ await channel.sendMessage('test@g.us', 'Queued');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('queues message on send failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Make sendMessage fail
+ fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
+
+ await channel.sendMessage('test@g.us', 'Will fail');
+
+ // Should not throw, message queued for retry
+ // The queue should have the message
+ });
+
+ it('flushes multiple queued messages in order', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Queue messages while disconnected
+ await channel.sendMessage('test@g.us', 'First');
+ await channel.sendMessage('test@g.us', 'Second');
+ await channel.sendMessage('test@g.us', 'Third');
+
+ // Connect — flush happens automatically on open
+ await connectChannel(channel);
+
+ // Give the async flush time to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
+ // Group messages get prefixed
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', {
+ text: 'Andy: First',
+ });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', {
+ text: 'Andy: Second',
+ });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', {
+ text: 'Andy: Third',
+ });
+ });
+ });
+
+ // --- Group metadata sync ---
+
+ describe('group metadata sync', () => {
+ it('syncs group metadata on first connection', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Group One' },
+ 'group2@g.us': { subject: 'Group Two' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Wait for async sync to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
+ expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
+ expect(setLastGroupSync).toHaveBeenCalled();
+ });
+
+ it('skips sync when synced recently', async () => {
+ // Last sync was 1 hour ago (within 24h threshold)
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
+ });
+
+ it('forces sync regardless of cache', async () => {
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group@g.us': { subject: 'Forced Group' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.syncGroupMetadata(true);
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
+ });
+
+ it('handles group sync failure gracefully', async () => {
+ fakeSocket.groupFetchAllParticipating.mockRejectedValue(
+ new Error('Network timeout'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Should not throw
+ await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
+ });
+
+ it('skips groups with no subject', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Has Subject' },
+ 'group2@g.us': { subject: '' },
+ 'group3@g.us': {},
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Clear any calls from the automatic sync on connect
+ vi.mocked(updateChatName).mockClear();
+
+ await channel.syncGroupMetadata(true);
+
+ expect(updateChatName).toHaveBeenCalledTimes(1);
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
+ });
+ });
+
+ // --- JID ownership ---
+
+ describe('ownsJid', () => {
+ it('owns @g.us JIDs (WhatsApp groups)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(true);
+ });
+
+ it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('tg:12345')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- Typing indicator ---
+
+ describe('setTyping', () => {
+ it('sends composing presence when typing', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', true);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
+ 'composing',
+ 'test@g.us',
+ );
+ });
+
+ it('sends paused presence when stopping', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', false);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
+ 'paused',
+ 'test@g.us',
+ );
+ });
+
+ it('handles typing indicator failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
+
+ // Should not throw
+ await expect(
+ channel.setTyping('test@g.us', true),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "whatsapp"', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.name).toBe('whatsapp');
+ });
+
+ it('does not expose prefixAssistantName (prefix handled internally)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect('prefixAssistantName' in channel).toBe(false);
+ });
+ });
+});
diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md
new file mode 100644
index 0000000..c7302f6
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.test.ts.intent.md
@@ -0,0 +1,22 @@
+# Intent: src/channels/whatsapp.test.ts modifications
+
+## What changed
+Added mocks for downloadMediaMessage and normalizeMessageContent, and test cases for PDF attachment handling.
+
+## Key sections
+
+### Mocks (top of file)
+- Modified: config mock to export `GROUPS_DIR: '/tmp/test-groups'`
+- Modified: `fs` mock to include `writeFileSync` as vi.fn()
+- Modified: Baileys mock to export `downloadMediaMessage`, `normalizeMessageContent`
+- Modified: fake socket factory to include `updateMediaMessage`
+
+### Test cases (inside "message handling" describe block)
+- "downloads and injects PDF attachment path" — verifies PDF download, save, and content replacement
+- "handles PDF download failure gracefully" — verifies error handling (message skipped since content remains empty)
+
+## Invariants (must-keep)
+- All existing test cases unchanged
+- All existing mocks unchanged (only additive changes)
+- All existing test helpers unchanged
+- All describe blocks preserved
diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts
new file mode 100644
index 0000000..a5f8138
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts
@@ -0,0 +1,429 @@
+import { exec } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import makeWASocket, {
+ Browsers,
+ DisconnectReason,
+ downloadMediaMessage,
+ WASocket,
+ fetchLatestWaWebVersion,
+ makeCacheableSignalKeyStore,
+ normalizeMessageContent,
+ useMultiFileAuthState,
+} from '@whiskeysockets/baileys';
+
+import {
+ ASSISTANT_HAS_OWN_NUMBER,
+ ASSISTANT_NAME,
+ GROUPS_DIR,
+ STORE_DIR,
+} from '../config.js';
+import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+import { registerChannel, ChannelOpts } from './registry.js';
+
+const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+export interface WhatsAppChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class WhatsAppChannel implements Channel {
+ name = 'whatsapp';
+
+ private sock!: WASocket;
+ private connected = false;
+ private lidToPhoneMap: Record = {};
+ private outgoingQueue: Array<{ jid: string; text: string }> = [];
+ private flushing = false;
+ private groupSyncTimerStarted = false;
+
+ private opts: WhatsAppChannelOpts;
+
+ constructor(opts: WhatsAppChannelOpts) {
+ this.opts = opts;
+ }
+
+ async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ this.connectInternal(resolve).catch(reject);
+ });
+ }
+
+ private async connectInternal(onFirstOpen?: () => void): Promise {
+ const authDir = path.join(STORE_DIR, 'auth');
+ fs.mkdirSync(authDir, { recursive: true });
+
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
+
+ const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
+ logger.warn(
+ { err },
+ 'Failed to fetch latest WA Web version, using default',
+ );
+ return { version: undefined };
+ });
+ this.sock = makeWASocket({
+ version,
+ auth: {
+ creds: state.creds,
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
+ },
+ printQRInTerminal: false,
+ logger,
+ browser: Browsers.macOS('Chrome'),
+ });
+
+ this.sock.ev.on('connection.update', (update) => {
+ const { connection, lastDisconnect, qr } = update;
+
+ if (qr) {
+ const msg =
+ 'WhatsApp authentication required. Run /setup in Claude Code.';
+ logger.error(msg);
+ exec(
+ `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
+ );
+ setTimeout(() => process.exit(1), 1000);
+ }
+
+ if (connection === 'close') {
+ this.connected = false;
+ const reason = (
+ lastDisconnect?.error as { output?: { statusCode?: number } }
+ )?.output?.statusCode;
+ const shouldReconnect = reason !== DisconnectReason.loggedOut;
+ logger.info(
+ {
+ reason,
+ shouldReconnect,
+ queuedMessages: this.outgoingQueue.length,
+ },
+ 'Connection closed',
+ );
+
+ if (shouldReconnect) {
+ logger.info('Reconnecting...');
+ this.connectInternal().catch((err) => {
+ logger.error({ err }, 'Failed to reconnect, retrying in 5s');
+ setTimeout(() => {
+ this.connectInternal().catch((err2) => {
+ logger.error({ err: err2 }, 'Reconnection retry failed');
+ });
+ }, 5000);
+ });
+ } else {
+ logger.info('Logged out. Run /setup to re-authenticate.');
+ process.exit(0);
+ }
+ } else if (connection === 'open') {
+ this.connected = true;
+ logger.info('Connected to WhatsApp');
+
+ // Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
+ this.sock.sendPresenceUpdate('available').catch((err) => {
+ logger.warn({ err }, 'Failed to send presence update');
+ });
+
+ // Build LID to phone mapping from auth state for self-chat translation
+ if (this.sock.user) {
+ const phoneUser = this.sock.user.id.split(':')[0];
+ const lidUser = this.sock.user.lid?.split(':')[0];
+ if (lidUser && phoneUser) {
+ this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
+ logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
+ }
+ }
+
+ // Flush any messages queued while disconnected
+ this.flushOutgoingQueue().catch((err) =>
+ logger.error({ err }, 'Failed to flush outgoing queue'),
+ );
+
+ // Sync group metadata on startup (respects 24h cache)
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Initial group sync failed'),
+ );
+ // Set up daily sync timer (only once)
+ if (!this.groupSyncTimerStarted) {
+ this.groupSyncTimerStarted = true;
+ setInterval(() => {
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Periodic group sync failed'),
+ );
+ }, GROUP_SYNC_INTERVAL_MS);
+ }
+
+ // Signal first connection to caller
+ if (onFirstOpen) {
+ onFirstOpen();
+ onFirstOpen = undefined;
+ }
+ }
+ });
+
+ this.sock.ev.on('creds.update', saveCreds);
+
+ this.sock.ev.on('messages.upsert', async ({ messages }) => {
+ for (const msg of messages) {
+ try {
+ if (!msg.message) continue;
+ // Unwrap container types (viewOnceMessageV2, ephemeralMessage,
+ // editedMessage, etc.) so that conversation, extendedTextMessage,
+ // imageMessage, etc. are accessible at the top level.
+ const normalized = normalizeMessageContent(msg.message);
+ if (!normalized) continue;
+ const rawJid = msg.key.remoteJid;
+ if (!rawJid || rawJid === 'status@broadcast') continue;
+
+ // Translate LID JID to phone JID if applicable
+ const chatJid = await this.translateJid(rawJid);
+
+ const timestamp = new Date(
+ Number(msg.messageTimestamp) * 1000,
+ ).toISOString();
+
+ // Always notify about chat metadata for group discovery
+ const isGroup = chatJid.endsWith('@g.us');
+ this.opts.onChatMetadata(
+ chatJid,
+ timestamp,
+ undefined,
+ 'whatsapp',
+ isGroup,
+ );
+
+ // Only deliver full message for registered groups
+ const groups = this.opts.registeredGroups();
+ if (groups[chatJid]) {
+ let content =
+ normalized.conversation ||
+ normalized.extendedTextMessage?.text ||
+ normalized.imageMessage?.caption ||
+ normalized.videoMessage?.caption ||
+ '';
+
+ // PDF attachment handling
+ if (normalized?.documentMessage?.mimetype === 'application/pdf') {
+ try {
+ const buffer = await downloadMediaMessage(msg, 'buffer', {});
+ const groupDir = path.join(GROUPS_DIR, groups[chatJid].folder);
+ const attachDir = path.join(groupDir, 'attachments');
+ fs.mkdirSync(attachDir, { recursive: true });
+ const filename = path.basename(
+ normalized.documentMessage.fileName ||
+ `doc-${Date.now()}.pdf`,
+ );
+ const filePath = path.join(attachDir, filename);
+ fs.writeFileSync(filePath, buffer as Buffer);
+ const sizeKB = Math.round((buffer as Buffer).length / 1024);
+ const pdfRef = `[PDF: attachments/${filename} (${sizeKB}KB)]\nUse: pdf-reader extract attachments/${filename}`;
+ const caption = normalized.documentMessage.caption || '';
+ content = caption ? `${caption}\n\n${pdfRef}` : pdfRef;
+ logger.info(
+ { jid: chatJid, filename },
+ 'Downloaded PDF attachment',
+ );
+ } catch (err) {
+ logger.warn(
+ { err, jid: chatJid },
+ 'Failed to download PDF attachment',
+ );
+ }
+ }
+
+ // Skip protocol messages with no text content (encryption keys, read receipts, etc.)
+ if (!content) continue;
+
+ const sender = msg.key.participant || msg.key.remoteJid || '';
+ const senderName = msg.pushName || sender.split('@')[0];
+
+ const fromMe = msg.key.fromMe || false;
+ // Detect bot messages: with own number, fromMe is reliable
+ // since only the bot sends from that number.
+ // With shared number, bot messages carry the assistant name prefix
+ // (even in DMs/self-chat) so we check for that.
+ const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
+ ? fromMe
+ : content.startsWith(`${ASSISTANT_NAME}:`);
+
+ this.opts.onMessage(chatJid, {
+ id: msg.key.id || '',
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: fromMe,
+ is_bot_message: isBotMessage,
+ });
+ }
+ } catch (err) {
+ logger.error(
+ { err, remoteJid: msg.key?.remoteJid },
+ 'Error processing incoming message',
+ );
+ }
+ }
+ });
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ // Prefix bot messages with assistant name so users know who's speaking.
+ // On a shared number, prefix is also needed in DMs (including self-chat)
+ // to distinguish bot output from user messages.
+ // Skip only when the assistant has its own dedicated phone number.
+ const prefixed = ASSISTANT_HAS_OWN_NUMBER
+ ? text
+ : `${ASSISTANT_NAME}: ${text}`;
+
+ if (!this.connected) {
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.info(
+ { jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
+ 'WA disconnected, message queued',
+ );
+ return;
+ }
+ try {
+ await this.sock.sendMessage(jid, { text: prefixed });
+ logger.info({ jid, length: prefixed.length }, 'Message sent');
+ } catch (err) {
+ // If send fails, queue it for retry on reconnect
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.warn(
+ { jid, err, queueSize: this.outgoingQueue.length },
+ 'Failed to send, message queued',
+ );
+ }
+ }
+
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
+ }
+
+ async disconnect(): Promise {
+ this.connected = false;
+ this.sock?.end(undefined);
+ }
+
+ async setTyping(jid: string, isTyping: boolean): Promise {
+ try {
+ const status = isTyping ? 'composing' : 'paused';
+ logger.debug({ jid, status }, 'Sending presence update');
+ await this.sock.sendPresenceUpdate(status, jid);
+ } catch (err) {
+ logger.debug({ jid, err }, 'Failed to update typing status');
+ }
+ }
+
+ async syncGroups(force: boolean): Promise {
+ return this.syncGroupMetadata(force);
+ }
+
+ /**
+ * Sync group metadata from WhatsApp.
+ * Fetches all participating groups and stores their names in the database.
+ * Called on startup, daily, and on-demand via IPC.
+ */
+ async syncGroupMetadata(force = false): Promise {
+ if (!force) {
+ const lastSync = getLastGroupSync();
+ if (lastSync) {
+ const lastSyncTime = new Date(lastSync).getTime();
+ if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
+ logger.debug({ lastSync }, 'Skipping group sync - synced recently');
+ return;
+ }
+ }
+ }
+
+ try {
+ logger.info('Syncing group metadata from WhatsApp...');
+ const groups = await this.sock.groupFetchAllParticipating();
+
+ let count = 0;
+ for (const [jid, metadata] of Object.entries(groups)) {
+ if (metadata.subject) {
+ updateChatName(jid, metadata.subject);
+ count++;
+ }
+ }
+
+ setLastGroupSync();
+ logger.info({ count }, 'Group metadata synced');
+ } catch (err) {
+ logger.error({ err }, 'Failed to sync group metadata');
+ }
+ }
+
+ private async translateJid(jid: string): Promise {
+ if (!jid.endsWith('@lid')) return jid;
+ const lidUser = jid.split('@')[0].split(':')[0];
+
+ // Check local cache first
+ const cached = this.lidToPhoneMap[lidUser];
+ if (cached) {
+ logger.debug(
+ { lidJid: jid, phoneJid: cached },
+ 'Translated LID to phone JID (cached)',
+ );
+ return cached;
+ }
+
+ // Query Baileys' signal repository for the mapping
+ try {
+ const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
+ if (pn) {
+ const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
+ this.lidToPhoneMap[lidUser] = phoneJid;
+ logger.info(
+ { lidJid: jid, phoneJid },
+ 'Translated LID to phone JID (signalRepository)',
+ );
+ return phoneJid;
+ }
+ } catch (err) {
+ logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
+ }
+
+ return jid;
+ }
+
+ private async flushOutgoingQueue(): Promise {
+ if (this.flushing || this.outgoingQueue.length === 0) return;
+ this.flushing = true;
+ try {
+ logger.info(
+ { count: this.outgoingQueue.length },
+ 'Flushing outgoing message queue',
+ );
+ while (this.outgoingQueue.length > 0) {
+ const item = this.outgoingQueue.shift()!;
+ // Send directly — queued items are already prefixed by sendMessage
+ await this.sock.sendMessage(item.jid, { text: item.text });
+ logger.info(
+ { jid: item.jid, length: item.text.length },
+ 'Queued message sent',
+ );
+ }
+ } finally {
+ this.flushing = false;
+ }
+ }
+}
+
+registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts));
diff --git a/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md
new file mode 100644
index 0000000..112efa2
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/modify/src/channels/whatsapp.ts.intent.md
@@ -0,0 +1,29 @@
+# Intent: src/channels/whatsapp.ts modifications
+
+## What changed
+Added PDF attachment download and path injection. When a WhatsApp message contains a PDF document, it is downloaded to the group's attachments/ directory and the message content is replaced with the file path and a usage hint. Also uses `normalizeMessageContent()` from Baileys to unwrap container types before reading fields.
+
+## Key sections
+
+### Imports (top of file)
+- Added: `downloadMediaMessage` from `@whiskeysockets/baileys`
+- Added: `normalizeMessageContent` from `@whiskeysockets/baileys`
+- Added: `GROUPS_DIR` from `../config.js`
+
+### messages.upsert handler (inside connectInternal)
+- Added: `normalizeMessageContent(msg.message)` call to unwrap container types
+- Changed: `let content` to allow reassignment for PDF messages
+- Added: Check for `normalized.documentMessage?.mimetype === 'application/pdf'`
+- Added: Download PDF via `downloadMediaMessage`, save to `groups/{folder}/attachments/`
+- Added: Replace content with `[PDF: attachments/{filename} ({size}KB)]` and usage hint
+- Note: PDF check is placed BEFORE the `if (!content) continue;` guard so PDF-only messages are not skipped
+
+## Invariants (must-keep)
+- All existing message handling (conversation, extendedTextMessage, imageMessage, videoMessage)
+- Connection lifecycle (connect, reconnect with exponential backoff, disconnect)
+- LID translation logic unchanged
+- Outgoing message queue unchanged
+- Group metadata sync unchanged
+- sendMessage prefix logic unchanged
+- setTyping, ownsJid, isConnected — all unchanged
+- Local timestamp format (no Z suffix)
diff --git a/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts b/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts
new file mode 100644
index 0000000..2d9e961
--- /dev/null
+++ b/.claude/skills/add-pdf-reader/tests/pdf-reader.test.ts
@@ -0,0 +1,171 @@
+import { describe, expect, it } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+describe('pdf-reader skill package', () => {
+ const skillDir = path.resolve(__dirname, '..');
+
+ it('has a valid manifest', () => {
+ const manifestPath = path.join(skillDir, 'manifest.yaml');
+ expect(fs.existsSync(manifestPath)).toBe(true);
+
+ const content = fs.readFileSync(manifestPath, 'utf-8');
+ expect(content).toContain('skill: add-pdf-reader');
+ expect(content).toContain('version: 1.1.0');
+ expect(content).toContain('container/Dockerfile');
+ });
+
+ it('has all files declared in adds', () => {
+ const skillMd = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'SKILL.md');
+ const pdfReaderScript = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'pdf-reader');
+
+ expect(fs.existsSync(skillMd)).toBe(true);
+ expect(fs.existsSync(pdfReaderScript)).toBe(true);
+ });
+
+ it('pdf-reader script is a valid Bash script', () => {
+ const scriptPath = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'pdf-reader');
+ const content = fs.readFileSync(scriptPath, 'utf-8');
+
+ // Valid shell script
+ expect(content).toMatch(/^#!/);
+
+ // Core CLI commands
+ expect(content).toContain('pdftotext');
+ expect(content).toContain('pdfinfo');
+ expect(content).toContain('extract');
+ expect(content).toContain('fetch');
+ expect(content).toContain('info');
+ expect(content).toContain('list');
+
+ // Key options
+ expect(content).toContain('--layout');
+ expect(content).toContain('--pages');
+ });
+
+ it('container skill SKILL.md has correct frontmatter', () => {
+ const skillMdPath = path.join(skillDir, 'add', 'container', 'skills', 'pdf-reader', 'SKILL.md');
+ const content = fs.readFileSync(skillMdPath, 'utf-8');
+
+ expect(content).toContain('name: pdf-reader');
+ expect(content).toContain('allowed-tools: Bash(pdf-reader:*)');
+ expect(content).toContain('pdf-reader extract');
+ expect(content).toContain('pdf-reader fetch');
+ expect(content).toContain('pdf-reader info');
+ });
+
+ it('has all files declared in modifies', () => {
+ const dockerfile = path.join(skillDir, 'modify', 'container', 'Dockerfile');
+ const whatsappTs = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts');
+ const whatsappTestTs = path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts');
+
+ expect(fs.existsSync(dockerfile)).toBe(true);
+ expect(fs.existsSync(whatsappTs)).toBe(true);
+ expect(fs.existsSync(whatsappTestTs)).toBe(true);
+ });
+
+ it('has intent files for all modified files', () => {
+ expect(
+ fs.existsSync(path.join(skillDir, 'modify', 'container', 'Dockerfile.intent.md')),
+ ).toBe(true);
+ expect(
+ fs.existsSync(path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts.intent.md')),
+ ).toBe(true);
+ expect(
+ fs.existsSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts.intent.md'),
+ ),
+ ).toBe(true);
+ });
+
+ it('modified Dockerfile includes poppler-utils and pdf-reader', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'container', 'Dockerfile'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('poppler-utils');
+ expect(content).toContain('pdf-reader');
+ expect(content).toContain('/usr/local/bin/pdf-reader');
+ });
+
+ it('modified Dockerfile preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'container', 'Dockerfile'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('FROM node:22-slim');
+ expect(content).toContain('chromium');
+ expect(content).toContain('agent-browser');
+ expect(content).toContain('WORKDIR /app');
+ expect(content).toContain('COPY agent-runner/');
+ expect(content).toContain('ENTRYPOINT');
+ expect(content).toContain('/workspace/group');
+ expect(content).toContain('USER node');
+ });
+
+ it('modified whatsapp.ts includes PDF attachment handling', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('documentMessage');
+ expect(content).toContain('application/pdf');
+ expect(content).toContain('downloadMediaMessage');
+ expect(content).toContain('attachments');
+ expect(content).toContain('pdf-reader extract');
+ });
+
+ it('modified whatsapp.ts preserves core structure', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.ts'),
+ 'utf-8',
+ );
+
+ // Core class and methods preserved
+ expect(content).toContain('class WhatsAppChannel');
+ expect(content).toContain('implements Channel');
+ expect(content).toContain('async connect()');
+ expect(content).toContain('async sendMessage(');
+ expect(content).toContain('isConnected()');
+ expect(content).toContain('ownsJid(');
+ expect(content).toContain('async disconnect()');
+ expect(content).toContain('async setTyping(');
+
+ // Core imports preserved
+ expect(content).toContain('ASSISTANT_NAME');
+ expect(content).toContain('STORE_DIR');
+ });
+
+ it('modified whatsapp.test.ts includes PDF attachment tests', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
+ 'utf-8',
+ );
+
+ expect(content).toContain('PDF');
+ expect(content).toContain('documentMessage');
+ expect(content).toContain('application/pdf');
+ });
+
+ it('modified whatsapp.test.ts preserves all existing test sections', () => {
+ const content = fs.readFileSync(
+ path.join(skillDir, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
+ 'utf-8',
+ );
+
+ // All existing test describe blocks preserved
+ expect(content).toContain("describe('connection lifecycle'");
+ expect(content).toContain("describe('authentication'");
+ expect(content).toContain("describe('reconnection'");
+ expect(content).toContain("describe('message handling'");
+ expect(content).toContain("describe('LID to JID translation'");
+ expect(content).toContain("describe('outgoing message queue'");
+ expect(content).toContain("describe('group metadata sync'");
+ expect(content).toContain("describe('ownsJid'");
+ expect(content).toContain("describe('setTyping'");
+ expect(content).toContain("describe('channel properties'");
+ });
+});
diff --git a/vitest.skills.config.ts b/vitest.skills.config.ts
new file mode 100644
index 0000000..3be7fcd
--- /dev/null
+++ b/vitest.skills.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ include: ['.claude/skills/**/tests/*.test.ts'],
+ },
+});
From be1991108b213b8b79c9e093d7727aee3a5342e8 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Fri, 6 Mar 2026 17:51:00 +0100
Subject: [PATCH 049/246] fix(whatsapp): write pairing code to file for
immediate access (#745)
The pairing code was only emitted to stdout, which is buffered by the
calling process and not visible until the auth command exits (~120s).
By also writing to store/pairing-code.txt the moment the code is ready,
callers can poll that file and display the code to the user within seconds
instead of after the 60s expiry window.
Update the add-whatsapp skill instructions to use the background +
file-poll pattern instead of waiting on buffered stdout.
Co-authored-by: Claude Sonnet 4.6
---
.claude/skills/add-whatsapp/SKILL.md | 24 ++++++++++++++-----
.../add-whatsapp/add/setup/whatsapp-auth.ts | 10 ++++++++
2 files changed, 28 insertions(+), 6 deletions(-)
diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md
index f572ea0..660123a 100644
--- a/.claude/skills/add-whatsapp/SKILL.md
+++ b/.claude/skills/add-whatsapp/SKILL.md
@@ -115,21 +115,33 @@ Tell the user to run `npm run auth` in another terminal, then:
For pairing code:
+Tell the user to have WhatsApp open on **Settings > Linked Devices > Link a Device**, ready to tap **"Link with phone number instead"** — the code expires in ~60 seconds and must be entered immediately.
+
+Run the auth process in the background and poll `store/pairing-code.txt` for the code:
+
```bash
-npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone
+rm -f store/pairing-code.txt && npx tsx setup/index.ts --step whatsapp-auth -- --method pairing-code --phone > /tmp/wa-auth.log 2>&1 &
```
-(Bash timeout: 150000ms). Display PAIRING_CODE from output.
+Then immediately poll for the code (do NOT wait for the background command to finish):
-Tell the user:
+```bash
+for i in $(seq 1 20); do [ -f store/pairing-code.txt ] && cat store/pairing-code.txt && break; sleep 1; done
+```
-> A pairing code will appear. **Enter it within 60 seconds** — codes expire quickly.
+Display the code to the user the moment it appears. Tell them:
+
+> **Enter this code now** — it expires in ~60 seconds.
>
> 1. Open WhatsApp > **Settings** > **Linked Devices** > **Link a Device**
> 2. Tap **Link with phone number instead**
> 3. Enter the code immediately
->
-> If the code expires, re-run the command — a new code will be generated.
+
+After the user enters the code, poll for authentication to complete:
+
+```bash
+for i in $(seq 1 60); do grep -q 'AUTH_STATUS: authenticated' /tmp/wa-auth.log 2>/dev/null && echo "authenticated" && break; grep -q 'AUTH_STATUS: failed' /tmp/wa-auth.log 2>/dev/null && echo "failed" && break; sleep 2; done
+```
**If failed:** qr_timeout → re-run. logged_out → delete `store/auth/` and re-run. 515 → re-run. timeout → ask user, offer retry.
diff --git a/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts b/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
index 4aa3433..2cbec76 100644
--- a/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
+++ b/.claude/skills/add-whatsapp/add/setup/whatsapp-auth.ts
@@ -306,6 +306,16 @@ async function handlePairingCode(
process.exit(3);
}
+ // Write to file immediately so callers can read it without waiting for stdout
+ try {
+ fs.writeFileSync(
+ path.join(projectRoot, 'store', 'pairing-code.txt'),
+ pairingCode,
+ );
+ } catch {
+ /* non-fatal */
+ }
+
// Emit pairing code immediately so the caller can display it to the user
emitAuthStatus('pairing-code', 'pairing_code_ready', 'waiting', {
PAIRING_CODE: pairingCode,
From af937d6453b51afb077d3797c804cecc4c9799d5 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Fri, 6 Mar 2026 17:52:59 +0100
Subject: [PATCH 050/246] feat(skills): add image vision skill for WhatsApp
(#770)
* chore: prepare image-vision skill for template regeneration
- Delete stale modify/*.ts templates (built against 1.1.2)
- Update core_version to 1.2.6
- Strip fork-specific details from intent docs
Co-Authored-By: Claude Opus 4.6
* feat(skills): regenerate image-vision modify/ templates against upstream
Templates regenerated against upstream 1.2.6:
- src/container-runner.ts: imageAttachments field in ContainerInput
- src/index.ts: parseImageReferences + threading to runAgent
- src/channels/whatsapp.ts: downloadMediaMessage + image handling block
- src/channels/whatsapp.test.ts: image mocks + 4 test cases
- container/agent-runner/src/index.ts: ContentBlock types, pushMultimodal, image loading
Co-Authored-By: Claude Opus 4.6
* test: update image-vision tests for upstream templates
- Relax downloadMediaMessage import pattern check (multi-line import)
- Remove check for [Image - processing failed] (not in upstream template)
- Add vitest.skills.config.ts for skill package test runs
Co-Authored-By: Claude Opus 4.6
* chore: update image-vision core_version to 1.2.8
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
---
.claude/skills/add-image-vision/SKILL.md | 70 ++
.../add-image-vision/add/src/image.test.ts | 89 ++
.../skills/add-image-vision/add/src/image.ts | 63 +
.claude/skills/add-image-vision/manifest.yaml | 20 +
.../container/agent-runner/src/index.ts | 626 +++++++++
.../agent-runner/src/index.ts.intent.md | 23 +
.../modify/src/channels/whatsapp.test.ts | 1117 +++++++++++++++++
.../src/channels/whatsapp.test.ts.intent.md | 21 +
.../modify/src/channels/whatsapp.ts | 419 +++++++
.../modify/src/channels/whatsapp.ts.intent.md | 23 +
.../modify/src/container-runner.ts | 703 +++++++++++
.../modify/src/container-runner.ts.intent.md | 15 +
.../add-image-vision/modify/src/index.ts | 590 +++++++++
.../modify/src/index.ts.intent.md | 24 +
.../tests/image-vision.test.ts | 297 +++++
15 files changed, 4100 insertions(+)
create mode 100644 .claude/skills/add-image-vision/SKILL.md
create mode 100644 .claude/skills/add-image-vision/add/src/image.test.ts
create mode 100644 .claude/skills/add-image-vision/add/src/image.ts
create mode 100644 .claude/skills/add-image-vision/manifest.yaml
create mode 100644 .claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts
create mode 100644 .claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md
create mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts
create mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md
create mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.ts
create mode 100644 .claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md
create mode 100644 .claude/skills/add-image-vision/modify/src/container-runner.ts
create mode 100644 .claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md
create mode 100644 .claude/skills/add-image-vision/modify/src/index.ts
create mode 100644 .claude/skills/add-image-vision/modify/src/index.ts.intent.md
create mode 100644 .claude/skills/add-image-vision/tests/image-vision.test.ts
diff --git a/.claude/skills/add-image-vision/SKILL.md b/.claude/skills/add-image-vision/SKILL.md
new file mode 100644
index 0000000..7ba621e
--- /dev/null
+++ b/.claude/skills/add-image-vision/SKILL.md
@@ -0,0 +1,70 @@
+---
+name: add-image-vision
+description: Add image vision to NanoClaw agents. Resizes and processes WhatsApp image attachments, then sends them to Claude as multimodal content blocks.
+---
+
+# Image Vision Skill
+
+Adds the ability for NanoClaw agents to see and understand images sent via WhatsApp. Images are downloaded, resized with sharp, saved to the group workspace, and passed to the agent as base64-encoded multimodal content blocks.
+
+## Phase 1: Pre-flight
+
+1. Check `.nanoclaw/state.yaml` for `add-image-vision` — skip if already applied
+2. Confirm `sharp` is installable (native bindings require build tools)
+
+## Phase 2: Apply Code Changes
+
+1. Initialize the skills system if not already done:
+ ```bash
+ npx tsx -e "import { initNanoclawDir } from './skills-engine/init.ts'; initNanoclawDir();"
+ ```
+
+2. Apply the skill:
+ ```bash
+ npx tsx skills-engine/apply-skill.ts add-image-vision
+ ```
+
+3. Install new dependency:
+ ```bash
+ npm install sharp
+ ```
+
+4. Validate:
+ ```bash
+ npm run typecheck
+ npm test
+ ```
+
+## Phase 3: Configure
+
+1. Rebuild the container (agent-runner changes need a rebuild):
+ ```bash
+ ./container/build.sh
+ ```
+
+2. Sync agent-runner source to group caches:
+ ```bash
+ for dir in data/sessions/*/agent-runner-src/; do
+ cp container/agent-runner/src/*.ts "$dir"
+ done
+ ```
+
+3. Restart the service:
+ ```bash
+ launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+ ```
+
+## Phase 4: Verify
+
+1. Send an image in a registered WhatsApp group
+2. Check the agent responds with understanding of the image content
+3. Check logs for "Processed image attachment":
+ ```bash
+ tail -50 groups/*/logs/container-*.log
+ ```
+
+## Troubleshooting
+
+- **"Image - download failed"**: Check WhatsApp connection stability. The download may timeout on slow connections.
+- **"Image - processing failed"**: Sharp may not be installed correctly. Run `npm ls sharp` to verify.
+- **Agent doesn't mention image content**: Check container logs for "Loaded image" messages. If missing, ensure agent-runner source was synced to group caches.
diff --git a/.claude/skills/add-image-vision/add/src/image.test.ts b/.claude/skills/add-image-vision/add/src/image.test.ts
new file mode 100644
index 0000000..6164a78
--- /dev/null
+++ b/.claude/skills/add-image-vision/add/src/image.test.ts
@@ -0,0 +1,89 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import fs from 'fs';
+
+// Mock sharp
+vi.mock('sharp', () => {
+ const mockSharp = vi.fn(() => ({
+ resize: vi.fn().mockReturnThis(),
+ jpeg: vi.fn().mockReturnThis(),
+ toBuffer: vi.fn().mockResolvedValue(Buffer.from('resized-image-data')),
+ }));
+ return { default: mockSharp };
+});
+
+vi.mock('fs');
+
+import { processImage, parseImageReferences, isImageMessage } from './image.js';
+
+describe('image processing', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(fs.mkdirSync).mockReturnValue(undefined);
+ vi.mocked(fs.writeFileSync).mockReturnValue(undefined);
+ });
+
+ describe('isImageMessage', () => {
+ it('returns true for image messages', () => {
+ const msg = { message: { imageMessage: { mimetype: 'image/jpeg' } } };
+ expect(isImageMessage(msg as any)).toBe(true);
+ });
+
+ it('returns false for non-image messages', () => {
+ const msg = { message: { conversation: 'hello' } };
+ expect(isImageMessage(msg as any)).toBe(false);
+ });
+
+ it('returns false for null message', () => {
+ const msg = { message: null };
+ expect(isImageMessage(msg as any)).toBe(false);
+ });
+ });
+
+ describe('processImage', () => {
+ it('resizes and saves image, returns content string', async () => {
+ const buffer = Buffer.from('raw-image-data');
+ const result = await processImage(buffer, '/tmp/groups/test', 'Check this out');
+
+ expect(result).not.toBeNull();
+ expect(result!.content).toMatch(/^\[Image: attachments\/img-\d+-[a-z0-9]+\.jpg\] Check this out$/);
+ expect(result!.relativePath).toMatch(/^attachments\/img-\d+-[a-z0-9]+\.jpg$/);
+ expect(fs.mkdirSync).toHaveBeenCalled();
+ expect(fs.writeFileSync).toHaveBeenCalled();
+ });
+
+ it('returns content without caption when none provided', async () => {
+ const buffer = Buffer.from('raw-image-data');
+ const result = await processImage(buffer, '/tmp/groups/test', '');
+
+ expect(result).not.toBeNull();
+ expect(result!.content).toMatch(/^\[Image: attachments\/img-\d+-[a-z0-9]+\.jpg\]$/);
+ });
+
+ it('returns null on empty buffer', async () => {
+ const result = await processImage(Buffer.alloc(0), '/tmp/groups/test', '');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('parseImageReferences', () => {
+ it('extracts image paths from message content', () => {
+ const messages = [
+ { content: '[Image: attachments/img-123.jpg] hello' },
+ { content: 'plain text' },
+ { content: '[Image: attachments/img-456.jpg]' },
+ ];
+ const refs = parseImageReferences(messages as any);
+
+ expect(refs).toEqual([
+ { relativePath: 'attachments/img-123.jpg', mediaType: 'image/jpeg' },
+ { relativePath: 'attachments/img-456.jpg', mediaType: 'image/jpeg' },
+ ]);
+ });
+
+ it('returns empty array when no images', () => {
+ const messages = [{ content: 'just text' }];
+ expect(parseImageReferences(messages as any)).toEqual([]);
+ });
+ });
+});
diff --git a/.claude/skills/add-image-vision/add/src/image.ts b/.claude/skills/add-image-vision/add/src/image.ts
new file mode 100644
index 0000000..574110f
--- /dev/null
+++ b/.claude/skills/add-image-vision/add/src/image.ts
@@ -0,0 +1,63 @@
+import fs from 'fs';
+import path from 'path';
+import sharp from 'sharp';
+import type { WAMessage } from '@whiskeysockets/baileys';
+
+const MAX_DIMENSION = 1024;
+const IMAGE_REF_PATTERN = /\[Image: (attachments\/[^\]]+)\]/g;
+
+export interface ProcessedImage {
+ content: string;
+ relativePath: string;
+}
+
+export interface ImageAttachment {
+ relativePath: string;
+ mediaType: string;
+}
+
+export function isImageMessage(msg: WAMessage): boolean {
+ return !!msg.message?.imageMessage;
+}
+
+export async function processImage(
+ buffer: Buffer,
+ groupDir: string,
+ caption: string,
+): Promise {
+ if (!buffer || buffer.length === 0) return null;
+
+ const resized = await sharp(buffer)
+ .resize(MAX_DIMENSION, MAX_DIMENSION, { fit: 'inside', withoutEnlargement: true })
+ .jpeg({ quality: 85 })
+ .toBuffer();
+
+ const attachDir = path.join(groupDir, 'attachments');
+ fs.mkdirSync(attachDir, { recursive: true });
+
+ const filename = `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.jpg`;
+ const filePath = path.join(attachDir, filename);
+ fs.writeFileSync(filePath, resized);
+
+ const relativePath = `attachments/${filename}`;
+ const content = caption
+ ? `[Image: ${relativePath}] ${caption}`
+ : `[Image: ${relativePath}]`;
+
+ return { content, relativePath };
+}
+
+export function parseImageReferences(
+ messages: Array<{ content: string }>,
+): ImageAttachment[] {
+ const refs: ImageAttachment[] = [];
+ for (const msg of messages) {
+ let match: RegExpExecArray | null;
+ IMAGE_REF_PATTERN.lastIndex = 0;
+ while ((match = IMAGE_REF_PATTERN.exec(msg.content)) !== null) {
+ // Always JPEG — processImage() normalizes all images to .jpg
+ refs.push({ relativePath: match[1], mediaType: 'image/jpeg' });
+ }
+ }
+ return refs;
+}
diff --git a/.claude/skills/add-image-vision/manifest.yaml b/.claude/skills/add-image-vision/manifest.yaml
new file mode 100644
index 0000000..f611011
--- /dev/null
+++ b/.claude/skills/add-image-vision/manifest.yaml
@@ -0,0 +1,20 @@
+skill: add-image-vision
+version: 1.1.0
+description: "Add image vision to NanoClaw agents via WhatsApp image attachments"
+core_version: 1.2.8
+adds:
+ - src/image.ts
+ - src/image.test.ts
+modifies:
+ - src/channels/whatsapp.ts
+ - src/channels/whatsapp.test.ts
+ - src/container-runner.ts
+ - src/index.ts
+ - container/agent-runner/src/index.ts
+structured:
+ npm_dependencies:
+ sharp: "^0.34.5"
+ env_additions: []
+conflicts: []
+depends: []
+test: "npx vitest run --config vitest.skills.config.ts .claude/skills/add-image-vision/tests/image-vision.test.ts"
diff --git a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts
new file mode 100644
index 0000000..c08fc34
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts
@@ -0,0 +1,626 @@
+/**
+ * NanoClaw Agent Runner
+ * Runs inside a container, receives config via stdin, outputs result to stdout
+ *
+ * Input protocol:
+ * Stdin: Full ContainerInput JSON (read until EOF, like before)
+ * IPC: Follow-up messages written as JSON files to /workspace/ipc/input/
+ * Files: {type:"message", text:"..."}.json — polled and consumed
+ * Sentinel: /workspace/ipc/input/_close — signals session end
+ *
+ * Stdout protocol:
+ * Each result is wrapped in OUTPUT_START_MARKER / OUTPUT_END_MARKER pairs.
+ * Multiple results may be emitted (one per agent teams result).
+ * Final marker after loop ends signals completion.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { query, HookCallback, PreCompactHookInput, PreToolUseHookInput } from '@anthropic-ai/claude-agent-sdk';
+import { fileURLToPath } from 'url';
+
+interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+ imageAttachments?: Array<{ relativePath: string; mediaType: string }>;
+}
+
+interface ImageContentBlock {
+ type: 'image';
+ source: { type: 'base64'; media_type: string; data: string };
+}
+interface TextContentBlock {
+ type: 'text';
+ text: string;
+}
+type ContentBlock = ImageContentBlock | TextContentBlock;
+
+interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface SessionEntry {
+ sessionId: string;
+ fullPath: string;
+ summary: string;
+ firstPrompt: string;
+}
+
+interface SessionsIndex {
+ entries: SessionEntry[];
+}
+
+interface SDKUserMessage {
+ type: 'user';
+ message: { role: 'user'; content: string | ContentBlock[] };
+ parent_tool_use_id: null;
+ session_id: string;
+}
+
+const IPC_INPUT_DIR = '/workspace/ipc/input';
+const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close');
+const IPC_POLL_MS = 500;
+
+/**
+ * Push-based async iterable for streaming user messages to the SDK.
+ * Keeps the iterable alive until end() is called, preventing isSingleUserTurn.
+ */
+class MessageStream {
+ private queue: SDKUserMessage[] = [];
+ private waiting: (() => void) | null = null;
+ private done = false;
+
+ push(text: string): void {
+ this.queue.push({
+ type: 'user',
+ message: { role: 'user', content: text },
+ parent_tool_use_id: null,
+ session_id: '',
+ });
+ this.waiting?.();
+ }
+
+ pushMultimodal(content: ContentBlock[]): void {
+ this.queue.push({
+ type: 'user',
+ message: { role: 'user', content },
+ parent_tool_use_id: null,
+ session_id: '',
+ });
+ this.waiting?.();
+ }
+
+ end(): void {
+ this.done = true;
+ this.waiting?.();
+ }
+
+ async *[Symbol.asyncIterator](): AsyncGenerator {
+ while (true) {
+ while (this.queue.length > 0) {
+ yield this.queue.shift()!;
+ }
+ if (this.done) return;
+ await new Promise(r => { this.waiting = r; });
+ this.waiting = null;
+ }
+ }
+}
+
+async function readStdin(): Promise {
+ return new Promise((resolve, reject) => {
+ let data = '';
+ process.stdin.setEncoding('utf8');
+ process.stdin.on('data', chunk => { data += chunk; });
+ process.stdin.on('end', () => resolve(data));
+ process.stdin.on('error', reject);
+ });
+}
+
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+function writeOutput(output: ContainerOutput): void {
+ console.log(OUTPUT_START_MARKER);
+ console.log(JSON.stringify(output));
+ console.log(OUTPUT_END_MARKER);
+}
+
+function log(message: string): void {
+ console.error(`[agent-runner] ${message}`);
+}
+
+function getSessionSummary(sessionId: string, transcriptPath: string): string | null {
+ const projectDir = path.dirname(transcriptPath);
+ const indexPath = path.join(projectDir, 'sessions-index.json');
+
+ if (!fs.existsSync(indexPath)) {
+ log(`Sessions index not found at ${indexPath}`);
+ return null;
+ }
+
+ try {
+ const index: SessionsIndex = JSON.parse(fs.readFileSync(indexPath, 'utf-8'));
+ const entry = index.entries.find(e => e.sessionId === sessionId);
+ if (entry?.summary) {
+ return entry.summary;
+ }
+ } catch (err) {
+ log(`Failed to read sessions index: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ return null;
+}
+
+/**
+ * Archive the full transcript to conversations/ before compaction.
+ */
+function createPreCompactHook(assistantName?: string): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preCompact = input as PreCompactHookInput;
+ const transcriptPath = preCompact.transcript_path;
+ const sessionId = preCompact.session_id;
+
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) {
+ log('No transcript found for archiving');
+ return {};
+ }
+
+ try {
+ const content = fs.readFileSync(transcriptPath, 'utf-8');
+ const messages = parseTranscript(content);
+
+ if (messages.length === 0) {
+ log('No messages to archive');
+ return {};
+ }
+
+ const summary = getSessionSummary(sessionId, transcriptPath);
+ const name = summary ? sanitizeFilename(summary) : generateFallbackName();
+
+ const conversationsDir = '/workspace/group/conversations';
+ fs.mkdirSync(conversationsDir, { recursive: true });
+
+ const date = new Date().toISOString().split('T')[0];
+ const filename = `${date}-${name}.md`;
+ const filePath = path.join(conversationsDir, filename);
+
+ const markdown = formatTranscriptMarkdown(messages, summary, assistantName);
+ fs.writeFileSync(filePath, markdown);
+
+ log(`Archived conversation to ${filePath}`);
+ } catch (err) {
+ log(`Failed to archive transcript: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
+ return {};
+ };
+}
+
+// Secrets to strip from Bash tool subprocess environments.
+// These are needed by claude-code for API auth but should never
+// be visible to commands Kit runs.
+const SECRET_ENV_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_CODE_OAUTH_TOKEN'];
+
+function createSanitizeBashHook(): HookCallback {
+ return async (input, _toolUseId, _context) => {
+ const preInput = input as PreToolUseHookInput;
+ const command = (preInput.tool_input as { command?: string })?.command;
+ if (!command) return {};
+
+ const unsetPrefix = `unset ${SECRET_ENV_VARS.join(' ')} 2>/dev/null; `;
+ return {
+ hookSpecificOutput: {
+ hookEventName: 'PreToolUse',
+ updatedInput: {
+ ...(preInput.tool_input as Record),
+ command: unsetPrefix + command,
+ },
+ },
+ };
+ };
+}
+
+function sanitizeFilename(summary: string): string {
+ return summary
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 50);
+}
+
+function generateFallbackName(): string {
+ const time = new Date();
+ return `conversation-${time.getHours().toString().padStart(2, '0')}${time.getMinutes().toString().padStart(2, '0')}`;
+}
+
+interface ParsedMessage {
+ role: 'user' | 'assistant';
+ content: string;
+}
+
+function parseTranscript(content: string): ParsedMessage[] {
+ const messages: ParsedMessage[] = [];
+
+ for (const line of content.split('\n')) {
+ if (!line.trim()) continue;
+ try {
+ const entry = JSON.parse(line);
+ if (entry.type === 'user' && entry.message?.content) {
+ const text = typeof entry.message.content === 'string'
+ ? entry.message.content
+ : entry.message.content.map((c: { text?: string }) => c.text || '').join('');
+ if (text) messages.push({ role: 'user', content: text });
+ } else if (entry.type === 'assistant' && entry.message?.content) {
+ const textParts = entry.message.content
+ .filter((c: { type: string }) => c.type === 'text')
+ .map((c: { text: string }) => c.text);
+ const text = textParts.join('');
+ if (text) messages.push({ role: 'assistant', content: text });
+ }
+ } catch {
+ }
+ }
+
+ return messages;
+}
+
+function formatTranscriptMarkdown(messages: ParsedMessage[], title?: string | null, assistantName?: string): string {
+ const now = new Date();
+ const formatDateTime = (d: Date) => d.toLocaleString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ hour12: true
+ });
+
+ const lines: string[] = [];
+ lines.push(`# ${title || 'Conversation'}`);
+ lines.push('');
+ lines.push(`Archived: ${formatDateTime(now)}`);
+ lines.push('');
+ lines.push('---');
+ lines.push('');
+
+ for (const msg of messages) {
+ const sender = msg.role === 'user' ? 'User' : (assistantName || 'Assistant');
+ const content = msg.content.length > 2000
+ ? msg.content.slice(0, 2000) + '...'
+ : msg.content;
+ lines.push(`**${sender}**: ${content}`);
+ lines.push('');
+ }
+
+ return lines.join('\n');
+}
+
+/**
+ * Check for _close sentinel.
+ */
+function shouldClose(): boolean {
+ if (fs.existsSync(IPC_INPUT_CLOSE_SENTINEL)) {
+ try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Drain all pending IPC input messages.
+ * Returns messages found, or empty array.
+ */
+function drainIpcInput(): string[] {
+ try {
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+ const files = fs.readdirSync(IPC_INPUT_DIR)
+ .filter(f => f.endsWith('.json'))
+ .sort();
+
+ const messages: string[] = [];
+ for (const file of files) {
+ const filePath = path.join(IPC_INPUT_DIR, file);
+ try {
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
+ fs.unlinkSync(filePath);
+ if (data.type === 'message' && data.text) {
+ messages.push(data.text);
+ }
+ } catch (err) {
+ log(`Failed to process input file ${file}: ${err instanceof Error ? err.message : String(err)}`);
+ try { fs.unlinkSync(filePath); } catch { /* ignore */ }
+ }
+ }
+ return messages;
+ } catch (err) {
+ log(`IPC drain error: ${err instanceof Error ? err.message : String(err)}`);
+ return [];
+ }
+}
+
+/**
+ * Wait for a new IPC message or _close sentinel.
+ * Returns the messages as a single string, or null if _close.
+ */
+function waitForIpcMessage(): Promise {
+ return new Promise((resolve) => {
+ const poll = () => {
+ if (shouldClose()) {
+ resolve(null);
+ return;
+ }
+ const messages = drainIpcInput();
+ if (messages.length > 0) {
+ resolve(messages.join('\n'));
+ return;
+ }
+ setTimeout(poll, IPC_POLL_MS);
+ };
+ poll();
+ });
+}
+
+/**
+ * Run a single query and stream results via writeOutput.
+ * Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
+ * allowing agent teams subagents to run to completion.
+ * Also pipes IPC messages into the stream during the query.
+ */
+async function runQuery(
+ prompt: string,
+ sessionId: string | undefined,
+ mcpServerPath: string,
+ containerInput: ContainerInput,
+ sdkEnv: Record,
+ resumeAt?: string,
+): Promise<{ newSessionId?: string; lastAssistantUuid?: string; closedDuringQuery: boolean }> {
+ const stream = new MessageStream();
+ stream.push(prompt);
+
+ // Load image attachments and send as multimodal content blocks
+ if (containerInput.imageAttachments?.length) {
+ const blocks: ContentBlock[] = [];
+ for (const img of containerInput.imageAttachments) {
+ const imgPath = path.join('/workspace/group', img.relativePath);
+ try {
+ const data = fs.readFileSync(imgPath).toString('base64');
+ blocks.push({ type: 'image', source: { type: 'base64', media_type: img.mediaType, data } });
+ } catch (err) {
+ log(`Failed to load image: ${imgPath}`);
+ }
+ }
+ if (blocks.length > 0) {
+ stream.pushMultimodal(blocks);
+ }
+ }
+
+ // Poll IPC for follow-up messages and _close sentinel during the query
+ let ipcPolling = true;
+ let closedDuringQuery = false;
+ const pollIpcDuringQuery = () => {
+ if (!ipcPolling) return;
+ if (shouldClose()) {
+ log('Close sentinel detected during query, ending stream');
+ closedDuringQuery = true;
+ stream.end();
+ ipcPolling = false;
+ return;
+ }
+ const messages = drainIpcInput();
+ for (const text of messages) {
+ log(`Piping IPC message into active query (${text.length} chars)`);
+ stream.push(text);
+ }
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+ };
+ setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
+
+ let newSessionId: string | undefined;
+ let lastAssistantUuid: string | undefined;
+ let messageCount = 0;
+ let resultCount = 0;
+
+ // Load global CLAUDE.md as additional system context (shared across all groups)
+ const globalClaudeMdPath = '/workspace/global/CLAUDE.md';
+ let globalClaudeMd: string | undefined;
+ if (!containerInput.isMain && fs.existsSync(globalClaudeMdPath)) {
+ globalClaudeMd = fs.readFileSync(globalClaudeMdPath, 'utf-8');
+ }
+
+ // Discover additional directories mounted at /workspace/extra/*
+ // These are passed to the SDK so their CLAUDE.md files are loaded automatically
+ const extraDirs: string[] = [];
+ const extraBase = '/workspace/extra';
+ if (fs.existsSync(extraBase)) {
+ for (const entry of fs.readdirSync(extraBase)) {
+ const fullPath = path.join(extraBase, entry);
+ if (fs.statSync(fullPath).isDirectory()) {
+ extraDirs.push(fullPath);
+ }
+ }
+ }
+ if (extraDirs.length > 0) {
+ log(`Additional directories: ${extraDirs.join(', ')}`);
+ }
+
+ for await (const message of query({
+ prompt: stream,
+ options: {
+ cwd: '/workspace/group',
+ additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
+ resume: sessionId,
+ resumeSessionAt: resumeAt,
+ systemPrompt: globalClaudeMd
+ ? { type: 'preset' as const, preset: 'claude_code' as const, append: globalClaudeMd }
+ : undefined,
+ allowedTools: [
+ 'Bash',
+ 'Read', 'Write', 'Edit', 'Glob', 'Grep',
+ 'WebSearch', 'WebFetch',
+ 'Task', 'TaskOutput', 'TaskStop',
+ 'TeamCreate', 'TeamDelete', 'SendMessage',
+ 'TodoWrite', 'ToolSearch', 'Skill',
+ 'NotebookEdit',
+ 'mcp__nanoclaw__*'
+ ],
+ env: sdkEnv,
+ permissionMode: 'bypassPermissions',
+ allowDangerouslySkipPermissions: true,
+ settingSources: ['project', 'user'],
+ mcpServers: {
+ nanoclaw: {
+ command: 'node',
+ args: [mcpServerPath],
+ env: {
+ NANOCLAW_CHAT_JID: containerInput.chatJid,
+ NANOCLAW_GROUP_FOLDER: containerInput.groupFolder,
+ NANOCLAW_IS_MAIN: containerInput.isMain ? '1' : '0',
+ },
+ },
+ },
+ hooks: {
+ PreCompact: [{ hooks: [createPreCompactHook(containerInput.assistantName)] }],
+ PreToolUse: [{ matcher: 'Bash', hooks: [createSanitizeBashHook()] }],
+ },
+ }
+ })) {
+ messageCount++;
+ const msgType = message.type === 'system' ? `system/${(message as { subtype?: string }).subtype}` : message.type;
+ log(`[msg #${messageCount}] type=${msgType}`);
+
+ if (message.type === 'assistant' && 'uuid' in message) {
+ lastAssistantUuid = (message as { uuid: string }).uuid;
+ }
+
+ if (message.type === 'system' && message.subtype === 'init') {
+ newSessionId = message.session_id;
+ log(`Session initialized: ${newSessionId}`);
+ }
+
+ if (message.type === 'system' && (message as { subtype?: string }).subtype === 'task_notification') {
+ const tn = message as { task_id: string; status: string; summary: string };
+ log(`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`);
+ }
+
+ if (message.type === 'result') {
+ resultCount++;
+ const textResult = 'result' in message ? (message as { result?: string }).result : null;
+ log(`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`);
+ writeOutput({
+ status: 'success',
+ result: textResult || null,
+ newSessionId
+ });
+ }
+ }
+
+ ipcPolling = false;
+ log(`Query done. Messages: ${messageCount}, results: ${resultCount}, lastAssistantUuid: ${lastAssistantUuid || 'none'}, closedDuringQuery: ${closedDuringQuery}`);
+ return { newSessionId, lastAssistantUuid, closedDuringQuery };
+}
+
+async function main(): Promise {
+ let containerInput: ContainerInput;
+
+ try {
+ const stdinData = await readStdin();
+ containerInput = JSON.parse(stdinData);
+ // Delete the temp file the entrypoint wrote — it contains secrets
+ try { fs.unlinkSync('/tmp/input.json'); } catch { /* may not exist */ }
+ log(`Received input for group: ${containerInput.groupFolder}`);
+ } catch (err) {
+ writeOutput({
+ status: 'error',
+ result: null,
+ error: `Failed to parse input: ${err instanceof Error ? err.message : String(err)}`
+ });
+ process.exit(1);
+ }
+
+ // Build SDK env: merge secrets into process.env for the SDK only.
+ // Secrets never touch process.env itself, so Bash subprocesses can't see them.
+ const sdkEnv: Record = { ...process.env };
+ for (const [key, value] of Object.entries(containerInput.secrets || {})) {
+ sdkEnv[key] = value;
+ }
+
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const mcpServerPath = path.join(__dirname, 'ipc-mcp-stdio.js');
+
+ let sessionId = containerInput.sessionId;
+ fs.mkdirSync(IPC_INPUT_DIR, { recursive: true });
+
+ // Clean up stale _close sentinel from previous container runs
+ try { fs.unlinkSync(IPC_INPUT_CLOSE_SENTINEL); } catch { /* ignore */ }
+
+ // Build initial prompt (drain any pending IPC messages too)
+ let prompt = containerInput.prompt;
+ if (containerInput.isScheduledTask) {
+ prompt = `[SCHEDULED TASK - The following message was sent automatically and is not coming directly from the user or group.]\n\n${prompt}`;
+ }
+ const pending = drainIpcInput();
+ if (pending.length > 0) {
+ log(`Draining ${pending.length} pending IPC messages into initial prompt`);
+ prompt += '\n' + pending.join('\n');
+ }
+
+ // Query loop: run query → wait for IPC message → run new query → repeat
+ let resumeAt: string | undefined;
+ try {
+ while (true) {
+ log(`Starting query (session: ${sessionId || 'new'}, resumeAt: ${resumeAt || 'latest'})...`);
+
+ const queryResult = await runQuery(prompt, sessionId, mcpServerPath, containerInput, sdkEnv, resumeAt);
+ if (queryResult.newSessionId) {
+ sessionId = queryResult.newSessionId;
+ }
+ if (queryResult.lastAssistantUuid) {
+ resumeAt = queryResult.lastAssistantUuid;
+ }
+
+ // If _close was consumed during the query, exit immediately.
+ // Don't emit a session-update marker (it would reset the host's
+ // idle timer and cause a 30-min delay before the next _close).
+ if (queryResult.closedDuringQuery) {
+ log('Close sentinel consumed during query, exiting');
+ break;
+ }
+
+ // Emit session update so host can track it
+ writeOutput({ status: 'success', result: null, newSessionId: sessionId });
+
+ log('Query ended, waiting for next IPC message...');
+
+ // Wait for the next message or _close sentinel
+ const nextMessage = await waitForIpcMessage();
+ if (nextMessage === null) {
+ log('Close sentinel received, exiting');
+ break;
+ }
+
+ log(`Got new message (${nextMessage.length} chars), starting new query`);
+ prompt = nextMessage;
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : String(err);
+ log(`Agent error: ${errorMessage}`);
+ writeOutput({
+ status: 'error',
+ result: null,
+ newSessionId: sessionId,
+ error: errorMessage
+ });
+ process.exit(1);
+ }
+}
+
+main();
diff --git a/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md
new file mode 100644
index 0000000..bf659bb
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/container/agent-runner/src/index.ts.intent.md
@@ -0,0 +1,23 @@
+# Intent: container/agent-runner/src/index.ts
+
+## What Changed
+- Added `imageAttachments?` field to ContainerInput interface
+- Added `ImageContentBlock`, `TextContentBlock`, `ContentBlock` type definitions
+- Changed `SDKUserMessage.message.content` type from `string` to `string | ContentBlock[]`
+- Added `pushMultimodal(content: ContentBlock[])` method to MessageStream class
+- In `runQuery`: image loading logic reads attachments from disk, base64-encodes, sends as multimodal content blocks
+
+## Key Sections
+- **Types** (top of file): New content block interfaces, updated SDKUserMessage
+- **MessageStream class**: New pushMultimodal method
+- **runQuery function**: Image loading block
+
+## Invariants (must-keep)
+- All IPC protocol logic (input polling, close sentinel, message stream)
+- MessageStream push/end/asyncIterator (text messages still work)
+- readStdin, writeOutput, log functions
+- Session management (getSessionSummary, sessions index)
+- PreCompact hook (transcript archiving)
+- Bash sanitization hook
+- SDK query options structure (mcpServers, hooks, permissions)
+- Query loop in main() (query -> wait for IPC -> repeat)
diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts
new file mode 100644
index 0000000..9014758
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts
@@ -0,0 +1,1117 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { EventEmitter } from 'events';
+
+// --- Mocks ---
+
+// Mock config
+vi.mock('../config.js', () => ({
+ STORE_DIR: '/tmp/nanoclaw-test-store',
+ ASSISTANT_NAME: 'Andy',
+ ASSISTANT_HAS_OWN_NUMBER: false,
+ GROUPS_DIR: '/tmp/test-groups',
+}));
+
+// Mock logger
+vi.mock('../logger.js', () => ({
+ logger: {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+// Mock db
+vi.mock('../db.js', () => ({
+ getLastGroupSync: vi.fn(() => null),
+ setLastGroupSync: vi.fn(),
+ updateChatName: vi.fn(),
+}));
+
+// Mock image module
+vi.mock('../image.js', () => ({
+ isImageMessage: vi.fn().mockReturnValue(false),
+ processImage: vi.fn().mockResolvedValue({ content: '[Image: attachments/test.jpg]', relativePath: 'attachments/test.jpg' }),
+}));
+
+// Mock fs
+vi.mock('fs', async () => {
+ const actual = await vi.importActual('fs');
+ return {
+ ...actual,
+ default: {
+ ...actual,
+ existsSync: vi.fn(() => true),
+ mkdirSync: vi.fn(),
+ },
+ };
+});
+
+// Mock child_process (used for osascript notification)
+vi.mock('child_process', () => ({
+ exec: vi.fn(),
+}));
+
+// Build a fake WASocket that's an EventEmitter with the methods we need
+function createFakeSocket() {
+ const ev = new EventEmitter();
+ const sock = {
+ ev: {
+ on: (event: string, handler: (...args: unknown[]) => void) => {
+ ev.on(event, handler);
+ },
+ },
+ user: {
+ id: '1234567890:1@s.whatsapp.net',
+ lid: '9876543210:1@lid',
+ },
+ sendMessage: vi.fn().mockResolvedValue(undefined),
+ sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
+ groupFetchAllParticipating: vi.fn().mockResolvedValue({}),
+ updateMediaMessage: vi.fn().mockResolvedValue(undefined),
+ end: vi.fn(),
+ // Expose the event emitter for triggering events in tests
+ _ev: ev,
+ };
+ return sock;
+}
+
+let fakeSocket: ReturnType;
+
+// Mock Baileys
+vi.mock('@whiskeysockets/baileys', () => {
+ return {
+ default: vi.fn(() => fakeSocket),
+ Browsers: { macOS: vi.fn(() => ['macOS', 'Chrome', '']) },
+ DisconnectReason: {
+ loggedOut: 401,
+ badSession: 500,
+ connectionClosed: 428,
+ connectionLost: 408,
+ connectionReplaced: 440,
+ timedOut: 408,
+ restartRequired: 515,
+ },
+ fetchLatestWaWebVersion: vi
+ .fn()
+ .mockResolvedValue({ version: [2, 3000, 0] }),
+ downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from('image-data')),
+ normalizeMessageContent: vi.fn((content: unknown) => content),
+ makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
+ useMultiFileAuthState: vi.fn().mockResolvedValue({
+ state: {
+ creds: {},
+ keys: {},
+ },
+ saveCreds: vi.fn(),
+ }),
+ };
+});
+
+import { WhatsAppChannel, WhatsAppChannelOpts } from './whatsapp.js';
+import { downloadMediaMessage } from '@whiskeysockets/baileys';
+import { getLastGroupSync, updateChatName, setLastGroupSync } from '../db.js';
+import { isImageMessage, processImage } from '../image.js';
+
+// --- Test helpers ---
+
+function createTestOpts(
+ overrides?: Partial,
+): WhatsAppChannelOpts {
+ return {
+ onMessage: vi.fn(),
+ onChatMetadata: vi.fn(),
+ registeredGroups: vi.fn(() => ({
+ 'registered@g.us': {
+ name: 'Test Group',
+ folder: 'test-group',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ ...overrides,
+ };
+}
+
+function triggerConnection(state: string, extra?: Record) {
+ fakeSocket._ev.emit('connection.update', { connection: state, ...extra });
+}
+
+function triggerDisconnect(statusCode: number) {
+ fakeSocket._ev.emit('connection.update', {
+ connection: 'close',
+ lastDisconnect: {
+ error: { output: { statusCode } },
+ },
+ });
+}
+
+async function triggerMessages(messages: unknown[]) {
+ fakeSocket._ev.emit('messages.upsert', { messages });
+ // Flush microtasks so the async messages.upsert handler completes
+ await new Promise((r) => setTimeout(r, 0));
+}
+
+// --- Tests ---
+
+describe('WhatsAppChannel', () => {
+ beforeEach(() => {
+ fakeSocket = createFakeSocket();
+ vi.mocked(getLastGroupSync).mockReturnValue(null);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ /**
+ * Helper: start connect, flush microtasks so event handlers are registered,
+ * then trigger the connection open event. Returns the resolved promise.
+ */
+ async function connectChannel(channel: WhatsAppChannel): Promise {
+ const p = channel.connect();
+ // Flush microtasks so connectInternal completes its await and registers handlers
+ await new Promise((r) => setTimeout(r, 0));
+ triggerConnection('open');
+ return p;
+ }
+
+ // --- Version fetch ---
+
+ describe('version fetch', () => {
+ it('connects with fetched version', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+ await connectChannel(channel);
+
+ const { fetchLatestWaWebVersion } =
+ await import('@whiskeysockets/baileys');
+ expect(fetchLatestWaWebVersion).toHaveBeenCalledWith({});
+ });
+
+ it('falls back gracefully when version fetch fails', async () => {
+ const { fetchLatestWaWebVersion } =
+ await import('@whiskeysockets/baileys');
+ vi.mocked(fetchLatestWaWebVersion).mockRejectedValueOnce(
+ new Error('network error'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+ await connectChannel(channel);
+
+ // Should still connect successfully despite fetch failure
+ expect(channel.isConnected()).toBe(true);
+ });
+ });
+
+ // --- Connection lifecycle ---
+
+ describe('connection lifecycle', () => {
+ it('resolves connect() when connection opens', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('sets up LID to phone mapping on open', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The channel should have mapped the LID from sock.user
+ // We can verify by sending a message from a LID JID
+ // and checking the translated JID in the callback
+ });
+
+ it('flushes outgoing queue on reconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect
+ (channel as any).connected = false;
+
+ // Queue a message while disconnected
+ await channel.sendMessage('test@g.us', 'Queued message');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+
+ // Reconnect
+ (channel as any).connected = true;
+ await (channel as any).flushOutgoingQueue();
+
+ // Group messages get prefixed when flushed
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
+ text: 'Andy: Queued message',
+ });
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ expect(fakeSocket.end).toHaveBeenCalled();
+ });
+ });
+
+ // --- QR code and auth ---
+
+ describe('authentication', () => {
+ it('exits process when QR code is emitted (no auth state)', async () => {
+ vi.useFakeTimers();
+ const mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Start connect but don't await (it won't resolve - process exits)
+ channel.connect().catch(() => {});
+
+ // Flush microtasks so connectInternal registers handlers
+ await vi.advanceTimersByTimeAsync(0);
+
+ // Emit QR code event
+ fakeSocket._ev.emit('connection.update', { qr: 'some-qr-data' });
+
+ // Advance timer past the 1000ms setTimeout before exit
+ await vi.advanceTimersByTimeAsync(1500);
+
+ expect(mockExit).toHaveBeenCalledWith(1);
+ mockExit.mockRestore();
+ vi.useRealTimers();
+ });
+ });
+
+ // --- Reconnection behavior ---
+
+ describe('reconnection', () => {
+ it('reconnects on non-loggedOut disconnect', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ expect(channel.isConnected()).toBe(true);
+
+ // Disconnect with a non-loggedOut reason (e.g., connectionClosed = 428)
+ triggerDisconnect(428);
+
+ expect(channel.isConnected()).toBe(false);
+ // The channel should attempt to reconnect (calls connectInternal again)
+ });
+
+ it('exits on loggedOut disconnect', async () => {
+ const mockExit = vi
+ .spyOn(process, 'exit')
+ .mockImplementation(() => undefined as never);
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with loggedOut reason (401)
+ triggerDisconnect(401);
+
+ expect(channel.isConnected()).toBe(false);
+ expect(mockExit).toHaveBeenCalledWith(0);
+ mockExit.mockRestore();
+ });
+
+ it('retries reconnection after 5s on failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Disconnect with stream error 515
+ triggerDisconnect(515);
+
+ // The channel sets a 5s retry — just verify it doesn't crash
+ await new Promise((r) => setTimeout(r, 100));
+ });
+ });
+
+ // --- Message handling ---
+
+ describe('message handling', () => {
+ it('delivers message for registered group', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-1',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello Andy' },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ id: 'msg-1',
+ content: 'Hello Andy',
+ sender_name: 'Alice',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered groups', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-2',
+ remoteJid: 'unregistered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Hello' },
+ pushName: 'Bob',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'unregistered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores status@broadcast messages', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-3',
+ remoteJid: 'status@broadcast',
+ fromMe: false,
+ },
+ message: { conversation: 'Status update' },
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).not.toHaveBeenCalled();
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores messages with no content', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-4',
+ remoteJid: 'registered@g.us',
+ fromMe: false,
+ },
+ message: null,
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('extracts text from extendedTextMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-5',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ extendedTextMessage: { text: 'A reply message' },
+ },
+ pushName: 'Charlie',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'A reply message' }),
+ );
+ });
+
+ it('extracts caption from imageMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-6',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ caption: 'Check this photo',
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Diana',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Check this photo' }),
+ );
+ });
+
+ it('extracts caption from videoMessage', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-7',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ videoMessage: { caption: 'Watch this', mimetype: 'video/mp4' },
+ },
+ pushName: 'Eve',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ content: 'Watch this' }),
+ );
+ });
+
+ it('handles message with no extractable text (e.g. voice note without caption)', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-8',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ audioMessage: { mimetype: 'audio/ogg; codecs=opus', ptt: true },
+ },
+ pushName: 'Frank',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Skipped — no text content to process
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('uses sender JID when pushName is absent', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-9',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'No push name' },
+ // pushName is undefined
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({ sender_name: '5551234' }),
+ );
+ });
+
+ it('downloads and processes image attachments', async () => {
+ vi.mocked(isImageMessage).mockReturnValue(true);
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-img-1',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ caption: 'Check this',
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Alice',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(downloadMediaMessage).toHaveBeenCalled();
+ expect(processImage).toHaveBeenCalled();
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: '[Image: attachments/test.jpg]',
+ }),
+ );
+
+ vi.mocked(isImageMessage).mockReturnValue(false);
+ });
+
+ it('handles image without caption', async () => {
+ vi.mocked(isImageMessage).mockReturnValue(true);
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-img-2',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Bob',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(processImage).toHaveBeenCalledWith(
+ expect.any(Buffer),
+ expect.any(String),
+ '',
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: '[Image: attachments/test.jpg]',
+ }),
+ );
+
+ vi.mocked(isImageMessage).mockReturnValue(false);
+ });
+
+ it('handles image download failure gracefully', async () => {
+ vi.mocked(isImageMessage).mockReturnValue(true);
+ vi.mocked(downloadMediaMessage).mockRejectedValueOnce(
+ new Error('Download failed'),
+ );
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-img-3',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ caption: 'Will fail',
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Charlie',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Image download failed but caption is still there as content
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: 'Will fail',
+ }),
+ );
+
+ vi.mocked(isImageMessage).mockReturnValue(false);
+ });
+
+ it('falls back to caption when processImage returns null', async () => {
+ vi.mocked(isImageMessage).mockReturnValue(true);
+ vi.mocked(processImage).mockResolvedValueOnce(null);
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-img-4',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: {
+ imageMessage: {
+ caption: 'Fallback caption',
+ mimetype: 'image/jpeg',
+ },
+ },
+ pushName: 'Diana',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // processImage returned null, so original caption content is used
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.objectContaining({
+ content: 'Fallback caption',
+ }),
+ );
+
+ vi.mocked(isImageMessage).mockReturnValue(false);
+ });
+ });
+
+ // --- LID ↔ JID translation ---
+
+ describe('LID to JID translation', () => {
+ it('translates known LID to phone JID', async () => {
+ const opts = createTestOpts({
+ registeredGroups: vi.fn(() => ({
+ '1234567890@s.whatsapp.net': {
+ name: 'Self Chat',
+ folder: 'self-chat',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // The socket has lid '9876543210:1@lid' → phone '1234567890@s.whatsapp.net'
+ // Send a message from the LID
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-lid',
+ remoteJid: '9876543210@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'From LID' },
+ pushName: 'Self',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Should be translated to phone JID
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '1234567890@s.whatsapp.net',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+
+ it('passes through non-LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-normal',
+ remoteJid: 'registered@g.us',
+ participant: '5551234@s.whatsapp.net',
+ fromMe: false,
+ },
+ message: { conversation: 'Normal JID' },
+ pushName: 'Grace',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'registered@g.us',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ true,
+ );
+ });
+
+ it('passes through unknown LID JIDs unchanged', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await triggerMessages([
+ {
+ key: {
+ id: 'msg-unknown-lid',
+ remoteJid: '0000000000@lid',
+ fromMe: false,
+ },
+ message: { conversation: 'Unknown LID' },
+ pushName: 'Unknown',
+ messageTimestamp: Math.floor(Date.now() / 1000),
+ },
+ ]);
+
+ // Unknown LID passes through unchanged
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ '0000000000@lid',
+ expect.any(String),
+ undefined,
+ 'whatsapp',
+ false,
+ );
+ });
+ });
+
+ // --- Outgoing message queue ---
+
+ describe('outgoing message queue', () => {
+ it('sends message directly when connected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('test@g.us', 'Hello');
+ // Group messages get prefixed with assistant name
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith('test@g.us', {
+ text: 'Andy: Hello',
+ });
+ });
+
+ it('prefixes direct chat messages on shared number', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.sendMessage('123@s.whatsapp.net', 'Hello');
+ // Shared number: DMs also get prefixed (needed for self-chat distinction)
+ expect(fakeSocket.sendMessage).toHaveBeenCalledWith(
+ '123@s.whatsapp.net',
+ { text: 'Andy: Hello' },
+ );
+ });
+
+ it('queues message when disconnected', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Don't connect — channel starts disconnected
+ await channel.sendMessage('test@g.us', 'Queued');
+ expect(fakeSocket.sendMessage).not.toHaveBeenCalled();
+ });
+
+ it('queues message on send failure', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Make sendMessage fail
+ fakeSocket.sendMessage.mockRejectedValueOnce(new Error('Network error'));
+
+ await channel.sendMessage('test@g.us', 'Will fail');
+
+ // Should not throw, message queued for retry
+ // The queue should have the message
+ });
+
+ it('flushes multiple queued messages in order', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ // Queue messages while disconnected
+ await channel.sendMessage('test@g.us', 'First');
+ await channel.sendMessage('test@g.us', 'Second');
+ await channel.sendMessage('test@g.us', 'Third');
+
+ // Connect — flush happens automatically on open
+ await connectChannel(channel);
+
+ // Give the async flush time to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.sendMessage).toHaveBeenCalledTimes(3);
+ // Group messages get prefixed
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(1, 'test@g.us', {
+ text: 'Andy: First',
+ });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(2, 'test@g.us', {
+ text: 'Andy: Second',
+ });
+ expect(fakeSocket.sendMessage).toHaveBeenNthCalledWith(3, 'test@g.us', {
+ text: 'Andy: Third',
+ });
+ });
+ });
+
+ // --- Group metadata sync ---
+
+ describe('group metadata sync', () => {
+ it('syncs group metadata on first connection', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Group One' },
+ 'group2@g.us': { subject: 'Group Two' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Wait for async sync to complete
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Group One');
+ expect(updateChatName).toHaveBeenCalledWith('group2@g.us', 'Group Two');
+ expect(setLastGroupSync).toHaveBeenCalled();
+ });
+
+ it('skips sync when synced recently', async () => {
+ // Last sync was 1 hour ago (within 24h threshold)
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(fakeSocket.groupFetchAllParticipating).not.toHaveBeenCalled();
+ });
+
+ it('forces sync regardless of cache', async () => {
+ vi.mocked(getLastGroupSync).mockReturnValue(
+ new Date(Date.now() - 60 * 60 * 1000).toISOString(),
+ );
+
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group@g.us': { subject: 'Forced Group' },
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.syncGroupMetadata(true);
+
+ expect(fakeSocket.groupFetchAllParticipating).toHaveBeenCalled();
+ expect(updateChatName).toHaveBeenCalledWith('group@g.us', 'Forced Group');
+ });
+
+ it('handles group sync failure gracefully', async () => {
+ fakeSocket.groupFetchAllParticipating.mockRejectedValue(
+ new Error('Network timeout'),
+ );
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Should not throw
+ await expect(channel.syncGroupMetadata(true)).resolves.toBeUndefined();
+ });
+
+ it('skips groups with no subject', async () => {
+ fakeSocket.groupFetchAllParticipating.mockResolvedValue({
+ 'group1@g.us': { subject: 'Has Subject' },
+ 'group2@g.us': { subject: '' },
+ 'group3@g.us': {},
+ });
+
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ // Clear any calls from the automatic sync on connect
+ vi.mocked(updateChatName).mockClear();
+
+ await channel.syncGroupMetadata(true);
+
+ expect(updateChatName).toHaveBeenCalledTimes(1);
+ expect(updateChatName).toHaveBeenCalledWith('group1@g.us', 'Has Subject');
+ });
+ });
+
+ // --- JID ownership ---
+
+ describe('ownsJid', () => {
+ it('owns @g.us JIDs (WhatsApp groups)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(true);
+ });
+
+ it('owns @s.whatsapp.net JIDs (WhatsApp DMs)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('12345@s.whatsapp.net')).toBe(true);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('tg:12345')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+ });
+
+ // --- Typing indicator ---
+
+ describe('setTyping', () => {
+ it('sends composing presence when typing', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', true);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
+ 'composing',
+ 'test@g.us',
+ );
+ });
+
+ it('sends paused presence when stopping', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ await channel.setTyping('test@g.us', false);
+ expect(fakeSocket.sendPresenceUpdate).toHaveBeenCalledWith(
+ 'paused',
+ 'test@g.us',
+ );
+ });
+
+ it('handles typing indicator failure gracefully', async () => {
+ const opts = createTestOpts();
+ const channel = new WhatsAppChannel(opts);
+
+ await connectChannel(channel);
+
+ fakeSocket.sendPresenceUpdate.mockRejectedValueOnce(new Error('Failed'));
+
+ // Should not throw
+ await expect(
+ channel.setTyping('test@g.us', true),
+ ).resolves.toBeUndefined();
+ });
+ });
+
+ // --- Channel properties ---
+
+ describe('channel properties', () => {
+ it('has name "whatsapp"', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect(channel.name).toBe('whatsapp');
+ });
+
+ it('does not expose prefixAssistantName (prefix handled internally)', () => {
+ const channel = new WhatsAppChannel(createTestOpts());
+ expect('prefixAssistantName' in channel).toBe(false);
+ });
+ });
+});
diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md
new file mode 100644
index 0000000..2c96eec
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.test.ts.intent.md
@@ -0,0 +1,21 @@
+# Intent: src/channels/whatsapp.test.ts
+
+## What Changed
+- Added `GROUPS_DIR` to config mock
+- Added `../image.js` mock (isImageMessage defaults false, processImage returns stub)
+- Added `updateMediaMessage` to fake socket (needed by downloadMediaMessage)
+- Added `normalizeMessageContent` to Baileys mock (pass-through)
+- Added `downloadMediaMessage` to Baileys mock (returns Buffer)
+- Added imports for `downloadMediaMessage`, `isImageMessage`, `processImage`
+- Added image test cases: downloads/processes, no caption, download failure, processImage null fallback
+
+## Key Sections
+- **Mock setup** (top of file): New image mock, extended Baileys mock, extended fakeSocket
+- **Message handling tests**: Image test cases
+
+## Invariants (must-keep)
+- All existing test sections and describe blocks
+- Existing mock structure (config, logger, db, fs, child_process, Baileys)
+- Test helpers (createTestOpts, triggerConnection, triggerDisconnect, triggerMessages, connectChannel)
+- Connection lifecycle, authentication, reconnection, LID translation tests
+- Outgoing queue, group metadata sync, JID ownership, typing indicator tests
diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts
new file mode 100644
index 0000000..cee13f7
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts
@@ -0,0 +1,419 @@
+import { exec } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import makeWASocket, {
+ Browsers,
+ DisconnectReason,
+ downloadMediaMessage,
+ WASocket,
+ fetchLatestWaWebVersion,
+ makeCacheableSignalKeyStore,
+ normalizeMessageContent,
+ useMultiFileAuthState,
+} from '@whiskeysockets/baileys';
+
+import {
+ ASSISTANT_HAS_OWN_NUMBER,
+ ASSISTANT_NAME,
+ GROUPS_DIR,
+ STORE_DIR,
+} from '../config.js';
+import { getLastGroupSync, setLastGroupSync, updateChatName } from '../db.js';
+import { isImageMessage, processImage } from '../image.js';
+import { logger } from '../logger.js';
+import {
+ Channel,
+ OnInboundMessage,
+ OnChatMetadata,
+ RegisteredGroup,
+} from '../types.js';
+import { registerChannel, ChannelOpts } from './registry.js';
+
+const GROUP_SYNC_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+export interface WhatsAppChannelOpts {
+ onMessage: OnInboundMessage;
+ onChatMetadata: OnChatMetadata;
+ registeredGroups: () => Record;
+}
+
+export class WhatsAppChannel implements Channel {
+ name = 'whatsapp';
+
+ private sock!: WASocket;
+ private connected = false;
+ private lidToPhoneMap: Record = {};
+ private outgoingQueue: Array<{ jid: string; text: string }> = [];
+ private flushing = false;
+ private groupSyncTimerStarted = false;
+
+ private opts: WhatsAppChannelOpts;
+
+ constructor(opts: WhatsAppChannelOpts) {
+ this.opts = opts;
+ }
+
+ async connect(): Promise {
+ return new Promise((resolve, reject) => {
+ this.connectInternal(resolve).catch(reject);
+ });
+ }
+
+ private async connectInternal(onFirstOpen?: () => void): Promise {
+ const authDir = path.join(STORE_DIR, 'auth');
+ fs.mkdirSync(authDir, { recursive: true });
+
+ const { state, saveCreds } = await useMultiFileAuthState(authDir);
+
+ const { version } = await fetchLatestWaWebVersion({}).catch((err) => {
+ logger.warn(
+ { err },
+ 'Failed to fetch latest WA Web version, using default',
+ );
+ return { version: undefined };
+ });
+ this.sock = makeWASocket({
+ version,
+ auth: {
+ creds: state.creds,
+ keys: makeCacheableSignalKeyStore(state.keys, logger),
+ },
+ printQRInTerminal: false,
+ logger,
+ browser: Browsers.macOS('Chrome'),
+ });
+
+ this.sock.ev.on('connection.update', (update) => {
+ const { connection, lastDisconnect, qr } = update;
+
+ if (qr) {
+ const msg =
+ 'WhatsApp authentication required. Run /setup in Claude Code.';
+ logger.error(msg);
+ exec(
+ `osascript -e 'display notification "${msg}" with title "NanoClaw" sound name "Basso"'`,
+ );
+ setTimeout(() => process.exit(1), 1000);
+ }
+
+ if (connection === 'close') {
+ this.connected = false;
+ const reason = (
+ lastDisconnect?.error as { output?: { statusCode?: number } }
+ )?.output?.statusCode;
+ const shouldReconnect = reason !== DisconnectReason.loggedOut;
+ logger.info(
+ {
+ reason,
+ shouldReconnect,
+ queuedMessages: this.outgoingQueue.length,
+ },
+ 'Connection closed',
+ );
+
+ if (shouldReconnect) {
+ this.scheduleReconnect(1);
+ } else {
+ logger.info('Logged out. Run /setup to re-authenticate.');
+ process.exit(0);
+ }
+ } else if (connection === 'open') {
+ this.connected = true;
+ logger.info('Connected to WhatsApp');
+
+ // Announce availability so WhatsApp relays subsequent presence updates (typing indicators)
+ this.sock.sendPresenceUpdate('available').catch((err) => {
+ logger.warn({ err }, 'Failed to send presence update');
+ });
+
+ // Build LID to phone mapping from auth state for self-chat translation
+ if (this.sock.user) {
+ const phoneUser = this.sock.user.id.split(':')[0];
+ const lidUser = this.sock.user.lid?.split(':')[0];
+ if (lidUser && phoneUser) {
+ this.lidToPhoneMap[lidUser] = `${phoneUser}@s.whatsapp.net`;
+ logger.debug({ lidUser, phoneUser }, 'LID to phone mapping set');
+ }
+ }
+
+ // Flush any messages queued while disconnected
+ this.flushOutgoingQueue().catch((err) =>
+ logger.error({ err }, 'Failed to flush outgoing queue'),
+ );
+
+ // Sync group metadata on startup (respects 24h cache)
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Initial group sync failed'),
+ );
+ // Set up daily sync timer (only once)
+ if (!this.groupSyncTimerStarted) {
+ this.groupSyncTimerStarted = true;
+ setInterval(() => {
+ this.syncGroupMetadata().catch((err) =>
+ logger.error({ err }, 'Periodic group sync failed'),
+ );
+ }, GROUP_SYNC_INTERVAL_MS);
+ }
+
+ // Signal first connection to caller
+ if (onFirstOpen) {
+ onFirstOpen();
+ onFirstOpen = undefined;
+ }
+ }
+ });
+
+ this.sock.ev.on('creds.update', saveCreds);
+
+ this.sock.ev.on('messages.upsert', async ({ messages }) => {
+ for (const msg of messages) {
+ try {
+ if (!msg.message) continue;
+ // Unwrap container types (viewOnceMessageV2, ephemeralMessage,
+ // editedMessage, etc.) so that conversation, extendedTextMessage,
+ // imageMessage, etc. are accessible at the top level.
+ const normalized = normalizeMessageContent(msg.message);
+ if (!normalized) continue;
+ const rawJid = msg.key.remoteJid;
+ if (!rawJid || rawJid === 'status@broadcast') continue;
+
+ // Translate LID JID to phone JID if applicable
+ const chatJid = await this.translateJid(rawJid);
+
+ const timestamp = new Date(
+ Number(msg.messageTimestamp) * 1000,
+ ).toISOString();
+
+ // Always notify about chat metadata for group discovery
+ const isGroup = chatJid.endsWith('@g.us');
+ this.opts.onChatMetadata(
+ chatJid,
+ timestamp,
+ undefined,
+ 'whatsapp',
+ isGroup,
+ );
+
+ // Only deliver full message for registered groups
+ const groups = this.opts.registeredGroups();
+ if (groups[chatJid]) {
+ let content =
+ normalized.conversation ||
+ normalized.extendedTextMessage?.text ||
+ normalized.imageMessage?.caption ||
+ normalized.videoMessage?.caption ||
+ '';
+
+ // Image attachment handling
+ if (isImageMessage(msg)) {
+ try {
+ const buffer = await downloadMediaMessage(msg, 'buffer', {});
+ const groupDir = path.join(GROUPS_DIR, groups[chatJid].folder);
+ const caption = normalized?.imageMessage?.caption ?? '';
+ const result = await processImage(buffer as Buffer, groupDir, caption);
+ if (result) {
+ content = result.content;
+ }
+ } catch (err) {
+ logger.warn({ err, jid: chatJid }, 'Image - download failed');
+ }
+ }
+
+ // Skip protocol messages with no text content (encryption keys, read receipts, etc.)
+ if (!content) continue;
+
+ const sender = msg.key.participant || msg.key.remoteJid || '';
+ const senderName = msg.pushName || sender.split('@')[0];
+
+ const fromMe = msg.key.fromMe || false;
+ // Detect bot messages: with own number, fromMe is reliable
+ // since only the bot sends from that number.
+ // With shared number, bot messages carry the assistant name prefix
+ // (even in DMs/self-chat) so we check for that.
+ const isBotMessage = ASSISTANT_HAS_OWN_NUMBER
+ ? fromMe
+ : content.startsWith(`${ASSISTANT_NAME}:`);
+
+ this.opts.onMessage(chatJid, {
+ id: msg.key.id || '',
+ chat_jid: chatJid,
+ sender,
+ sender_name: senderName,
+ content,
+ timestamp,
+ is_from_me: fromMe,
+ is_bot_message: isBotMessage,
+ });
+ }
+ } catch (err) {
+ logger.error(
+ { err, remoteJid: msg.key?.remoteJid },
+ 'Error processing incoming message',
+ );
+ }
+ }
+ });
+ }
+
+ async sendMessage(jid: string, text: string): Promise {
+ // Prefix bot messages with assistant name so users know who's speaking.
+ // On a shared number, prefix is also needed in DMs (including self-chat)
+ // to distinguish bot output from user messages.
+ // Skip only when the assistant has its own dedicated phone number.
+ const prefixed = ASSISTANT_HAS_OWN_NUMBER
+ ? text
+ : `${ASSISTANT_NAME}: ${text}`;
+
+ if (!this.connected) {
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.info(
+ { jid, length: prefixed.length, queueSize: this.outgoingQueue.length },
+ 'WA disconnected, message queued',
+ );
+ return;
+ }
+ try {
+ await this.sock.sendMessage(jid, { text: prefixed });
+ logger.info({ jid, length: prefixed.length }, 'Message sent');
+ } catch (err) {
+ // If send fails, queue it for retry on reconnect
+ this.outgoingQueue.push({ jid, text: prefixed });
+ logger.warn(
+ { jid, err, queueSize: this.outgoingQueue.length },
+ 'Failed to send, message queued',
+ );
+ }
+ }
+
+ isConnected(): boolean {
+ return this.connected;
+ }
+
+ ownsJid(jid: string): boolean {
+ return jid.endsWith('@g.us') || jid.endsWith('@s.whatsapp.net');
+ }
+
+ async disconnect(): Promise {
+ this.connected = false;
+ this.sock?.end(undefined);
+ }
+
+ async setTyping(jid: string, isTyping: boolean): Promise {
+ try {
+ const status = isTyping ? 'composing' : 'paused';
+ logger.debug({ jid, status }, 'Sending presence update');
+ await this.sock.sendPresenceUpdate(status, jid);
+ } catch (err) {
+ logger.debug({ jid, err }, 'Failed to update typing status');
+ }
+ }
+
+ async syncGroups(force: boolean): Promise {
+ return this.syncGroupMetadata(force);
+ }
+
+ /**
+ * Sync group metadata from WhatsApp.
+ * Fetches all participating groups and stores their names in the database.
+ * Called on startup, daily, and on-demand via IPC.
+ */
+ async syncGroupMetadata(force = false): Promise {
+ if (!force) {
+ const lastSync = getLastGroupSync();
+ if (lastSync) {
+ const lastSyncTime = new Date(lastSync).getTime();
+ if (Date.now() - lastSyncTime < GROUP_SYNC_INTERVAL_MS) {
+ logger.debug({ lastSync }, 'Skipping group sync - synced recently');
+ return;
+ }
+ }
+ }
+
+ try {
+ logger.info('Syncing group metadata from WhatsApp...');
+ const groups = await this.sock.groupFetchAllParticipating();
+
+ let count = 0;
+ for (const [jid, metadata] of Object.entries(groups)) {
+ if (metadata.subject) {
+ updateChatName(jid, metadata.subject);
+ count++;
+ }
+ }
+
+ setLastGroupSync();
+ logger.info({ count }, 'Group metadata synced');
+ } catch (err) {
+ logger.error({ err }, 'Failed to sync group metadata');
+ }
+ }
+
+ private scheduleReconnect(attempt: number): void {
+ const delayMs = Math.min(5000 * Math.pow(2, attempt - 1), 300000);
+ logger.info({ attempt, delayMs }, 'Reconnecting...');
+ setTimeout(() => {
+ this.connectInternal().catch((err) => {
+ logger.error({ err, attempt }, 'Reconnection attempt failed');
+ this.scheduleReconnect(attempt + 1);
+ });
+ }, delayMs);
+ }
+
+ private async translateJid(jid: string): Promise {
+ if (!jid.endsWith('@lid')) return jid;
+ const lidUser = jid.split('@')[0].split(':')[0];
+
+ // Check local cache first
+ const cached = this.lidToPhoneMap[lidUser];
+ if (cached) {
+ logger.debug(
+ { lidJid: jid, phoneJid: cached },
+ 'Translated LID to phone JID (cached)',
+ );
+ return cached;
+ }
+
+ // Query Baileys' signal repository for the mapping
+ try {
+ const pn = await this.sock.signalRepository?.lidMapping?.getPNForLID(jid);
+ if (pn) {
+ const phoneJid = `${pn.split('@')[0].split(':')[0]}@s.whatsapp.net`;
+ this.lidToPhoneMap[lidUser] = phoneJid;
+ logger.info(
+ { lidJid: jid, phoneJid },
+ 'Translated LID to phone JID (signalRepository)',
+ );
+ return phoneJid;
+ }
+ } catch (err) {
+ logger.debug({ err, jid }, 'Failed to resolve LID via signalRepository');
+ }
+
+ return jid;
+ }
+
+ private async flushOutgoingQueue(): Promise {
+ if (this.flushing || this.outgoingQueue.length === 0) return;
+ this.flushing = true;
+ try {
+ logger.info(
+ { count: this.outgoingQueue.length },
+ 'Flushing outgoing message queue',
+ );
+ while (this.outgoingQueue.length > 0) {
+ const item = this.outgoingQueue.shift()!;
+ // Send directly — queued items are already prefixed by sendMessage
+ await this.sock.sendMessage(item.jid, { text: item.text });
+ logger.info(
+ { jid: item.jid, length: item.text.length },
+ 'Queued message sent',
+ );
+ }
+ } finally {
+ this.flushing = false;
+ }
+ }
+}
+
+registerChannel('whatsapp', (opts: ChannelOpts) => new WhatsAppChannel(opts));
diff --git a/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md
new file mode 100644
index 0000000..bed8467
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/channels/whatsapp.ts.intent.md
@@ -0,0 +1,23 @@
+# Intent: src/channels/whatsapp.ts
+
+## What Changed
+- Added `downloadMediaMessage` import from Baileys
+- Added `normalizeMessageContent` import from Baileys for unwrapping container types
+- Added `GROUPS_DIR` to config import
+- Added `isImageMessage`, `processImage` imports from `../image.js`
+- Uses `normalizeMessageContent(msg.message)` to unwrap viewOnce, ephemeral, edited messages
+- Changed `const content =` to `let content =` (allows mutation by image handler)
+- Added image download/process block between content extraction and `!content` guard
+
+## Key Sections
+- **Imports** (top of file): New imports for downloadMediaMessage, normalizeMessageContent, isImageMessage, processImage, GROUPS_DIR
+- **messages.upsert handler** (inside `connectInternal`): normalizeMessageContent call, image block inserted after text extraction, before the `!content` skip guard
+
+## Invariants (must-keep)
+- WhatsAppChannel class structure and all existing methods
+- Connection lifecycle (connect, reconnect with exponential backoff, disconnect)
+- LID-to-phone translation logic
+- Outgoing message queue and flush logic
+- Group metadata sync with 24h cache
+- The `!content` guard must remain AFTER media blocks (they provide content for otherwise-empty messages)
+- Local timestamp format (no Z suffix) for cursor compatibility
diff --git a/.claude/skills/add-image-vision/modify/src/container-runner.ts b/.claude/skills/add-image-vision/modify/src/container-runner.ts
new file mode 100644
index 0000000..8657e7b
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/container-runner.ts
@@ -0,0 +1,703 @@
+/**
+ * Container Runner for NanoClaw
+ * Spawns agent execution in containers and handles IPC
+ */
+import { ChildProcess, exec, spawn } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import {
+ CONTAINER_IMAGE,
+ CONTAINER_MAX_OUTPUT_SIZE,
+ CONTAINER_TIMEOUT,
+ DATA_DIR,
+ GROUPS_DIR,
+ IDLE_TIMEOUT,
+ TIMEZONE,
+} from './config.js';
+import { readEnvFile } from './env.js';
+import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js';
+import { logger } from './logger.js';
+import {
+ CONTAINER_RUNTIME_BIN,
+ readonlyMountArgs,
+ stopContainer,
+} from './container-runtime.js';
+import { validateAdditionalMounts } from './mount-security.js';
+import { RegisteredGroup } from './types.js';
+
+// Sentinel markers for robust output parsing (must match agent-runner)
+const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
+const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
+
+export interface ContainerInput {
+ prompt: string;
+ sessionId?: string;
+ groupFolder: string;
+ chatJid: string;
+ isMain: boolean;
+ isScheduledTask?: boolean;
+ assistantName?: string;
+ secrets?: Record;
+ imageAttachments?: Array<{ relativePath: string; mediaType: string }>;
+}
+
+export interface ContainerOutput {
+ status: 'success' | 'error';
+ result: string | null;
+ newSessionId?: string;
+ error?: string;
+}
+
+interface VolumeMount {
+ hostPath: string;
+ containerPath: string;
+ readonly: boolean;
+}
+
+function buildVolumeMounts(
+ group: RegisteredGroup,
+ isMain: boolean,
+): VolumeMount[] {
+ const mounts: VolumeMount[] = [];
+ const projectRoot = process.cwd();
+ const groupDir = resolveGroupFolderPath(group.folder);
+
+ if (isMain) {
+ // Main gets the project root read-only. Writable paths the agent needs
+ // (group folder, IPC, .claude/) are mounted separately below.
+ // Read-only prevents the agent from modifying host application code
+ // (src/, dist/, package.json, etc.) which would bypass the sandbox
+ // entirely on next restart.
+ mounts.push({
+ hostPath: projectRoot,
+ containerPath: '/workspace/project',
+ readonly: true,
+ });
+
+ // Shadow .env so the agent cannot read secrets from the mounted project root.
+ // Secrets are passed via stdin instead (see readSecrets()).
+ const envFile = path.join(projectRoot, '.env');
+ if (fs.existsSync(envFile)) {
+ mounts.push({
+ hostPath: '/dev/null',
+ containerPath: '/workspace/project/.env',
+ readonly: true,
+ });
+ }
+
+ // Main also gets its group folder as the working directory
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+ } else {
+ // Other groups only get their own folder
+ mounts.push({
+ hostPath: groupDir,
+ containerPath: '/workspace/group',
+ readonly: false,
+ });
+
+ // Global memory directory (read-only for non-main)
+ // Only directory mounts are supported, not file mounts
+ const globalDir = path.join(GROUPS_DIR, 'global');
+ if (fs.existsSync(globalDir)) {
+ mounts.push({
+ hostPath: globalDir,
+ containerPath: '/workspace/global',
+ readonly: true,
+ });
+ }
+ }
+
+ // Per-group Claude sessions directory (isolated from other groups)
+ // Each group gets their own .claude/ to prevent cross-group session access
+ const groupSessionsDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ '.claude',
+ );
+ fs.mkdirSync(groupSessionsDir, { recursive: true });
+ const settingsFile = path.join(groupSessionsDir, 'settings.json');
+ if (!fs.existsSync(settingsFile)) {
+ fs.writeFileSync(
+ settingsFile,
+ JSON.stringify(
+ {
+ env: {
+ // Enable agent swarms (subagent orchestration)
+ // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
+ // Load CLAUDE.md from additional mounted directories
+ // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories
+ CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1',
+ // Enable Claude's memory feature (persists user preferences between sessions)
+ // https://code.claude.com/docs/en/memory#manage-auto-memory
+ CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0',
+ },
+ },
+ null,
+ 2,
+ ) + '\n',
+ );
+ }
+
+ // Sync skills from container/skills/ into each group's .claude/skills/
+ const skillsSrc = path.join(process.cwd(), 'container', 'skills');
+ const skillsDst = path.join(groupSessionsDir, 'skills');
+ if (fs.existsSync(skillsSrc)) {
+ for (const skillDir of fs.readdirSync(skillsSrc)) {
+ const srcDir = path.join(skillsSrc, skillDir);
+ if (!fs.statSync(srcDir).isDirectory()) continue;
+ const dstDir = path.join(skillsDst, skillDir);
+ fs.cpSync(srcDir, dstDir, { recursive: true });
+ }
+ }
+ mounts.push({
+ hostPath: groupSessionsDir,
+ containerPath: '/home/node/.claude',
+ readonly: false,
+ });
+
+ // Per-group IPC namespace: each group gets its own IPC directory
+ // This prevents cross-group privilege escalation via IPC
+ const groupIpcDir = resolveGroupIpcPath(group.folder);
+ fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true });
+ fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true });
+ mounts.push({
+ hostPath: groupIpcDir,
+ containerPath: '/workspace/ipc',
+ readonly: false,
+ });
+
+ // Copy agent-runner source into a per-group writable location so agents
+ // can customize it (add tools, change behavior) without affecting other
+ // groups. Recompiled on container startup via entrypoint.sh.
+ const agentRunnerSrc = path.join(
+ projectRoot,
+ 'container',
+ 'agent-runner',
+ 'src',
+ );
+ const groupAgentRunnerDir = path.join(
+ DATA_DIR,
+ 'sessions',
+ group.folder,
+ 'agent-runner-src',
+ );
+ if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
+ fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
+ }
+ mounts.push({
+ hostPath: groupAgentRunnerDir,
+ containerPath: '/app/src',
+ readonly: false,
+ });
+
+ // Additional mounts validated against external allowlist (tamper-proof from containers)
+ if (group.containerConfig?.additionalMounts) {
+ const validatedMounts = validateAdditionalMounts(
+ group.containerConfig.additionalMounts,
+ group.name,
+ isMain,
+ );
+ mounts.push(...validatedMounts);
+ }
+
+ return mounts;
+}
+
+/**
+ * Read allowed secrets from .env for passing to the container via stdin.
+ * Secrets are never written to disk or mounted as files.
+ */
+function readSecrets(): Record {
+ return readEnvFile([
+ 'CLAUDE_CODE_OAUTH_TOKEN',
+ 'ANTHROPIC_API_KEY',
+ 'ANTHROPIC_BASE_URL',
+ 'ANTHROPIC_AUTH_TOKEN',
+ ]);
+}
+
+function buildContainerArgs(
+ mounts: VolumeMount[],
+ containerName: string,
+): string[] {
+ const args: string[] = ['run', '-i', '--rm', '--name', containerName];
+
+ // Pass host timezone so container's local time matches the user's
+ args.push('-e', `TZ=${TIMEZONE}`);
+
+ // Run as host user so bind-mounted files are accessible.
+ // Skip when running as root (uid 0), as the container's node user (uid 1000),
+ // or when getuid is unavailable (native Windows without WSL).
+ const hostUid = process.getuid?.();
+ const hostGid = process.getgid?.();
+ if (hostUid != null && hostUid !== 0 && hostUid !== 1000) {
+ args.push('--user', `${hostUid}:${hostGid}`);
+ args.push('-e', 'HOME=/home/node');
+ }
+
+ for (const mount of mounts) {
+ if (mount.readonly) {
+ args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath));
+ } else {
+ args.push('-v', `${mount.hostPath}:${mount.containerPath}`);
+ }
+ }
+
+ args.push(CONTAINER_IMAGE);
+
+ return args;
+}
+
+export async function runContainerAgent(
+ group: RegisteredGroup,
+ input: ContainerInput,
+ onProcess: (proc: ChildProcess, containerName: string) => void,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise {
+ const startTime = Date.now();
+
+ const groupDir = resolveGroupFolderPath(group.folder);
+ fs.mkdirSync(groupDir, { recursive: true });
+
+ const mounts = buildVolumeMounts(group, input.isMain);
+ const safeName = group.folder.replace(/[^a-zA-Z0-9-]/g, '-');
+ const containerName = `nanoclaw-${safeName}-${Date.now()}`;
+ const containerArgs = buildContainerArgs(mounts, containerName);
+
+ logger.debug(
+ {
+ group: group.name,
+ containerName,
+ mounts: mounts.map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ ),
+ containerArgs: containerArgs.join(' '),
+ },
+ 'Container mount configuration',
+ );
+
+ logger.info(
+ {
+ group: group.name,
+ containerName,
+ mountCount: mounts.length,
+ isMain: input.isMain,
+ },
+ 'Spawning container agent',
+ );
+
+ const logsDir = path.join(groupDir, 'logs');
+ fs.mkdirSync(logsDir, { recursive: true });
+
+ return new Promise((resolve) => {
+ const container = spawn(CONTAINER_RUNTIME_BIN, containerArgs, {
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+
+ onProcess(container, containerName);
+
+ let stdout = '';
+ let stderr = '';
+ let stdoutTruncated = false;
+ let stderrTruncated = false;
+
+ // Pass secrets via stdin (never written to disk or mounted as files)
+ input.secrets = readSecrets();
+ container.stdin.write(JSON.stringify(input));
+ container.stdin.end();
+ // Remove secrets from input so they don't appear in logs
+ delete input.secrets;
+
+ // Streaming output: parse OUTPUT_START/END marker pairs as they arrive
+ let parseBuffer = '';
+ let newSessionId: string | undefined;
+ let outputChain = Promise.resolve();
+
+ container.stdout.on('data', (data) => {
+ const chunk = data.toString();
+
+ // Always accumulate for logging
+ if (!stdoutTruncated) {
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stdout.length;
+ if (chunk.length > remaining) {
+ stdout += chunk.slice(0, remaining);
+ stdoutTruncated = true;
+ logger.warn(
+ { group: group.name, size: stdout.length },
+ 'Container stdout truncated due to size limit',
+ );
+ } else {
+ stdout += chunk;
+ }
+ }
+
+ // Stream-parse for output markers
+ if (onOutput) {
+ parseBuffer += chunk;
+ let startIdx: number;
+ while ((startIdx = parseBuffer.indexOf(OUTPUT_START_MARKER)) !== -1) {
+ const endIdx = parseBuffer.indexOf(OUTPUT_END_MARKER, startIdx);
+ if (endIdx === -1) break; // Incomplete pair, wait for more data
+
+ const jsonStr = parseBuffer
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ parseBuffer = parseBuffer.slice(endIdx + OUTPUT_END_MARKER.length);
+
+ try {
+ const parsed: ContainerOutput = JSON.parse(jsonStr);
+ if (parsed.newSessionId) {
+ newSessionId = parsed.newSessionId;
+ }
+ hadStreamingOutput = true;
+ // Activity detected — reset the hard timeout
+ resetTimeout();
+ // Call onOutput for all markers (including null results)
+ // so idle timers start even for "silent" query completions.
+ outputChain = outputChain.then(() => onOutput(parsed));
+ } catch (err) {
+ logger.warn(
+ { group: group.name, error: err },
+ 'Failed to parse streamed output chunk',
+ );
+ }
+ }
+ }
+ });
+
+ container.stderr.on('data', (data) => {
+ const chunk = data.toString();
+ const lines = chunk.trim().split('\n');
+ for (const line of lines) {
+ if (line) logger.debug({ container: group.folder }, line);
+ }
+ // Don't reset timeout on stderr — SDK writes debug logs continuously.
+ // Timeout only resets on actual output (OUTPUT_MARKER in stdout).
+ if (stderrTruncated) return;
+ const remaining = CONTAINER_MAX_OUTPUT_SIZE - stderr.length;
+ if (chunk.length > remaining) {
+ stderr += chunk.slice(0, remaining);
+ stderrTruncated = true;
+ logger.warn(
+ { group: group.name, size: stderr.length },
+ 'Container stderr truncated due to size limit',
+ );
+ } else {
+ stderr += chunk;
+ }
+ });
+
+ let timedOut = false;
+ let hadStreamingOutput = false;
+ const configTimeout = group.containerConfig?.timeout || CONTAINER_TIMEOUT;
+ // Grace period: hard timeout must be at least IDLE_TIMEOUT + 30s so the
+ // graceful _close sentinel has time to trigger before the hard kill fires.
+ const timeoutMs = Math.max(configTimeout, IDLE_TIMEOUT + 30_000);
+
+ const killOnTimeout = () => {
+ timedOut = true;
+ logger.error(
+ { 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');
+ }
+ });
+ };
+
+ let timeout = setTimeout(killOnTimeout, timeoutMs);
+
+ // Reset the timeout whenever there's activity (streaming output)
+ const resetTimeout = () => {
+ clearTimeout(timeout);
+ timeout = setTimeout(killOnTimeout, timeoutMs);
+ };
+
+ container.on('close', (code) => {
+ clearTimeout(timeout);
+ const duration = Date.now() - startTime;
+
+ if (timedOut) {
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
+ const timeoutLog = path.join(logsDir, `container-${ts}.log`);
+ fs.writeFileSync(
+ timeoutLog,
+ [
+ `=== Container Run Log (TIMEOUT) ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `Container: ${containerName}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Had Streaming Output: ${hadStreamingOutput}`,
+ ].join('\n'),
+ );
+
+ // Timeout after output = idle cleanup, not failure.
+ // The agent already sent its response; this is just the
+ // container being reaped after the idle period expired.
+ if (hadStreamingOutput) {
+ logger.info(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out after output (idle cleanup)',
+ );
+ outputChain.then(() => {
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ logger.error(
+ { group: group.name, containerName, duration, code },
+ 'Container timed out with no output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container timed out after ${configTimeout}ms`,
+ });
+ return;
+ }
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
+ const logFile = path.join(logsDir, `container-${timestamp}.log`);
+ const isVerbose =
+ process.env.LOG_LEVEL === 'debug' || process.env.LOG_LEVEL === 'trace';
+
+ const logLines = [
+ `=== Container Run Log ===`,
+ `Timestamp: ${new Date().toISOString()}`,
+ `Group: ${group.name}`,
+ `IsMain: ${input.isMain}`,
+ `Duration: ${duration}ms`,
+ `Exit Code: ${code}`,
+ `Stdout Truncated: ${stdoutTruncated}`,
+ `Stderr Truncated: ${stderrTruncated}`,
+ ``,
+ ];
+
+ const isError = code !== 0;
+
+ if (isVerbose || isError) {
+ logLines.push(
+ `=== Input ===`,
+ JSON.stringify(input, null, 2),
+ ``,
+ `=== Container Args ===`,
+ containerArgs.join(' '),
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map(
+ (m) =>
+ `${m.hostPath} -> ${m.containerPath}${m.readonly ? ' (ro)' : ''}`,
+ )
+ .join('\n'),
+ ``,
+ `=== Stderr${stderrTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stderr,
+ ``,
+ `=== Stdout${stdoutTruncated ? ' (TRUNCATED)' : ''} ===`,
+ stdout,
+ );
+ } else {
+ logLines.push(
+ `=== Input Summary ===`,
+ `Prompt length: ${input.prompt.length} chars`,
+ `Session ID: ${input.sessionId || 'new'}`,
+ ``,
+ `=== Mounts ===`,
+ mounts
+ .map((m) => `${m.containerPath}${m.readonly ? ' (ro)' : ''}`)
+ .join('\n'),
+ ``,
+ );
+ }
+
+ fs.writeFileSync(logFile, logLines.join('\n'));
+ logger.debug({ logFile, verbose: isVerbose }, 'Container log written');
+
+ if (code !== 0) {
+ logger.error(
+ {
+ group: group.name,
+ code,
+ duration,
+ stderr,
+ stdout,
+ logFile,
+ },
+ 'Container exited with error',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container exited with code ${code}: ${stderr.slice(-200)}`,
+ });
+ return;
+ }
+
+ // Streaming mode: wait for output chain to settle, return completion marker
+ if (onOutput) {
+ outputChain.then(() => {
+ logger.info(
+ { group: group.name, duration, newSessionId },
+ 'Container completed (streaming mode)',
+ );
+ resolve({
+ status: 'success',
+ result: null,
+ newSessionId,
+ });
+ });
+ return;
+ }
+
+ // Legacy mode: parse the last output marker pair from accumulated stdout
+ try {
+ // Extract JSON between sentinel markers for robust parsing
+ const startIdx = stdout.indexOf(OUTPUT_START_MARKER);
+ const endIdx = stdout.indexOf(OUTPUT_END_MARKER);
+
+ let jsonLine: string;
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
+ jsonLine = stdout
+ .slice(startIdx + OUTPUT_START_MARKER.length, endIdx)
+ .trim();
+ } else {
+ // Fallback: last non-empty line (backwards compatibility)
+ const lines = stdout.trim().split('\n');
+ jsonLine = lines[lines.length - 1];
+ }
+
+ const output: ContainerOutput = JSON.parse(jsonLine);
+
+ logger.info(
+ {
+ group: group.name,
+ duration,
+ status: output.status,
+ hasResult: !!output.result,
+ },
+ 'Container completed',
+ );
+
+ resolve(output);
+ } catch (err) {
+ logger.error(
+ {
+ group: group.name,
+ stdout,
+ stderr,
+ error: err,
+ },
+ 'Failed to parse container output',
+ );
+
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Failed to parse container output: ${err instanceof Error ? err.message : String(err)}`,
+ });
+ }
+ });
+
+ container.on('error', (err) => {
+ clearTimeout(timeout);
+ logger.error(
+ { group: group.name, containerName, error: err },
+ 'Container spawn error',
+ );
+ resolve({
+ status: 'error',
+ result: null,
+ error: `Container spawn error: ${err.message}`,
+ });
+ });
+ });
+}
+
+export function writeTasksSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ tasks: Array<{
+ id: string;
+ groupFolder: string;
+ prompt: string;
+ schedule_type: string;
+ schedule_value: string;
+ status: string;
+ next_run: string | null;
+ }>,
+): void {
+ // Write filtered tasks to the group's IPC directory
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all tasks, others only see their own
+ const filteredTasks = isMain
+ ? tasks
+ : tasks.filter((t) => t.groupFolder === groupFolder);
+
+ const tasksFile = path.join(groupIpcDir, 'current_tasks.json');
+ fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2));
+}
+
+export interface AvailableGroup {
+ jid: string;
+ name: string;
+ lastActivity: string;
+ isRegistered: boolean;
+}
+
+/**
+ * Write available groups snapshot for the container to read.
+ * Only main group can see all available groups (for activation).
+ * Non-main groups only see their own registration status.
+ */
+export function writeGroupsSnapshot(
+ groupFolder: string,
+ isMain: boolean,
+ groups: AvailableGroup[],
+ registeredJids: Set,
+): void {
+ const groupIpcDir = resolveGroupIpcPath(groupFolder);
+ fs.mkdirSync(groupIpcDir, { recursive: true });
+
+ // Main sees all groups; others see nothing (they can't activate groups)
+ const visibleGroups = isMain ? groups : [];
+
+ const groupsFile = path.join(groupIpcDir, 'available_groups.json');
+ fs.writeFileSync(
+ groupsFile,
+ JSON.stringify(
+ {
+ groups: visibleGroups,
+ lastSync: new Date().toISOString(),
+ },
+ null,
+ 2,
+ ),
+ );
+}
diff --git a/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md b/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md
new file mode 100644
index 0000000..d30f24f
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/container-runner.ts.intent.md
@@ -0,0 +1,15 @@
+# Intent: src/container-runner.ts
+
+## What Changed
+- Added `imageAttachments?` optional field to `ContainerInput` interface
+
+## Key Sections
+- **ContainerInput interface**: imageAttachments optional field (`Array<{ relativePath: string; mediaType: string }>`)
+
+## Invariants (must-keep)
+- ContainerOutput interface unchanged
+- buildContainerArgs structure (run, -i, --rm, --name, mounts, image)
+- runContainerAgent with streaming output parsing (OUTPUT_START/END markers)
+- writeTasksSnapshot, writeGroupsSnapshot functions
+- Additional mounts via validateAdditionalMounts
+- Mount security validation against external allowlist
diff --git a/.claude/skills/add-image-vision/modify/src/index.ts b/.claude/skills/add-image-vision/modify/src/index.ts
new file mode 100644
index 0000000..2073a4d
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/index.ts
@@ -0,0 +1,590 @@
+import fs from 'fs';
+import path from 'path';
+
+import {
+ ASSISTANT_NAME,
+ IDLE_TIMEOUT,
+ POLL_INTERVAL,
+ TRIGGER_PATTERN,
+} from './config.js';
+import './channels/index.js';
+import {
+ getChannelFactory,
+ getRegisteredChannelNames,
+} from './channels/registry.js';
+import {
+ ContainerOutput,
+ runContainerAgent,
+ writeGroupsSnapshot,
+ writeTasksSnapshot,
+} from './container-runner.js';
+import {
+ cleanupOrphans,
+ ensureContainerRuntimeRunning,
+} from './container-runtime.js';
+import {
+ getAllChats,
+ getAllRegisteredGroups,
+ getAllSessions,
+ getAllTasks,
+ getMessagesSince,
+ getNewMessages,
+ getRouterState,
+ initDatabase,
+ setRegisteredGroup,
+ setRouterState,
+ setSession,
+ storeChatMetadata,
+ storeMessage,
+} from './db.js';
+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 {
+ isSenderAllowed,
+ isTriggerAllowed,
+ loadSenderAllowlist,
+ shouldDropMessage,
+} from './sender-allowlist.js';
+import { startSchedulerLoop } from './task-scheduler.js';
+import { Channel, NewMessage, RegisteredGroup } from './types.js';
+import { parseImageReferences } from './image.js';
+import { logger } from './logger.js';
+
+// Re-export for backwards compatibility during refactor
+export { escapeXml, formatMessages } from './router.js';
+
+let lastTimestamp = '';
+let sessions: Record = {};
+let registeredGroups: Record = {};
+let lastAgentTimestamp: Record = {};
+let messageLoopRunning = false;
+
+const channels: Channel[] = [];
+const queue = new GroupQueue();
+
+function loadState(): void {
+ lastTimestamp = getRouterState('last_timestamp') || '';
+ const agentTs = getRouterState('last_agent_timestamp');
+ try {
+ lastAgentTimestamp = agentTs ? JSON.parse(agentTs) : {};
+ } catch {
+ logger.warn('Corrupted last_agent_timestamp in DB, resetting');
+ lastAgentTimestamp = {};
+ }
+ sessions = getAllSessions();
+ registeredGroups = getAllRegisteredGroups();
+ logger.info(
+ { groupCount: Object.keys(registeredGroups).length },
+ 'State loaded',
+ );
+}
+
+function saveState(): void {
+ setRouterState('last_timestamp', lastTimestamp);
+ setRouterState('last_agent_timestamp', JSON.stringify(lastAgentTimestamp));
+}
+
+function registerGroup(jid: string, group: RegisteredGroup): void {
+ let groupDir: string;
+ try {
+ groupDir = resolveGroupFolderPath(group.folder);
+ } catch (err) {
+ logger.warn(
+ { jid, folder: group.folder, err },
+ 'Rejecting group registration with invalid folder',
+ );
+ return;
+ }
+
+ registeredGroups[jid] = group;
+ setRegisteredGroup(jid, group);
+
+ // Create group folder
+ fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true });
+
+ logger.info(
+ { jid, name: group.name, folder: group.folder },
+ 'Group registered',
+ );
+}
+
+/**
+ * Get available groups list for the agent.
+ * Returns groups ordered by most recent activity.
+ */
+export function getAvailableGroups(): import('./container-runner.js').AvailableGroup[] {
+ const chats = getAllChats();
+ const registeredJids = new Set(Object.keys(registeredGroups));
+
+ return chats
+ .filter((c) => c.jid !== '__group_sync__' && c.is_group)
+ .map((c) => ({
+ jid: c.jid,
+ name: c.name,
+ lastActivity: c.last_message_time,
+ isRegistered: registeredJids.has(c.jid),
+ }));
+}
+
+/** @internal - exported for testing */
+export function _setRegisteredGroups(
+ groups: Record,
+): void {
+ registeredGroups = groups;
+}
+
+/**
+ * Process all pending messages for a group.
+ * Called by the GroupQueue when it's this group's turn.
+ */
+async function processGroupMessages(chatJid: string): Promise {
+ const group = registeredGroups[chatJid];
+ if (!group) return true;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
+ return true;
+ }
+
+ const isMainGroup = group.isMain === true;
+
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const missedMessages = getMessagesSince(
+ chatJid,
+ sinceTimestamp,
+ ASSISTANT_NAME,
+ );
+
+ if (missedMessages.length === 0) return true;
+
+ // For non-main groups, check if trigger is required and present
+ if (!isMainGroup && group.requiresTrigger !== false) {
+ const allowlistCfg = loadSenderAllowlist();
+ const hasTrigger = missedMessages.some(
+ (m) =>
+ TRIGGER_PATTERN.test(m.content.trim()) &&
+ (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
+ );
+ if (!hasTrigger) return true;
+ }
+
+ const prompt = formatMessages(missedMessages);
+ const imageAttachments = parseImageReferences(missedMessages);
+
+ // Advance cursor so the piping path in startMessageLoop won't re-fetch
+ // these messages. Save the old cursor so we can roll back on error.
+ const previousCursor = lastAgentTimestamp[chatJid] || '';
+ lastAgentTimestamp[chatJid] =
+ missedMessages[missedMessages.length - 1].timestamp;
+ saveState();
+
+ logger.info(
+ { group: group.name, messageCount: missedMessages.length },
+ 'Processing messages',
+ );
+
+ // Track idle timer for closing stdin when agent is idle
+ let idleTimer: ReturnType | null = null;
+
+ const resetIdleTimer = () => {
+ if (idleTimer) clearTimeout(idleTimer);
+ idleTimer = setTimeout(() => {
+ logger.debug(
+ { group: group.name },
+ 'Idle timeout, closing container stdin',
+ );
+ queue.closeStdin(chatJid);
+ }, IDLE_TIMEOUT);
+ };
+
+ await channel.setTyping?.(chatJid, true);
+ let hadError = false;
+ let outputSentToUser = false;
+
+ const output = await runAgent(group, prompt, chatJid, imageAttachments, async (result) => {
+ // Streaming output callback — called for each agent result
+ if (result.result) {
+ const raw =
+ typeof result.result === 'string'
+ ? result.result
+ : JSON.stringify(result.result);
+ // Strip ... blocks — agent uses these for internal reasoning
+ const text = raw.replace(/[\s\S]*?<\/internal>/g, '').trim();
+ logger.info({ group: group.name }, `Agent output: ${raw.slice(0, 200)}`);
+ if (text) {
+ await channel.sendMessage(chatJid, text);
+ outputSentToUser = true;
+ }
+ // Only reset idle timer on actual results, not session-update markers (result: null)
+ resetIdleTimer();
+ }
+
+ if (result.status === 'success') {
+ queue.notifyIdle(chatJid);
+ }
+
+ if (result.status === 'error') {
+ hadError = true;
+ }
+ });
+
+ await channel.setTyping?.(chatJid, false);
+ if (idleTimer) clearTimeout(idleTimer);
+
+ if (output === 'error' || hadError) {
+ // If we already sent output to the user, don't roll back the cursor —
+ // the user got their response and re-processing would send duplicates.
+ if (outputSentToUser) {
+ logger.warn(
+ { group: group.name },
+ 'Agent error after output was sent, skipping cursor rollback to prevent duplicates',
+ );
+ return true;
+ }
+ // Roll back cursor so retries can re-process these messages
+ lastAgentTimestamp[chatJid] = previousCursor;
+ saveState();
+ logger.warn(
+ { group: group.name },
+ 'Agent error, rolled back message cursor for retry',
+ );
+ return false;
+ }
+
+ return true;
+}
+
+async function runAgent(
+ group: RegisteredGroup,
+ prompt: string,
+ chatJid: string,
+ imageAttachments: Array<{ relativePath: string; mediaType: string }>,
+ onOutput?: (output: ContainerOutput) => Promise,
+): Promise<'success' | 'error'> {
+ const isMain = group.isMain === true;
+ const sessionId = sessions[group.folder];
+
+ // Update tasks snapshot for container to read (filtered by group)
+ const tasks = getAllTasks();
+ writeTasksSnapshot(
+ group.folder,
+ isMain,
+ tasks.map((t) => ({
+ id: t.id,
+ groupFolder: t.group_folder,
+ prompt: t.prompt,
+ schedule_type: t.schedule_type,
+ schedule_value: t.schedule_value,
+ status: t.status,
+ next_run: t.next_run,
+ })),
+ );
+
+ // Update available groups snapshot (main group only can see all groups)
+ const availableGroups = getAvailableGroups();
+ writeGroupsSnapshot(
+ group.folder,
+ isMain,
+ availableGroups,
+ new Set(Object.keys(registeredGroups)),
+ );
+
+ // Wrap onOutput to track session ID from streamed results
+ const wrappedOnOutput = onOutput
+ ? async (output: ContainerOutput) => {
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+ await onOutput(output);
+ }
+ : undefined;
+
+ try {
+ const output = await runContainerAgent(
+ group,
+ {
+ prompt,
+ sessionId,
+ groupFolder: group.folder,
+ chatJid,
+ isMain,
+ assistantName: ASSISTANT_NAME,
+ ...(imageAttachments.length > 0 && { imageAttachments }),
+ },
+ (proc, containerName) =>
+ queue.registerProcess(chatJid, proc, containerName, group.folder),
+ wrappedOnOutput,
+ );
+
+ if (output.newSessionId) {
+ sessions[group.folder] = output.newSessionId;
+ setSession(group.folder, output.newSessionId);
+ }
+
+ if (output.status === 'error') {
+ logger.error(
+ { group: group.name, error: output.error },
+ 'Container agent error',
+ );
+ return 'error';
+ }
+
+ return 'success';
+ } catch (err) {
+ logger.error({ group: group.name, err }, 'Agent error');
+ return 'error';
+ }
+}
+
+async function startMessageLoop(): Promise {
+ if (messageLoopRunning) {
+ logger.debug('Message loop already running, skipping duplicate start');
+ return;
+ }
+ messageLoopRunning = true;
+
+ logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`);
+
+ while (true) {
+ try {
+ const jids = Object.keys(registeredGroups);
+ const { messages, newTimestamp } = getNewMessages(
+ jids,
+ lastTimestamp,
+ ASSISTANT_NAME,
+ );
+
+ if (messages.length > 0) {
+ logger.info({ count: messages.length }, 'New messages');
+
+ // Advance the "seen" cursor for all messages immediately
+ lastTimestamp = newTimestamp;
+ saveState();
+
+ // Deduplicate by group
+ const messagesByGroup = new Map();
+ for (const msg of messages) {
+ const existing = messagesByGroup.get(msg.chat_jid);
+ if (existing) {
+ existing.push(msg);
+ } else {
+ messagesByGroup.set(msg.chat_jid, [msg]);
+ }
+ }
+
+ for (const [chatJid, groupMessages] of messagesByGroup) {
+ const group = registeredGroups[chatJid];
+ if (!group) continue;
+
+ const channel = findChannel(channels, chatJid);
+ if (!channel) {
+ logger.warn({ chatJid }, 'No channel owns JID, skipping messages');
+ continue;
+ }
+
+ const isMainGroup = group.isMain === true;
+ const needsTrigger = !isMainGroup && group.requiresTrigger !== false;
+
+ // For non-main groups, only act on trigger messages.
+ // Non-trigger messages accumulate in DB and get pulled as
+ // context when a trigger eventually arrives.
+ if (needsTrigger) {
+ const allowlistCfg = loadSenderAllowlist();
+ const hasTrigger = groupMessages.some(
+ (m) =>
+ TRIGGER_PATTERN.test(m.content.trim()) &&
+ (m.is_from_me ||
+ isTriggerAllowed(chatJid, m.sender, allowlistCfg)),
+ );
+ if (!hasTrigger) continue;
+ }
+
+ // Pull all messages since lastAgentTimestamp so non-trigger
+ // context that accumulated between triggers is included.
+ const allPending = getMessagesSince(
+ chatJid,
+ lastAgentTimestamp[chatJid] || '',
+ ASSISTANT_NAME,
+ );
+ const messagesToSend =
+ allPending.length > 0 ? allPending : groupMessages;
+ const formatted = formatMessages(messagesToSend);
+
+ if (queue.sendMessage(chatJid, formatted)) {
+ logger.debug(
+ { chatJid, count: messagesToSend.length },
+ 'Piped messages to active container',
+ );
+ lastAgentTimestamp[chatJid] =
+ messagesToSend[messagesToSend.length - 1].timestamp;
+ saveState();
+ // Show typing indicator while the container processes the piped message
+ channel
+ .setTyping?.(chatJid, true)
+ ?.catch((err) =>
+ logger.warn({ chatJid, err }, 'Failed to set typing indicator'),
+ );
+ } else {
+ // No active container — enqueue for a new one
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+ }
+ } catch (err) {
+ logger.error({ err }, 'Error in message loop');
+ }
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
+ }
+}
+
+/**
+ * Startup recovery: check for unprocessed messages in registered groups.
+ * Handles crash between advancing lastTimestamp and processing messages.
+ */
+function recoverPendingMessages(): void {
+ for (const [chatJid, group] of Object.entries(registeredGroups)) {
+ const sinceTimestamp = lastAgentTimestamp[chatJid] || '';
+ const pending = getMessagesSince(chatJid, sinceTimestamp, ASSISTANT_NAME);
+ if (pending.length > 0) {
+ logger.info(
+ { group: group.name, pendingCount: pending.length },
+ 'Recovery: found unprocessed messages',
+ );
+ queue.enqueueMessageCheck(chatJid);
+ }
+ }
+}
+
+function ensureContainerSystemRunning(): void {
+ ensureContainerRuntimeRunning();
+ cleanupOrphans();
+}
+
+async function main(): Promise {
+ ensureContainerSystemRunning();
+ initDatabase();
+ logger.info('Database initialized');
+ loadState();
+
+ // Graceful shutdown handlers
+ const shutdown = async (signal: string) => {
+ logger.info({ signal }, 'Shutdown signal received');
+ await queue.shutdown(10000);
+ for (const ch of channels) await ch.disconnect();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
+ process.on('SIGINT', () => shutdown('SIGINT'));
+
+ // Channel callbacks (shared by all channels)
+ const channelOpts = {
+ onMessage: (chatJid: string, msg: NewMessage) => {
+ // 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();
+ if (
+ shouldDropMessage(chatJid, cfg) &&
+ !isSenderAllowed(chatJid, msg.sender, cfg)
+ ) {
+ if (cfg.logDenied) {
+ logger.debug(
+ { chatJid, sender: msg.sender },
+ 'sender-allowlist: dropping message (drop mode)',
+ );
+ }
+ return;
+ }
+ }
+ storeMessage(msg);
+ },
+ onChatMetadata: (
+ chatJid: string,
+ timestamp: string,
+ name?: string,
+ channel?: string,
+ isGroup?: boolean,
+ ) => storeChatMetadata(chatJid, timestamp, name, channel, isGroup),
+ registeredGroups: () => registeredGroups,
+ };
+
+ // Create and connect all registered channels.
+ // Each channel self-registers via the barrel import above.
+ // Factories return null when credentials are missing, so unconfigured channels are skipped.
+ for (const channelName of getRegisteredChannelNames()) {
+ const factory = getChannelFactory(channelName)!;
+ const channel = factory(channelOpts);
+ if (!channel) {
+ logger.warn(
+ { channel: channelName },
+ 'Channel installed but credentials missing — skipping. Check .env or re-run the channel skill.',
+ );
+ continue;
+ }
+ channels.push(channel);
+ await channel.connect();
+ }
+ if (channels.length === 0) {
+ logger.fatal('No channels connected');
+ process.exit(1);
+ }
+
+ // Start subsystems (independently of connection handler)
+ startSchedulerLoop({
+ registeredGroups: () => registeredGroups,
+ getSessions: () => sessions,
+ queue,
+ onProcess: (groupJid, proc, containerName, groupFolder) =>
+ queue.registerProcess(groupJid, proc, containerName, groupFolder),
+ sendMessage: async (jid, rawText) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) {
+ logger.warn({ jid }, 'No channel owns JID, cannot send message');
+ return;
+ }
+ const text = formatOutbound(rawText);
+ if (text) await channel.sendMessage(jid, text);
+ },
+ });
+ startIpcWatcher({
+ sendMessage: (jid, text) => {
+ const channel = findChannel(channels, jid);
+ if (!channel) throw new Error(`No channel for JID: ${jid}`);
+ return channel.sendMessage(jid, text);
+ },
+ registeredGroups: () => registeredGroups,
+ registerGroup,
+ syncGroups: async (force: boolean) => {
+ await Promise.all(
+ channels
+ .filter((ch) => ch.syncGroups)
+ .map((ch) => ch.syncGroups!(force)),
+ );
+ },
+ getAvailableGroups,
+ writeGroupsSnapshot: (gf, im, ag, rj) =>
+ writeGroupsSnapshot(gf, im, ag, rj),
+ });
+ queue.setProcessMessagesFn(processGroupMessages);
+ recoverPendingMessages();
+ startMessageLoop().catch((err) => {
+ logger.fatal({ err }, 'Message loop crashed unexpectedly');
+ process.exit(1);
+ });
+}
+
+// Guard: only run when executed directly, not when imported by tests
+const isDirectRun =
+ process.argv[1] &&
+ new URL(import.meta.url).pathname ===
+ new URL(`file://${process.argv[1]}`).pathname;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ logger.error({ err }, 'Failed to start NanoClaw');
+ process.exit(1);
+ });
+}
diff --git a/.claude/skills/add-image-vision/modify/src/index.ts.intent.md b/.claude/skills/add-image-vision/modify/src/index.ts.intent.md
new file mode 100644
index 0000000..195b618
--- /dev/null
+++ b/.claude/skills/add-image-vision/modify/src/index.ts.intent.md
@@ -0,0 +1,24 @@
+# Intent: src/index.ts
+
+## What Changed
+- Added `import { parseImageReferences } from './image.js'`
+- In `processGroupMessages`: extract image references after formatting, pass `imageAttachments` to `runAgent`
+- In `runAgent`: added `imageAttachments` parameter, conditionally spread into `runContainerAgent` input
+
+## Key Sections
+- **Imports** (top of file): parseImageReferences
+- **processGroupMessages**: Image extraction, threading to runAgent
+- **runAgent**: Signature change + imageAttachments in input
+
+## Invariants (must-keep)
+- State management (lastTimestamp, sessions, registeredGroups, lastAgentTimestamp)
+- loadState/saveState functions
+- registerGroup function with folder validation
+- getAvailableGroups function
+- processGroupMessages trigger logic, cursor management, idle timer, error rollback with duplicate prevention
+- runAgent task/group snapshot writes, session tracking, wrappedOnOutput
+- startMessageLoop with dedup-by-group and piping logic
+- recoverPendingMessages startup recovery
+- main() with channel setup, scheduler, IPC watcher, queue
+- ensureContainerSystemRunning using container-runtime abstraction
+- Graceful shutdown with queue.shutdown
diff --git a/.claude/skills/add-image-vision/tests/image-vision.test.ts b/.claude/skills/add-image-vision/tests/image-vision.test.ts
new file mode 100644
index 0000000..e575ed4
--- /dev/null
+++ b/.claude/skills/add-image-vision/tests/image-vision.test.ts
@@ -0,0 +1,297 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+import fs from 'fs';
+import path from 'path';
+
+const SKILL_DIR = path.resolve(__dirname, '..');
+
+describe('add-image-vision skill package', () => {
+ describe('manifest', () => {
+ let content: string;
+
+ beforeAll(() => {
+ content = fs.readFileSync(path.join(SKILL_DIR, 'manifest.yaml'), 'utf-8');
+ });
+
+ it('has a valid manifest.yaml', () => {
+ expect(fs.existsSync(path.join(SKILL_DIR, 'manifest.yaml'))).toBe(true);
+ expect(content).toContain('skill: add-image-vision');
+ expect(content).toContain('version: 1.1.0');
+ });
+
+ it('declares sharp as npm dependency', () => {
+ expect(content).toContain('sharp:');
+ expect(content).toMatch(/sharp:\s*"\^0\.34/);
+ });
+
+ it('has no env_additions', () => {
+ expect(content).toContain('env_additions: []');
+ });
+
+ it('lists all add files', () => {
+ expect(content).toContain('src/image.ts');
+ expect(content).toContain('src/image.test.ts');
+ });
+
+ it('lists all modify files', () => {
+ expect(content).toContain('src/channels/whatsapp.ts');
+ expect(content).toContain('src/channels/whatsapp.test.ts');
+ expect(content).toContain('src/container-runner.ts');
+ expect(content).toContain('src/index.ts');
+ expect(content).toContain('container/agent-runner/src/index.ts');
+ });
+
+ it('has no dependencies', () => {
+ expect(content).toContain('depends: []');
+ });
+ });
+
+ describe('add/ files', () => {
+ it('includes src/image.ts with required exports', () => {
+ const filePath = path.join(SKILL_DIR, 'add', 'src', 'image.ts');
+ expect(fs.existsSync(filePath)).toBe(true);
+
+ const content = fs.readFileSync(filePath, 'utf-8');
+ expect(content).toContain('export function isImageMessage');
+ expect(content).toContain('export async function processImage');
+ expect(content).toContain('export function parseImageReferences');
+ expect(content).toContain('export interface ProcessedImage');
+ expect(content).toContain('export interface ImageAttachment');
+ expect(content).toContain("import sharp from 'sharp'");
+ });
+
+ it('includes src/image.test.ts with test cases', () => {
+ const filePath = path.join(SKILL_DIR, 'add', 'src', 'image.test.ts');
+ expect(fs.existsSync(filePath)).toBe(true);
+
+ const content = fs.readFileSync(filePath, 'utf-8');
+ expect(content).toContain('isImageMessage');
+ expect(content).toContain('processImage');
+ expect(content).toContain('parseImageReferences');
+ });
+ });
+
+ describe('modify/ files exist', () => {
+ const modifyFiles = [
+ 'src/channels/whatsapp.ts',
+ 'src/channels/whatsapp.test.ts',
+ 'src/container-runner.ts',
+ 'src/index.ts',
+ 'container/agent-runner/src/index.ts',
+ ];
+
+ for (const file of modifyFiles) {
+ it(`includes modify/${file}`, () => {
+ const filePath = path.join(SKILL_DIR, 'modify', file);
+ expect(fs.existsSync(filePath)).toBe(true);
+ });
+ }
+ });
+
+ describe('intent files exist', () => {
+ const intentFiles = [
+ 'src/channels/whatsapp.ts.intent.md',
+ 'src/channels/whatsapp.test.ts.intent.md',
+ 'src/container-runner.ts.intent.md',
+ 'src/index.ts.intent.md',
+ 'container/agent-runner/src/index.ts.intent.md',
+ ];
+
+ for (const file of intentFiles) {
+ it(`includes modify/${file}`, () => {
+ const filePath = path.join(SKILL_DIR, 'modify', file);
+ expect(fs.existsSync(filePath)).toBe(true);
+ });
+ }
+ });
+
+ describe('modify/src/channels/whatsapp.ts', () => {
+ let content: string;
+
+ beforeAll(() => {
+ content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'src', 'channels', 'whatsapp.ts'),
+ 'utf-8',
+ );
+ });
+
+ it('imports image utilities', () => {
+ expect(content).toContain("from '../image.js'");
+ expect(content).toContain('processImage');
+ });
+
+ it('imports downloadMediaMessage', () => {
+ expect(content).toContain('downloadMediaMessage');
+ expect(content).toContain("from '@whiskeysockets/baileys'");
+ });
+
+ it('imports GROUPS_DIR from config', () => {
+ expect(content).toContain('GROUPS_DIR');
+ });
+
+ it('uses let content for mutable assignment', () => {
+ expect(content).toMatch(/let content\s*=/);
+ });
+
+ it('includes image processing block', () => {
+ expect(content).toContain('processImage(buffer');
+ expect(content).toContain('Image - download failed');
+ });
+
+ it('preserves core WhatsAppChannel structure', () => {
+ expect(content).toContain('export class WhatsAppChannel implements Channel');
+ expect(content).toContain('async connect()');
+ expect(content).toContain('async sendMessage(');
+ expect(content).toContain('async syncGroupMetadata(');
+ expect(content).toContain('private async translateJid(');
+ expect(content).toContain('private async flushOutgoingQueue(');
+ });
+ });
+
+ describe('modify/src/channels/whatsapp.test.ts', () => {
+ let content: string;
+
+ beforeAll(() => {
+ content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'src', 'channels', 'whatsapp.test.ts'),
+ 'utf-8',
+ );
+ });
+
+ it('mocks image.js module', () => {
+ expect(content).toContain("vi.mock('../image.js'");
+ expect(content).toContain('isImageMessage');
+ expect(content).toContain('processImage');
+ });
+
+ it('mocks downloadMediaMessage', () => {
+ expect(content).toContain('downloadMediaMessage');
+ });
+
+ it('includes image test cases', () => {
+ expect(content).toContain('downloads and processes image attachments');
+ expect(content).toContain('handles image without caption');
+ expect(content).toContain('handles image download failure gracefully');
+ expect(content).toContain('falls back to caption when processImage returns null');
+ });
+
+ it('preserves all existing test sections', () => {
+ expect(content).toContain('connection lifecycle');
+ expect(content).toContain('authentication');
+ expect(content).toContain('reconnection');
+ expect(content).toContain('message handling');
+ expect(content).toContain('LID to JID translation');
+ expect(content).toContain('outgoing message queue');
+ expect(content).toContain('group metadata sync');
+ expect(content).toContain('ownsJid');
+ expect(content).toContain('setTyping');
+ expect(content).toContain('channel properties');
+ });
+
+ it('includes all media handling test sections', () => {
+ // Image tests present (core skill feature)
+ expect(content).toContain('downloads and processes image attachments');
+ expect(content).toContain('handles image without caption');
+ });
+ });
+
+ describe('modify/src/container-runner.ts', () => {
+ it('adds imageAttachments to ContainerInput', () => {
+ const content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'src', 'container-runner.ts'),
+ 'utf-8',
+ );
+ expect(content).toContain('imageAttachments?');
+ expect(content).toContain('relativePath: string');
+ expect(content).toContain('mediaType: string');
+ });
+
+ it('preserves core container-runner structure', () => {
+ const content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'src', 'container-runner.ts'),
+ 'utf-8',
+ );
+ expect(content).toContain('export async function runContainerAgent');
+ expect(content).toContain('ContainerInput');
+ });
+ });
+
+ describe('modify/src/index.ts', () => {
+ let content: string;
+
+ beforeAll(() => {
+ content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'src', 'index.ts'),
+ 'utf-8',
+ );
+ });
+
+ it('imports parseImageReferences', () => {
+ expect(content).toContain("import { parseImageReferences } from './image.js'");
+ });
+
+ it('calls parseImageReferences in processGroupMessages', () => {
+ expect(content).toContain('parseImageReferences(missedMessages)');
+ });
+
+ it('passes imageAttachments to runAgent', () => {
+ expect(content).toContain('imageAttachments');
+ expect(content).toMatch(/runAgent\(group,\s*prompt,\s*chatJid,\s*imageAttachments/);
+ });
+
+ it('spreads imageAttachments into container input', () => {
+ expect(content).toContain('...(imageAttachments.length > 0 && { imageAttachments })');
+ });
+
+ it('preserves core index.ts structure', () => {
+ expect(content).toContain('processGroupMessages');
+ expect(content).toContain('startMessageLoop');
+ expect(content).toContain('async function main()');
+ });
+ });
+
+ describe('modify/container/agent-runner/src/index.ts', () => {
+ let content: string;
+
+ beforeAll(() => {
+ content = fs.readFileSync(
+ path.join(SKILL_DIR, 'modify', 'container', 'agent-runner', 'src', 'index.ts'),
+ 'utf-8',
+ );
+ });
+
+ it('defines ContentBlock types', () => {
+ expect(content).toContain('interface ImageContentBlock');
+ expect(content).toContain('interface TextContentBlock');
+ expect(content).toContain('type ContentBlock = ImageContentBlock | TextContentBlock');
+ });
+
+ it('adds imageAttachments to ContainerInput', () => {
+ expect(content).toContain('imageAttachments?');
+ });
+
+ it('adds pushMultimodal to MessageStream', () => {
+ expect(content).toContain('pushMultimodal(content: ContentBlock[])');
+ });
+
+ it('includes image loading logic in runQuery', () => {
+ expect(content).toContain('containerInput.imageAttachments');
+ expect(content).toContain("path.join('/workspace/group', img.relativePath)");
+ expect(content).toContain("toString('base64')");
+ expect(content).toContain('stream.pushMultimodal(blocks)');
+ });
+
+ it('preserves core structure', () => {
+ expect(content).toContain('async function runQuery');
+ expect(content).toContain('class MessageStream');
+ expect(content).toContain('function writeOutput');
+ expect(content).toContain('function createPreCompactHook');
+ expect(content).toContain('function createSanitizeBashHook');
+ expect(content).toContain('async function main');
+ });
+
+ it('preserves core agent-runner exports', () => {
+ expect(content).toContain('async function main');
+ expect(content).toContain('function writeOutput');
+ });
+ });
+});
From cfabdd816bce30450abf965c328a4e8fe75d1c9b Mon Sep 17 00:00:00 2001
From: Thomas Lok <119156788+thomaslok0516@users.noreply.github.com>
Date: Sun, 8 Mar 2026 02:58:52 +0800
Subject: [PATCH 051/246] fix broken step references in setup/SKILL.md (#794)
---
.claude/skills/setup/SKILL.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md
index 024f8d5..b21a083 100644
--- a/.claude/skills/setup/SKILL.md
+++ b/.claude/skills/setup/SKILL.md
@@ -38,7 +38,7 @@ Run `npx tsx setup/index.ts --step environment` and parse the status block.
Check the preflight results for `APPLE_CONTAINER` and `DOCKER`, and the PLATFORM from step 1.
- PLATFORM=linux → Docker (only option)
-- PLATFORM=macos + APPLE_CONTAINER=installed → Use `AskUserQuestion: Docker (cross-platform) or Apple Container (native macOS)?` If Apple Container, run `/convert-to-apple-container` now, then skip to 4c.
+- 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=not_found → Docker
### 3a-docker. Install Docker
@@ -59,9 +59,9 @@ grep -q "CONTAINER_RUNTIME_BIN = 'container'" src/container-runtime.ts && echo "
**If NEEDS_CONVERSION**, the source code still uses Docker as the runtime. You MUST run the `/convert-to-apple-container` skill NOW, before proceeding to the build step.
-**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 4c.
+**If ALREADY_CONVERTED**, the code already uses Apple Container. Continue to 3c.
-**If the chosen runtime is Docker**, no conversion is needed. Continue to 4c.
+**If the chosen runtime is Docker**, no conversion is needed. Continue to 3c.
### 3c. Build and test
From 32dda34af49f8d70c188860f87961fe947bafa43 Mon Sep 17 00:00:00 2001
From: tomermesser
Date: Sun, 8 Mar 2026 16:38:03 +0200
Subject: [PATCH 052/246] status-icon-01
---
.claude/skills/add-statusbar/SKILL.md | 140 ++++++++++++++++++
.../add-statusbar/add/src/statusbar.swift | 139 +++++++++++++++++
.claude/skills/add-statusbar/manifest.yaml | 10 ++
3 files changed, 289 insertions(+)
create mode 100644 .claude/skills/add-statusbar/SKILL.md
create mode 100644 .claude/skills/add-statusbar/add/src/statusbar.swift
create mode 100644 .claude/skills/add-statusbar/manifest.yaml
diff --git a/.claude/skills/add-statusbar/SKILL.md b/.claude/skills/add-statusbar/SKILL.md
new file mode 100644
index 0000000..c0f343c
--- /dev/null
+++ b/.claude/skills/add-statusbar/SKILL.md
@@ -0,0 +1,140 @@
+---
+name: add-statusbar
+description: Add a macOS menu bar status indicator for NanoClaw. Shows a ⚡ 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-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 4 (Verify).
+
+## Phase 2: Apply Code Changes
+
+### Initialize skills system (if needed)
+
+If `.nanoclaw/` directory doesn't exist yet:
+
+```bash
+npx tsx scripts/apply-skill.ts --init
+```
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/add-statusbar
+```
+
+This copies `src/statusbar.swift` into the project and records the application in `.nanoclaw/state.yaml`.
+
+## Phase 3: Compile and Install
+
+### Compile the Swift binary
+
+```bash
+swiftc -O -o dist/statusbar src/statusbar.swift
+```
+
+This produces a small (~55KB) native binary at `dist/statusbar`.
+
+### Create the launchd plist
+
+Determine the absolute project root:
+
+```bash
+pwd
+```
+
+Create `~/Library/LaunchAgents/com.nanoclaw.statusbar.plist`, substituting the actual values for `{PROJECT_ROOT}` and `{HOME}`:
+
+```xml
+
+
+
+
+ Label
+ com.nanoclaw.statusbar
+ ProgramArguments
+
+ {PROJECT_ROOT}/dist/statusbar
+
+ RunAtLoad
+
+ KeepAlive
+
+ EnvironmentVariables
+
+ HOME
+ {HOME}
+
+ StandardOutPath
+ {PROJECT_ROOT}/logs/statusbar.log
+ StandardErrorPath
+ {PROJECT_ROOT}/logs/statusbar.error.log
+
+
+```
+
+### Load the service
+
+```bash
+launchctl load ~/Library/LaunchAgents/com.nanoclaw.statusbar.plist
+```
+
+## Phase 4: Verify
+
+```bash
+launchctl list | grep com.nanoclaw.statusbar
+```
+
+The first column should show a PID (not `-`).
+
+Tell the user:
+
+> The ⚡ 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
+rm src/statusbar.swift
+```
diff --git a/.claude/skills/add-statusbar/add/src/statusbar.swift b/.claude/skills/add-statusbar/add/src/statusbar.swift
new file mode 100644
index 0000000..6fff79a
--- /dev/null
+++ b/.claude/skills/add-statusbar/add/src/statusbar.swift
@@ -0,0 +1,139 @@
+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"
+
+ 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 = "\(NSHomeDirectory())/Documents/Projects/nanoclaw/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()
diff --git a/.claude/skills/add-statusbar/manifest.yaml b/.claude/skills/add-statusbar/manifest.yaml
new file mode 100644
index 0000000..0d7d720
--- /dev/null
+++ b/.claude/skills/add-statusbar/manifest.yaml
@@ -0,0 +1,10 @@
+skill: statusbar
+version: 1.0.0
+description: "macOS menu bar status indicator — shows NanoClaw running state with start/stop/restart controls"
+core_version: 0.1.0
+adds:
+ - src/statusbar.swift
+modifies: []
+structured: {}
+conflicts: []
+depends: []
From 5b2bafd7bb0c7ef35401aa18f4f696fec181a088 Mon Sep 17 00:00:00 2001
From: glifocat
Date: Sun, 8 Mar 2026 18:58:48 +0100
Subject: [PATCH 053/246] fix(whatsapp): use sender's JID for DM-with-bot
registration, skip trigger (#751)
Two bugs in the DM with dedicated bot number setup:
1. The skill asked for the bot's own phone number to use as the JID.
But from the bot's perspective, incoming DMs appear with the SENDER's
JID (the user's personal number), not the bot's own number. The
registration must use the user's personal number as the JID.
2. DM with bot (1:1 conversation) should use --no-trigger-required, same
as self-chat. A trigger prefix is unnecessary in a private DM.
Co-authored-by: Claude Sonnet 4.6
---
.claude/skills/add-whatsapp/SKILL.md | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md
index 660123a..023e748 100644
--- a/.claude/skills/add-whatsapp/SKILL.md
+++ b/.claude/skills/add-whatsapp/SKILL.md
@@ -201,7 +201,11 @@ AskUserQuestion: Where do you want to chat with the assistant?
node -e "const c=JSON.parse(require('fs').readFileSync('store/auth/creds.json','utf-8'));console.log(c.me?.id?.split(':')[0]+'@s.whatsapp.net')"
```
-**DM with bot:** Ask for the bot's phone number. JID = `NUMBER@s.whatsapp.net`
+**DM with bot:** The JID is the **user's** phone number — the number they will message *from* (not the bot's own number). Ask:
+
+AskUserQuestion: What is your personal phone number? (The number you'll use to message the bot — include country code without +, e.g. 1234567890)
+
+JID = `@s.whatsapp.net`
**Group (solo, existing):** Run group sync and list available groups:
@@ -223,7 +227,7 @@ npx tsx setup/index.ts --step register \
--channel whatsapp \
--assistant-name "" \
--is-main \
- --no-trigger-required # Only for main/self-chat
+ --no-trigger-required # For self-chat and DM with bot (1:1 conversations don't need a trigger prefix)
```
For additional groups (trigger-required):
From ab9abbb21a5d638b3dbd3a767e6e9057d93523c8 Mon Sep 17 00:00:00 2001
From: Yonatan Azrielant
Date: Sun, 8 Mar 2026 14:02:20 -0400
Subject: [PATCH 054/246] feat(skill): add WhatsApp reactions skill (emoji
reactions + status tracker) (#509)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(skill): add reactions skill (emoji reactions + status tracker)
* refactor(reactions): minimize overlays per upstream review
Address gavrielc's review on qwibitai/nanoclaw#509:
- SKILL.md: remove all inline code, follow add-telegram/add-whatsapp pattern (465→79 lines)
- Rebuild overlays as minimal deltas against upstream/main base
- ipc-mcp-stdio.ts: upstream base + only react_to_message tool (8% delta)
- ipc.ts: upstream base + only reactions delta (14% delta)
- group-queue.test.ts: upstream base + isActive tests only (5% delta)
- Remove group-queue.ts overlay (isActive provided by container-hardening)
- Remove group-queue.ts from manifest modifies list
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
---
.claude/skills/add-reactions/SKILL.md | 103 ++
.../add/container/skills/reactions/SKILL.md | 63 ++
.../add/scripts/migrate-reactions.ts | 57 ++
.../add/src/status-tracker.test.ts | 450 +++++++++
.../add-reactions/add/src/status-tracker.ts | 324 ++++++
.claude/skills/add-reactions/manifest.yaml | 23 +
.../agent-runner/src/ipc-mcp-stdio.ts | 440 ++++++++
.../modify/src/channels/whatsapp.test.ts | 952 ++++++++++++++++++
.../modify/src/channels/whatsapp.ts | 457 +++++++++
.../add-reactions/modify/src/db.test.ts | 715 +++++++++++++
.claude/skills/add-reactions/modify/src/db.ts | 801 +++++++++++++++
.../modify/src/group-queue.test.ts | 510 ++++++++++
.../skills/add-reactions/modify/src/index.ts | 726 +++++++++++++
.../add-reactions/modify/src/ipc-auth.test.ts | 807 +++++++++++++++
.../skills/add-reactions/modify/src/ipc.ts | 446 ++++++++
.../skills/add-reactions/modify/src/types.ts | 111 ++
16 files changed, 6985 insertions(+)
create mode 100644 .claude/skills/add-reactions/SKILL.md
create mode 100644 .claude/skills/add-reactions/add/container/skills/reactions/SKILL.md
create mode 100644 .claude/skills/add-reactions/add/scripts/migrate-reactions.ts
create mode 100644 .claude/skills/add-reactions/add/src/status-tracker.test.ts
create mode 100644 .claude/skills/add-reactions/add/src/status-tracker.ts
create mode 100644 .claude/skills/add-reactions/manifest.yaml
create mode 100644 .claude/skills/add-reactions/modify/container/agent-runner/src/ipc-mcp-stdio.ts
create mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.test.ts
create mode 100644 .claude/skills/add-reactions/modify/src/channels/whatsapp.ts
create mode 100644 .claude/skills/add-reactions/modify/src/db.test.ts
create mode 100644 .claude/skills/add-reactions/modify/src/db.ts
create mode 100644 .claude/skills/add-reactions/modify/src/group-queue.test.ts
create mode 100644 .claude/skills/add-reactions/modify/src/index.ts
create mode 100644 .claude/skills/add-reactions/modify/src/ipc-auth.test.ts
create mode 100644 .claude/skills/add-reactions/modify/src/ipc.ts
create mode 100644 .claude/skills/add-reactions/modify/src/types.ts
diff --git a/.claude/skills/add-reactions/SKILL.md b/.claude/skills/add-reactions/SKILL.md
new file mode 100644
index 0000000..76f59ec
--- /dev/null
+++ b/.claude/skills/add-reactions/SKILL.md
@@ -0,0 +1,103 @@
+---
+name: add-reactions
+description: Add WhatsApp emoji reaction support — receive, send, store, and search reactions.
+---
+
+# Add Reactions
+
+This skill adds emoji reaction support to NanoClaw's WhatsApp channel: receive and store reactions, send reactions from the container agent via MCP tool, and query reaction history from SQLite.
+
+## Phase 1: Pre-flight
+
+### Check if already applied
+
+Read `.nanoclaw/state.yaml`. If `reactions` is in `applied_skills`, skip to Phase 3 (Verify). The code changes are already in place.
+
+## Phase 2: Apply Code Changes
+
+Run the skills engine to apply this skill's code package. The package files are in this directory alongside this SKILL.md.
+
+### Apply the skill
+
+```bash
+npx tsx scripts/apply-skill.ts .claude/skills/add-reactions
+```
+
+This deterministically:
+- Adds `scripts/migrate-reactions.ts` (database migration for `reactions` table with composite PK and indexes)
+- Adds `src/status-tracker.ts` (forward-only emoji state machine for message lifecycle signaling, with persistence and retry)
+- Adds `src/status-tracker.test.ts` (unit tests for StatusTracker)
+- Adds `container/skills/reactions/SKILL.md` (agent-facing documentation for the `react_to_message` MCP tool)
+- Modifies `src/db.ts` — adds `Reaction` interface, `reactions` table schema, `storeReaction`, `getReactionsForMessage`, `getMessagesByReaction`, `getReactionsByUser`, `getReactionStats`, `getLatestMessage`, `getMessageFromMe`
+- Modifies `src/channels/whatsapp.ts` — adds `messages.reaction` event handler, `sendReaction()`, `reactToLatestMessage()` methods
+- Modifies `src/types.ts` — adds optional `sendReaction` and `reactToLatestMessage` to `Channel` interface
+- Modifies `src/ipc.ts` — adds `type: 'reaction'` IPC handler with group-scoped authorization
+- Modifies `src/index.ts` — wires `sendReaction` dependency into IPC watcher
+- Modifies `src/group-queue.ts` — `GroupQueue` class for per-group container concurrency with retry
+- Modifies `container/agent-runner/src/ipc-mcp-stdio.ts` — adds `react_to_message` MCP tool exposed to container agents
+- Records the application in `.nanoclaw/state.yaml`
+
+### Run database migration
+
+```bash
+npx tsx scripts/migrate-reactions.ts
+```
+
+### Validate code changes
+
+```bash
+npm test
+npm run build
+```
+
+All tests must pass and build must be clean before proceeding.
+
+## Phase 3: Verify
+
+### Build and restart
+
+```bash
+npm run build
+```
+
+Linux:
+```bash
+systemctl --user restart nanoclaw
+```
+
+macOS:
+```bash
+launchctl kickstart -k gui/$(id -u)/com.nanoclaw
+```
+
+### Test receiving reactions
+
+1. Send a message from your phone
+2. React to it with an emoji on WhatsApp
+3. Check the database:
+
+```bash
+sqlite3 store/messages.db "SELECT * FROM reactions ORDER BY timestamp DESC LIMIT 5;"
+```
+
+### Test sending reactions
+
+Ask the agent to react to a message via the `react_to_message` MCP tool. Check your phone — the reaction should appear on the message.
+
+## Troubleshooting
+
+### Reactions not appearing in database
+
+- Check NanoClaw logs for `Failed to process reaction` errors
+- Verify the chat is registered
+- Confirm the service is running
+
+### Migration fails
+
+- Ensure `store/messages.db` exists and is accessible
+- If "table reactions already exists", the migration already ran — skip it
+
+### Agent can't send reactions
+
+- Check IPC logs for `Unauthorized IPC reaction attempt blocked` — the agent can only react in its own group's chat
+- Verify WhatsApp is connected: check logs for connection status
diff --git a/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md b/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md
new file mode 100644
index 0000000..4d8eeec
--- /dev/null
+++ b/.claude/skills/add-reactions/add/container/skills/reactions/SKILL.md
@@ -0,0 +1,63 @@
+---
+name: reactions
+description: React to WhatsApp messages with emoji. Use when the user asks you to react, when acknowledging a message with a reaction makes sense, or when you want to express a quick response without sending a full message.
+---
+
+# Reactions
+
+React to messages with emoji using the `mcp__nanoclaw__react_to_message` tool.
+
+## When to use
+
+- User explicitly asks you to react ("react with a thumbs up", "heart that message")
+- Quick acknowledgment is more appropriate than a full text reply
+- Expressing agreement, approval, or emotion about a specific message
+
+## How to use
+
+### React to the latest message
+
+```
+mcp__nanoclaw__react_to_message(emoji: "👍")
+```
+
+Omitting `message_id` reacts to the most recent message in the chat.
+
+### React to a specific message
+
+```
+mcp__nanoclaw__react_to_message(emoji: "❤️", message_id: "3EB0F4C9E7...")
+```
+
+Pass a `message_id` to react to a specific message. You can find message IDs by querying the messages database:
+
+```bash
+sqlite3 /workspace/project/store/messages.db "
+ SELECT id, sender_name, substr(content, 1, 80), timestamp
+ FROM messages
+ WHERE chat_jid = ''
+ ORDER BY timestamp DESC
+ LIMIT 5;
+"
+```
+
+### Remove a reaction
+
+Send an empty string to remove your reaction:
+
+```
+mcp__nanoclaw__react_to_message(emoji: "")
+```
+
+## Common emoji
+
+| Emoji | When to use |
+|-------|-------------|
+| 👍 | Acknowledgment, approval |
+| ❤️ | Appreciation, love |
+| 😂 | Something funny |
+| 🔥 | Impressive, exciting |
+| 🎉 | Celebration, congrats |
+| 🙏 | Thanks, prayer |
+| ✅ | Task done, confirmed |
+| ❓ | Needs clarification |
diff --git a/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts b/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts
new file mode 100644
index 0000000..8dec46e
--- /dev/null
+++ b/.claude/skills/add-reactions/add/scripts/migrate-reactions.ts
@@ -0,0 +1,57 @@
+// Database migration script for reactions table
+// Run: npx tsx scripts/migrate-reactions.ts
+
+import Database from 'better-sqlite3';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const STORE_DIR = process.env.STORE_DIR || path.join(process.cwd(), 'store');
+const dbPath = path.join(STORE_DIR, 'messages.db');
+
+console.log(`Migrating database at: ${dbPath}`);
+
+const db = new Database(dbPath);
+
+try {
+ db.transaction(() => {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS reactions (
+ message_id TEXT NOT NULL,
+ message_chat_jid TEXT NOT NULL,
+ reactor_jid TEXT NOT NULL,
+ reactor_name TEXT,
+ emoji TEXT NOT NULL,
+ timestamp TEXT NOT NULL,
+ PRIMARY KEY (message_id, message_chat_jid, reactor_jid)
+ );
+ `);
+
+ console.log('Created reactions table');
+
+ db.exec(`
+ CREATE INDEX IF NOT EXISTS idx_reactions_message ON reactions(message_id, message_chat_jid);
+ CREATE INDEX IF NOT EXISTS idx_reactions_reactor ON reactions(reactor_jid);
+ CREATE INDEX IF NOT EXISTS idx_reactions_emoji ON reactions(emoji);
+ CREATE INDEX IF NOT EXISTS idx_reactions_timestamp ON reactions(timestamp);
+ `);
+
+ console.log('Created indexes');
+ })();
+
+ const tableInfo = db.prepare(`PRAGMA table_info(reactions)`).all();
+ console.log('\nReactions table schema:');
+ console.table(tableInfo);
+
+ const count = db.prepare(`SELECT COUNT(*) as count FROM reactions`).get() as {
+ count: number;
+ };
+ console.log(`\nCurrent reaction count: ${count.count}`);
+
+ console.log('\nMigration complete!');
+} catch (err) {
+ console.error('Migration failed:', err);
+ process.exit(1);
+} finally {
+ db.close();
+}
diff --git a/.claude/skills/add-reactions/add/src/status-tracker.test.ts b/.claude/skills/add-reactions/add/src/status-tracker.test.ts
new file mode 100644
index 0000000..53a439d
--- /dev/null
+++ b/.claude/skills/add-reactions/add/src/status-tracker.test.ts
@@ -0,0 +1,450 @@
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+
+vi.mock('fs', async () => {
+ const actual = await vi.importActual