From 675acffeb1656b43a4470b01495bd88dfd8bf78f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 12:57:40 +0200 Subject: [PATCH 01/34] feat: add script field to ScheduledTask type and database layer Adds optional `script` field to the ScheduledTask interface, with a migration for existing DBs and full support in createTask/updateTask. Co-Authored-By: Claude Sonnet 4.6 --- src/db.ts | 20 +++++++++++++++++--- src/types.ts | 1 + 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/db.ts b/src/db.ts index 0896f41..36e3edc 100644 --- a/src/db.ts +++ b/src/db.ts @@ -93,6 +93,15 @@ function createSchema(database: Database.Database): void { /* column already exists */ } + // Add script column if it doesn't exist (migration for existing DBs) + try { + database.exec( + `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, + ); + } catch { + /* column already exists */ + } + // Add is_bot_message column if it doesn't exist (migration for existing DBs) try { database.exec( @@ -368,14 +377,15 @@ export function createTask( ): void { db.prepare( ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ).run( task.id, task.group_folder, task.chat_jid, task.prompt, + task.script || null, task.schedule_type, task.schedule_value, task.context_mode || 'isolated', @@ -410,7 +420,7 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' > >, ): void { @@ -421,6 +431,10 @@ export function updateTask( fields.push('prompt = ?'); values.push(updates.prompt); } + if (updates.script !== undefined) { + fields.push('script = ?'); + values.push(updates.script || null); + } if (updates.schedule_type !== undefined) { fields.push('schedule_type = ?'); values.push(updates.schedule_type); diff --git a/src/types.ts b/src/types.ts index acbb08a..bcef463 100644 --- a/src/types.ts +++ b/src/types.ts @@ -58,6 +58,7 @@ export interface ScheduledTask { group_folder: string; chat_jid: string; prompt: string; + script?: string | null; schedule_type: 'cron' | 'interval' | 'once'; schedule_value: string; context_mode: 'group' | 'isolated'; From a516cc5cfea2eceb14cd694df3b39d0356835ea9 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:28:36 +0200 Subject: [PATCH 02/34] feat: add script parameter to MCP task tools Add optional `script` field to schedule_task and update_task MCP tools, allowing agents to attach a pre-flight bash script that controls whether the task agent is woken up. Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/ipc-mcp-stdio.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/container/agent-runner/src/ipc-mcp-stdio.ts b/container/agent-runner/src/ipc-mcp-stdio.ts index 9de0138..5b03478 100644 --- a/container/agent-runner/src/ipc-mcp-stdio.ts +++ b/container/agent-runner/src/ipc-mcp-stdio.ts @@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), + script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'), }, async (args) => { // Validate schedule_value before writing IPC @@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone): type: 'schedule_task', taskId, prompt: args.prompt, + script: args.script || undefined, schedule_type: args.schedule_type, schedule_value: args.schedule_value, context_mode: args.context_mode || 'group', @@ -255,6 +257,7 @@ server.tool( prompt: z.string().optional().describe('New prompt for the task'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), + script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'), }, async (args) => { // Validate schedule_value if provided @@ -288,6 +291,7 @@ server.tool( timestamp: new Date().toISOString(), }; if (args.prompt !== undefined) data.prompt = args.prompt; + if (args.script !== undefined) data.script = args.script; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; From 0f283cbdd33a594665812ac4997e9ed0f736caf1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:31:12 +0200 Subject: [PATCH 03/34] feat: pass script through IPC task processing Thread the optional `script` field through the IPC layer so it is persisted when an agent calls schedule_task, and updated when an agent calls update_task (empty string clears the script). Co-Authored-By: Claude Sonnet 4.6 --- src/ipc.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ipc.ts b/src/ipc.ts index 48efeb5..043b07a 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -162,6 +162,7 @@ export async function processTaskIpc( schedule_type?: string; schedule_value?: string; context_mode?: string; + script?: string; groupFolder?: string; chatJid?: string; targetJid?: string; @@ -260,6 +261,7 @@ export async function processTaskIpc( group_folder: targetFolder, chat_jid: targetJid, prompt: data.prompt, + script: data.script || null, schedule_type: scheduleType, schedule_value: data.schedule_value, context_mode: contextMode, @@ -352,6 +354,7 @@ export async function processTaskIpc( const updates: Parameters[1] = {}; if (data.prompt !== undefined) updates.prompt = data.prompt; + if (data.script !== undefined) updates.script = data.script || null; if (data.schedule_type !== undefined) updates.schedule_type = data.schedule_type as | 'cron' From eb65121938210a1b8cb4d5909843d7be518c2fa1 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:14 +0200 Subject: [PATCH 04/34] feat: add script to ContainerInput and task snapshot Co-Authored-By: Claude Sonnet 4.6 --- container/agent-runner/src/index.ts | 1 + src/container-runner.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 96cb4a4..2cd34c9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -27,6 +27,7 @@ interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } interface ContainerOutput { diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356..469fe11 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -41,6 +41,7 @@ export interface ContainerInput { isMain: boolean; isScheduledTask?: boolean; assistantName?: string; + script?: string; } export interface ContainerOutput { @@ -649,6 +650,7 @@ export function writeTasksSnapshot( id: string; groupFolder: string; prompt: string; + script?: string | null; schedule_type: string; schedule_value: string; status: string; From 42d098c3c1f5835f8cd77dd0205f74b687239b25 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:38:28 +0200 Subject: [PATCH 05/34] feat: pass script from task scheduler to container Co-Authored-By: Claude Sonnet 4.6 --- src/task-scheduler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index d0abd2e..f2b964d 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -139,6 +139,7 @@ async function runTask( id: t.id, groupFolder: t.group_folder, prompt: t.prompt, + script: t.script, schedule_type: t.schedule_type, schedule_value: t.schedule_value, status: t.status, @@ -179,6 +180,7 @@ async function runTask( isMain, isScheduledTask: true, assistantName: ASSISTANT_NAME, + script: task.script || undefined, }, (proc, containerName) => deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), From 9f5aff99b68b0dbd9c23f4ac6907f29d2a7036df Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:43:56 +0200 Subject: [PATCH 06/34] feat: add script execution phase to agent-runner Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 70 +++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2cd34c9..382439f 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -16,6 +16,7 @@ import fs from 'fs'; import path from 'path'; +import { execFile } from 'child_process'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { fileURLToPath } from 'url'; @@ -465,6 +466,55 @@ async function runQuery( return { newSessionId, lastAssistantUuid, closedDuringQuery }; } +interface ScriptResult { + wakeAgent: boolean; + data?: unknown; +} + +const SCRIPT_TIMEOUT_MS = 30_000; + +async function runScript(script: string): Promise { + const scriptPath = '/tmp/task-script.sh'; + fs.writeFileSync(scriptPath, script, { mode: 0o755 }); + + return new Promise((resolve) => { + execFile('bash', [scriptPath], { + timeout: SCRIPT_TIMEOUT_MS, + maxBuffer: 1024 * 1024, + env: process.env, + }, (error, stdout, stderr) => { + if (stderr) { + log(`Script stderr: ${stderr.slice(0, 500)}`); + } + + if (error) { + log(`Script error: ${error.message}`); + return resolve(null); + } + + // Parse last non-empty line of stdout as JSON + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + if (!lastLine) { + log('Script produced no output'); + return resolve(null); + } + + try { + const result = JSON.parse(lastLine); + if (typeof result.wakeAgent !== 'boolean') { + log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`); + return resolve(null); + } + resolve(result as ScriptResult); + } catch { + log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`); + resolve(null); + } + }); + }); +} + async function main(): Promise { let containerInput: ContainerInput; @@ -506,6 +556,26 @@ async function main(): Promise { prompt += '\n' + pending.join('\n'); } + // Script phase: run script before waking agent + if (containerInput.script && containerInput.isScheduledTask) { + log('Running task script...'); + const scriptResult = await runScript(containerInput.script); + + if (!scriptResult || !scriptResult.wakeAgent) { + const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output'; + log(`Script decided not to wake agent: ${reason}`); + writeOutput({ + status: 'success', + result: `Script: ${reason}`, + }); + return; + } + + // Script says wake agent — enrich prompt with script data + log(`Script wakeAgent=true, enriching prompt with data`); + prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`; + } + // Query loop: run query → wait for IPC message → run new query → repeat let resumeAt: string | undefined; try { From a4dc3a744668e3202ce97c740d68c4cf1b3bb1a7 Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 13:45:01 +0200 Subject: [PATCH 07/34] docs: add task script instructions to agent CLAUDE.md --- groups/main/CLAUDE.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index 11e846b..0580e4b 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -244,3 +244,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` The task will run in that group's context with access to their files and memory. + +--- + +## Task Scripts + +When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up. + +### How it works + +1. You provide a bash `script` alongside the `prompt` when scheduling +2. When the task fires, the script runs first (30-second timeout) +3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }` +4. If `wakeAgent: false` — nothing happens, task waits for next run +5. If `wakeAgent: true` — you wake up and receive the script's data + prompt + +### Always test your script first + +Before scheduling, run the script in your sandbox to verify it works: + +```bash +bash -c 'node --input-type=module -e " + const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\"); + const prs = await r.json(); + console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) })); +"' +``` + +### When NOT to use scripts + +If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt. + +### Frequent task guidance + +If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups: + +- Explain that each wake-up uses API credits and risks rate limits +- Suggest restructuring with a script that checks the condition first +- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script +- Help the user find the minimum viable frequency From b7f1d48423646e825500e02618c2a62b12d1dd9f Mon Sep 17 00:00:00 2001 From: Gabi Simons Date: Wed, 18 Mar 2026 14:04:31 +0200 Subject: [PATCH 08/34] style: fix prettier formatting in db.ts --- src/db.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/db.ts b/src/db.ts index 36e3edc..87dd941 100644 --- a/src/db.ts +++ b/src/db.ts @@ -95,9 +95,7 @@ function createSchema(database: Database.Database): void { // Add script column if it doesn't exist (migration for existing DBs) try { - database.exec( - `ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`, - ); + database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`); } catch { /* column already exists */ } @@ -420,7 +418,12 @@ export function updateTask( updates: Partial< Pick< ScheduledTask, - 'prompt' | 'script' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + | 'prompt' + | 'script' + | 'schedule_type' + | 'schedule_value' + | 'next_run' + | 'status' > >, ): void { From 01b6258f59c76eff7ed86c8b9d4aa1d8eecddf46 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 10:40:04 +0100 Subject: [PATCH 09/34] docs: update outdated documentation, add docs portal links - README.md: add docs.nanoclaw.dev link, point architecture and security references to documentation site - CHANGELOG.md: add all releases from v1.1.0 through v1.2.21 (was only v1.2.0), link to full changelog on docs site - docs/REQUIREMENTS.md: update multi-channel references (NanoClaw now supports WhatsApp, Telegram, Discord, Slack, Gmail), update RFS to reflect existing skills, fix deployment info (macOS + Linux) - docs/SECURITY.md: generalize WhatsApp-specific language to channel-neutral - docs/DEBUG_CHECKLIST.md: use Docker commands (default runtime) instead of Apple Container syntax, generalize WhatsApp references - docs/README.md: new file pointing to docs.nanoclaw.dev as the authoritative source, with mapping table from local files to docs site pages Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 138 +++++++++++++++++++++++++++++++++++++++- README.md | 7 +- docs/DEBUG_CHECKLIST.md | 14 ++-- docs/README.md | 15 +++++ docs/REQUIREMENTS.md | 45 ++++++------- docs/SECURITY.md | 6 +- 6 files changed, 182 insertions(+), 43 deletions(-) create mode 100644 docs/README.md diff --git a/CHANGELOG.md b/CHANGELOG.md index bcb6496..323c0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,139 @@ All notable changes to NanoClaw will be documented in this file. -## [1.2.0](https://github.com/qwibitai/nanoclaw/compare/v1.1.6...v1.2.0) +For detailed release notes, see the [full changelog on the documentation site](https://docs.nanoclaw.dev/changelog). -[BREAKING] WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add (existing auth/groups preserved). -- **fix:** Prevent scheduled tasks from executing twice when container runtime exceeds poll interval (#138, #669) +## [1.2.21] - 2026-03-22 + +- Added opt-in diagnostics via PostHog with explicit user consent (Yes / No / Never ask again) + +## [1.2.20] - 2026-03-21 + +- Added ESLint configuration with error-handling rules + +## [1.2.19] - 2026-03-19 + +- Reduced `docker stop` timeout for faster container restarts (`-t 1` flag) + +## [1.2.18] - 2026-03-19 + +- User prompt content no longer logged on container errors — only input metadata +- Added Japanese README translation + +## [1.2.17] - 2026-03-18 + +- Added `/capabilities` and `/status` container-agent skills + +## [1.2.16] - 2026-03-18 + +- Tasks snapshot now refreshes immediately after IPC task mutations + +## [1.2.15] - 2026-03-16 + +- Fixed remote-control prompt auto-accept to prevent immediate exit +- Added `KillMode=process` so remote-control survives service restarts + +## [1.2.14] - 2026-03-14 + +- Added `/remote-control` command for host-level Claude Code access from within containers + +## [1.2.13] - 2026-03-14 + +**Breaking:** Skills are now git branches, channels are separate fork repos. + +- Skills live as `skill/*` git branches merged via `git merge` +- Added Docker Sandboxes support +- Fixed setup registration to use correct CLI commands + +## [1.2.12] - 2026-03-08 + +- Added `/compact` skill for manual context compaction +- Enhanced container environment isolation via credential proxy + +## [1.2.11] - 2026-03-08 + +- Added PDF reader, image vision, and WhatsApp reactions skills +- Fixed task container to close promptly when agent uses IPC-only messaging + +## [1.2.10] - 2026-03-06 + +- Added `LIMIT` to unbounded message history queries for better performance + +## [1.2.9] - 2026-03-06 + +- Agent prompts now include timezone context for accurate time references + +## [1.2.8] - 2026-03-06 + +- Fixed misleading `send_message` tool description for scheduled tasks + +## [1.2.7] - 2026-03-06 + +- Added `/add-ollama` skill for local model inference +- Added `update_task` tool and return task ID from `schedule_task` + +## [1.2.6] - 2026-03-04 + +- Updated `claude-agent-sdk` to 0.2.68 + +## [1.2.5] - 2026-03-04 + +- CI formatting fix + +## [1.2.4] - 2026-03-04 + +- Fixed `_chatJid` rename to `chatJid` in `onMessage` callback + +## [1.2.3] - 2026-03-04 + +- Added sender allowlist for per-chat access control + +## [1.2.2] - 2026-03-04 + +- Added `/use-local-whisper` skill for local voice transcription +- Atomic task claims prevent scheduled tasks from executing twice + +## [1.2.1] - 2026-03-02 + +- Version bump (no functional changes) + +## [1.2.0] - 2026-03-02 + +**Breaking:** WhatsApp removed from core, now a skill. Run `/add-whatsapp` to re-add. + +- Channel registry: channels self-register at startup via `registerChannel()` factory pattern +- `isMain` flag replaces folder-name-based main group detection +- `ENABLED_CHANNELS` removed — channels detected by credential presence +- Prevent scheduled tasks from executing twice when container runtime exceeds poll interval + +## [1.1.6] - 2026-03-01 + +- Added CJK font support for Chromium screenshots + +## [1.1.5] - 2026-03-01 + +- Fixed wrapped WhatsApp message normalization + +## [1.1.4] - 2026-03-01 + +- Added third-party model support +- Added `/update-nanoclaw` skill for syncing with upstream + +## [1.1.3] - 2026-02-25 + +- Added `/add-slack` skill +- Restructured Gmail skill for new architecture + +## [1.1.2] - 2026-02-24 + +- Improved error handling for WhatsApp Web version fetch + +## [1.1.1] - 2026-02-24 + +- Added Qodo skills and codebase intelligence +- Fixed WhatsApp 405 connection failures + +## [1.1.0] - 2026-02-23 + +- Added `/update` skill to pull upstream changes from within Claude Code +- Enhanced container environment isolation via credential proxy diff --git a/README.md b/README.md index 3aafd85..8cfe627 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

nanoclaw.dev  •   + docs  •   中文  •   日本語  •   Discord  •   @@ -134,7 +135,7 @@ Channels --> SQLite --> Polling loop --> Container (Claude Agent SDK) --> Respon Single Node.js process. Channels are added via skills and self-register at startup — the orchestrator connects whichever ones have credentials present. Agents execute in isolated Linux containers with filesystem isolation. Only mounted directories are accessible. Per-group message queue with concurrency control. IPC via filesystem. -For the full architecture details, see [docs/SPEC.md](docs/SPEC.md). +For the full architecture details, see the [documentation site](https://docs.nanoclaw.dev/concepts/architecture). Key files: - `src/index.ts` - Orchestrator: state, message loop, agent invocation @@ -159,7 +160,7 @@ Yes. Docker is the default runtime and works on both macOS and Linux. Just run ` **Is this secure?** -Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See [docs/SECURITY.md](docs/SECURITY.md) for the full security model. +Agents run in containers, not behind application-level permission checks. They can only access explicitly mounted directories. You should still review what you're running, but the codebase is small enough that you actually can. See the [security documentation](https://docs.nanoclaw.dev/concepts/security) for the full security model. **Why no configuration files?** @@ -203,7 +204,7 @@ Questions? Ideas? [Join the Discord](https://discord.gg/VDdww8qS42). ## Changelog -See [CHANGELOG.md](CHANGELOG.md) for breaking changes and migration notes. +See [CHANGELOG.md](CHANGELOG.md) for breaking changes, or the [full release history](https://docs.nanoclaw.dev/changelog) on the documentation site. ## License diff --git a/docs/DEBUG_CHECKLIST.md b/docs/DEBUG_CHECKLIST.md index 5597067..c1d53f1 100644 --- a/docs/DEBUG_CHECKLIST.md +++ b/docs/DEBUG_CHECKLIST.md @@ -19,16 +19,16 @@ launchctl list | grep nanoclaw # Expected: PID 0 com.nanoclaw (PID = running, "-" = not running, non-zero exit = crashed) # 2. Any running containers? -container ls --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw +docker ps --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 3. Any stopped/orphaned containers? -container ls -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw +docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep nanoclaw # 4. Recent errors in service log? grep -E 'ERROR|WARN' logs/nanoclaw.log | tail -20 -# 5. Is WhatsApp connected? (look for last connection event) -grep -E 'Connected to WhatsApp|Connection closed|connection.*close' logs/nanoclaw.log | tail -5 +# 5. Are channels connected? (look for last connection event) +grep -E 'Connected|Connection closed|connection.*close|channel.*ready' logs/nanoclaw.log | tail -5 # 6. Are groups loaded? grep 'groupCount' logs/nanoclaw.log | tail -3 @@ -77,7 +77,7 @@ grep -E 'Scheduling retry|retry|Max retries' logs/nanoclaw.log | tail -10 ## Agent Not Responding ```bash -# Check if messages are being received from WhatsApp +# Check if messages are being received from channels grep 'New messages' logs/nanoclaw.log | tail -10 # Check if messages are being processed (container spawned) @@ -107,10 +107,10 @@ sqlite3 store/messages.db "SELECT name, container_config FROM registered_groups; # Test-run a container to check mounts (dry run) # Replace with the group's folder name -container run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ +docker run -i --rm --entrypoint ls nanoclaw-agent:latest /workspace/extra/ ``` -## WhatsApp Auth Issues +## Channel Auth Issues ```bash # Check if QR code was requested (means auth expired) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..bb062e5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# NanoClaw Documentation + +The official documentation is at **[docs.nanoclaw.dev](https://docs.nanoclaw.dev)**. + +The files in this directory are original design documents and developer references. For the most current and accurate information, use the documentation site. + +| This directory | Documentation site | +|---|---| +| [SPEC.md](SPEC.md) | [Architecture](https://docs.nanoclaw.dev/concepts/architecture) | +| [SECURITY.md](SECURITY.md) | [Security model](https://docs.nanoclaw.dev/concepts/security) | +| [REQUIREMENTS.md](REQUIREMENTS.md) | [Introduction](https://docs.nanoclaw.dev/introduction) | +| [skills-as-branches.md](skills-as-branches.md) | [Skills system](https://docs.nanoclaw.dev/integrations/skills-system) | +| [DEBUG_CHECKLIST.md](DEBUG_CHECKLIST.md) | [Troubleshooting](https://docs.nanoclaw.dev/advanced/troubleshooting) | +| [docker-sandboxes.md](docker-sandboxes.md) | [Docker Sandboxes](https://docs.nanoclaw.dev/advanced/docker-sandboxes) | +| [APPLE-CONTAINER-NETWORKING.md](APPLE-CONTAINER-NETWORKING.md) | [Container runtime](https://docs.nanoclaw.dev/advanced/container-runtime) | diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 227c9ad..8c1a29e 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -22,9 +22,9 @@ The entire codebase should be something you can read and understand. One Node.js Instead of application-level permission systems trying to prevent agents from accessing things, agents run in actual Linux containers. The isolation is at the OS level. Agents can only see what's explicitly mounted. Bash access is safe because commands run inside the container, not on your Mac. -### Built for One User +### Built for the Individual User -This isn't a framework or a platform. It's working software for my specific needs. I use WhatsApp and Email, so it supports WhatsApp and Email. I don't use Telegram, so it doesn't support Telegram. I add the integrations I actually want, not every possible integration. +This isn't a framework or a platform. It's software that fits each user's exact needs. You fork the repo, add the channels you want (WhatsApp, Telegram, Discord, Slack, Gmail), and end up with clean code that does exactly what you need. ### Customization = Code Changes @@ -44,41 +44,31 @@ When people contribute, they shouldn't add "Telegram support alongside WhatsApp. ## RFS (Request for Skills) -Skills we'd love contributors to build: +Skills we'd like to see contributed: ### Communication Channels -Skills to add or switch to different messaging platforms: -- `/add-telegram` - Add Telegram as an input channel -- `/add-slack` - Add Slack as an input channel -- `/add-discord` - Add Discord as an input channel -- `/add-sms` - Add SMS via Twilio or similar -- `/convert-to-telegram` - Replace WhatsApp with Telegram entirely +- `/add-signal` - Add Signal as a channel +- `/add-matrix` - Add Matrix integration -### Container Runtime -The project uses Docker by default (cross-platform). For macOS users who prefer Apple Container: -- `/convert-to-apple-container` - Switch from Docker to Apple Container (macOS-only) - -### Platform Support -- `/setup-linux` - Make the full setup work on Linux (depends on Docker conversion) -- `/setup-windows` - Windows support via WSL2 + Docker +> **Note:** Telegram, Slack, Discord, Gmail, and Apple Container skills already exist. See the [skills documentation](https://docs.nanoclaw.dev/integrations/skills-system) for the full list. --- ## Vision -A personal Claude assistant accessible via WhatsApp, with minimal custom code. +A personal Claude assistant accessible via messaging, with minimal custom code. **Core components:** - **Claude Agent SDK** as the core agent - **Containers** for isolated agent execution (Linux VMs) -- **WhatsApp** as the primary I/O channel +- **Multi-channel messaging** (WhatsApp, Telegram, Discord, Slack, Gmail) — add exactly the channels you need - **Persistent memory** per conversation and globally - **Scheduled tasks** that run Claude and can message back - **Web access** for search and browsing - **Browser automation** via agent-browser **Implementation approach:** -- Use existing tools (WhatsApp connector, Claude Agent SDK, MCP servers) +- Use existing tools (channel libraries, Claude Agent SDK, MCP servers) - Minimal glue code - File-based systems where possible (CLAUDE.md for memory, folders for groups) @@ -87,7 +77,7 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Architecture Decisions ### Message Routing -- A router listens to WhatsApp and routes messages based on configuration +- A router listens to connected channels and routes messages based on configuration - Only messages from registered groups are processed - Trigger: `@Andy` prefix (case insensitive), configurable via `ASSISTANT_NAME` env var - Unregistered groups are ignored completely @@ -136,10 +126,11 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. ## Integration Points -### WhatsApp -- Using baileys library for WhatsApp Web connection +### Channels +- WhatsApp (baileys), Telegram (grammy), Discord (discord.js), Slack (@slack/bolt), Gmail (googleapis) +- Each channel lives in a separate fork repo and is added via skills (e.g., `/add-whatsapp`, `/add-telegram`) - Messages stored in SQLite, polled by router -- QR code authentication during setup +- Channels self-register at startup — unconfigured channels are skipped with a warning ### Scheduler - Built-in scheduler runs on the host, spawns containers for task execution @@ -170,12 +161,12 @@ A personal Claude assistant accessible via WhatsApp, with minimal custom code. - Each user gets a custom setup matching their exact needs ### Skills -- `/setup` - Install dependencies, authenticate WhatsApp, configure scheduler, start services -- `/customize` - General-purpose skill for adding capabilities (new channels like Telegram, new integrations, behavior changes) -- `/update` - Pull upstream changes, merge with customizations, run migrations +- `/setup` - Install dependencies, configure channels, start services +- `/customize` - General-purpose skill for adding capabilities +- `/update-nanoclaw` - Pull upstream changes, merge with customizations ### Deployment -- Runs on local Mac via launchd +- Runs on macOS (launchd) or Linux (systemd) - Single Node.js process handles everything --- diff --git a/docs/SECURITY.md b/docs/SECURITY.md index db6fc18..3562fbd 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -7,7 +7,7 @@ | Main group | Trusted | Private self-chat, admin control | | Non-main groups | Untrusted | Other users may be malicious | | Container agents | Sandboxed | Isolated execution environment | -| WhatsApp messages | User input | Potential prompt injection | +| Incoming messages | User input | Potential prompt injection | ## Security Boundaries @@ -76,7 +76,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP 5. Agents cannot discover real credentials — not in environment, stdin, files, or `/proc` **NOT Mounted:** -- WhatsApp session (`store/auth/`) - host only +- Channel auth sessions (`store/auth/`) - host only - Mount allowlist - external, never mounted - Any credentials matching blocked patterns - `.env` is shadowed with `/dev/null` in the project root mount @@ -97,7 +97,7 @@ Real API credentials **never enter containers**. Instead, the host runs an HTTP ``` ┌──────────────────────────────────────────────────────────────────┐ │ UNTRUSTED ZONE │ -│ WhatsApp Messages (potentially malicious) │ +│ Incoming Messages (potentially malicious) │ └────────────────────────────────┬─────────────────────────────────┘ │ ▼ Trigger check, input escaping From 8dcc70cf5cc45628d05b0fec604baab569d60d0a Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 10:48:18 +0100 Subject: [PATCH 10/34] docs: add Windows (WSL2) to supported platforms Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++--- docs/REQUIREMENTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8cfe627..8d1eb37 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Skills we'd like to see: ## Requirements -- macOS or Linux +- macOS, Linux, or Windows (via WSL2) - Node.js 20+ - [Claude Code](https://claude.ai/download) - [Apple Container](https://github.com/apple/container) (macOS) or [Docker](https://docker.com/products/docker-desktop) (macOS/Linux) @@ -154,9 +154,9 @@ Key files: Docker provides cross-platform support (macOS, Linux and even Windows via WSL2) and a mature ecosystem. On macOS, you can optionally switch to Apple Container via `/convert-to-apple-container` for a lighter-weight native runtime. For additional isolation, [Docker Sandboxes](docs/docker-sandboxes.md) run each container inside a micro VM. -**Can I run this on Linux?** +**Can I run this on Linux or Windows?** -Yes. Docker is the default runtime and works on both macOS and Linux. Just run `/setup`. +Yes. Docker is the default runtime and works on macOS, Linux, and Windows (via WSL2). Just run `/setup`. **Is this secure?** diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 8c1a29e..e7c2376 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -166,7 +166,7 @@ A personal Claude assistant accessible via messaging, with minimal custom code. - `/update-nanoclaw` - Pull upstream changes, merge with customizations ### Deployment -- Runs on macOS (launchd) or Linux (systemd) +- Runs on macOS (launchd), Linux (systemd), or Windows (WSL2) - Single Node.js process handles everything --- From 5a12ddd4cba283bec12bb68c5c712e2dfded1700 Mon Sep 17 00:00:00 2001 From: glifocat Date: Thu, 5 Mar 2026 20:38:32 +0000 Subject: [PATCH 11/34] fix(register): create CLAUDE.md in group folder from template When registering a new group, create CLAUDE.md in the group folder from the appropriate template (groups/main/ for main groups, groups/global/ for others). Without this, the container agent runs with no CLAUDE.md since its CWD is /workspace/group (the group folder). Also update the name-replacement glob to cover all groups/*/CLAUDE.md files rather than only two hardcoded paths, so newly created files and any future group folders are updated correctly. Co-Authored-By: Claude Sonnet 4.6 --- setup/register.ts | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/setup/register.ts b/setup/register.ts index eeafa90..6e32cd8 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,6 +116,27 @@ export async function run(args: string[]): Promise { recursive: true, }); + // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + const groupClaudeMdPath = path.join( + projectRoot, + 'groups', + parsed.folder, + 'CLAUDE.md', + ); + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, groupClaudeMdPath); + logger.info( + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', + ); + } + } + // Update assistant name in CLAUDE.md files if different from default let nameUpdated = false; if (parsed.assistantName !== 'Andy') { @@ -124,10 +145,11 @@ export async function run(args: string[]): Promise { 'Updating assistant name', ); - const mdFiles = [ - path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'), - path.join(projectRoot, 'groups', parsed.folder, 'CLAUDE.md'), - ]; + const groupsDir = path.join(projectRoot, 'groups'); + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); for (const mdFile of mdFiles) { if (fs.existsSync(mdFile)) { From b6e18688c206b2e50961a17cc9232c2b6fd83877 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:35:13 +0100 Subject: [PATCH 12/34] test: add coverage for CLAUDE.md template copy in register step Adds 5 tests verifying the template copy and glob-based name update logic introduced in the parent commit: - copies global template for non-main groups - copies main template for main groups - does not overwrite existing CLAUDE.md - updates name across all groups/*/CLAUDE.md files - handles missing template gracefully (no crash) Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 152 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index d47d95c..b3bd463 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -1,4 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, it, expect, beforeEach } from 'vitest'; import Database from 'better-sqlite3'; @@ -6,7 +9,7 @@ import Database from 'better-sqlite3'; * Tests for the register step. * * Verifies: parameterized SQL (no injection), file templating, - * apostrophe in names, .env updates. + * apostrophe in names, .env updates, CLAUDE.md template copy. */ function createTestDb(): Database.Database { @@ -255,3 +258,148 @@ describe('file templating', () => { expect(envContent).toContain('ASSISTANT_NAME="Nova"'); }); }); + +describe('CLAUDE.md template copy', () => { + let tmpDir: string; + let groupsDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); + groupsDir = path.join(tmpDir, 'groups'); + fs.mkdirSync(path.join(groupsDir, 'main'), { recursive: true }); + fs.mkdirSync(path.join(groupsDir, 'global'), { recursive: true }); + fs.writeFileSync( + path.join(groupsDir, 'main', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.\n\n## Admin Context\n\nThis is the **main channel**.', + ); + fs.writeFileSync( + path.join(groupsDir, 'global', 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('copies global template for non-main group', () => { + const folder = 'telegram_dev-team'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + // Replicate register.ts logic: copy template if dest doesn't exist + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); + // Should NOT contain main-specific content + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + }); + + it('copies main template for main group', () => { + const folder = 'whatsapp_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const isMain = true; + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.existsSync(dest)).toBe(true); + expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + }); + + it('does not overwrite existing CLAUDE.md', () => { + const folder = 'slack_main'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(folderDir, { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); + + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); + expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); + }); + + it('updates name in all groups/*/CLAUDE.md files', () => { + // Create a few group folders with CLAUDE.md + for (const folder of ['whatsapp_main', 'telegram_friends']) { + const dir = path.join(groupsDir, folder); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'CLAUDE.md'), + '# Andy\n\nYou are Andy, a personal assistant.', + ); + } + + const assistantName = 'Luna'; + + // Replicate register.ts glob logic + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace(/You are Andy/g, `You are ${assistantName}`); + fs.writeFileSync(mdFile, content); + } + + // All CLAUDE.md files should be updated, including templates and groups + for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { + const content = fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + expect(content).toContain('# Luna'); + expect(content).toContain('You are Luna'); + expect(content).not.toContain('Andy'); + } + }); + + it('handles missing template gracefully', () => { + // Remove templates + fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); + fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); + + const folder = 'discord_general'; + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + const dest = path.join(folderDir, 'CLAUDE.md'); + const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); + + if (!fs.existsSync(dest)) { + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // No crash, no file created + expect(fs.existsSync(dest)).toBe(false); + }); +}); From 07dc8c977c9b2c582896996dbe2bb4936e94269d Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:39:21 +0100 Subject: [PATCH 13/34] test: cover multi-channel main and cross-channel name propagation Replaces single-channel tests with multi-channel scenarios: - each channel can have its own main with admin context - non-main groups across channels get global template - custom name propagates to all channels and groups - user-modified CLAUDE.md preserved on re-registration - missing templates handled gracefully Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 212 +++++++++++++++++++++++------------------ 1 file changed, 118 insertions(+), 94 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index b3bd463..11f0f5f 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -263,6 +263,52 @@ describe('CLAUDE.md template copy', () => { let tmpDir: string; let groupsDir: string; + // Replicates register.ts template copy + name update logic + function simulateRegister( + folder: string, + isMain: boolean, + assistantName = 'Andy', + ): void { + const folderDir = path.join(groupsDir, folder); + fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + + // Template copy (register.ts lines 119-138) + const dest = path.join(folderDir, 'CLAUDE.md'); + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + if (fs.existsSync(templatePath)) { + fs.copyFileSync(templatePath, dest); + } + } + + // Name update across all groups (register.ts lines 140-165) + if (assistantName !== 'Andy') { + const mdFiles = fs + .readdirSync(groupsDir) + .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) + .filter((f) => fs.existsSync(f)); + + for (const mdFile of mdFiles) { + let content = fs.readFileSync(mdFile, 'utf-8'); + content = content.replace(/^# Andy$/m, `# ${assistantName}`); + content = content.replace( + /You are Andy/g, + `You are ${assistantName}`, + ); + fs.writeFileSync(mdFile, content); + } + } + } + + function readGroupMd(folder: string): string { + return fs.readFileSync( + path.join(groupsDir, folder, 'CLAUDE.md'), + 'utf-8', + ); + } + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-register-test-')); groupsDir = path.join(tmpDir, 'groups'); @@ -283,123 +329,101 @@ describe('CLAUDE.md template copy', () => { }); it('copies global template for non-main group', () => { - const folder = 'telegram_dev-team'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('telegram_dev-team', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - // Replicate register.ts logic: copy template if dest doesn't exist - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('You are Andy'); - // Should NOT contain main-specific content - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('Admin Context'); + const content = readGroupMd('telegram_dev-team'); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); }); it('copies main template for main group', () => { - const folder = 'whatsapp_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('whatsapp_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - const isMain = true; - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - expect(fs.existsSync(dest)).toBe(true); - expect(fs.readFileSync(dest, 'utf-8')).toContain('Admin Context'); + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); }); - it('does not overwrite existing CLAUDE.md', () => { - const folder = 'slack_main'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(folderDir, { recursive: true }); + it('each channel can have its own main with admin context', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_main', true); + simulateRegister('discord_main', true); - const dest = path.join(folderDir, 'CLAUDE.md'); - fs.writeFileSync(dest, '# Custom\n\nUser-modified content.'); - - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } + for (const folder of [ + 'whatsapp_main', + 'telegram_main', + 'slack_main', + 'discord_main', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Andy'); } - - expect(fs.readFileSync(dest, 'utf-8')).toContain('User-modified content'); - expect(fs.readFileSync(dest, 'utf-8')).not.toContain('You are Andy'); }); - it('updates name in all groups/*/CLAUDE.md files', () => { - // Create a few group folders with CLAUDE.md - for (const folder of ['whatsapp_main', 'telegram_friends']) { - const dir = path.join(groupsDir, folder); - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync( - path.join(dir, 'CLAUDE.md'), - '# Andy\n\nYou are Andy, a personal assistant.', - ); + it('non-main groups across channels get global template', () => { + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_friends', false); + simulateRegister('slack_engineering', false); + simulateRegister('discord_general', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + for (const folder of [ + 'telegram_friends', + 'slack_engineering', + 'discord_general', + ]) { + const content = readGroupMd(folder); + expect(content).toContain('You are Andy'); + expect(content).not.toContain('Admin Context'); } + }); - const assistantName = 'Luna'; + it('custom name propagates to all channels and groups', () => { + // Register multiple channels, last one sets custom name + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_main', true); + simulateRegister('slack_devs', false); + // Final registration triggers name update across all + simulateRegister('discord_main', true, 'Luna'); - // Replicate register.ts glob logic - const mdFiles = fs - .readdirSync(groupsDir) - .map((d) => path.join(groupsDir, d, 'CLAUDE.md')) - .filter((f) => fs.existsSync(f)); - - for (const mdFile of mdFiles) { - let content = fs.readFileSync(mdFile, 'utf-8'); - content = content.replace(/^# Andy$/m, `# ${assistantName}`); - content = content.replace(/You are Andy/g, `You are ${assistantName}`); - fs.writeFileSync(mdFile, content); - } - - // All CLAUDE.md files should be updated, including templates and groups - for (const folder of ['main', 'global', 'whatsapp_main', 'telegram_friends']) { - const content = fs.readFileSync( - path.join(groupsDir, folder, 'CLAUDE.md'), - 'utf-8', - ); + for (const folder of [ + 'main', + 'global', + 'whatsapp_main', + 'telegram_main', + 'slack_devs', + 'discord_main', + ]) { + const content = readGroupMd(folder); expect(content).toContain('# Luna'); expect(content).toContain('You are Luna'); expect(content).not.toContain('Andy'); } }); - it('handles missing template gracefully', () => { - // Remove templates + it('does not overwrite user-modified CLAUDE.md', () => { + simulateRegister('slack_main', true); + // User customizes the file + fs.writeFileSync( + path.join(groupsDir, 'slack_main', 'CLAUDE.md'), + '# Custom\n\nUser-modified content.', + ); + // Re-registering same folder (e.g. re-running /add-slack) + simulateRegister('slack_main', true); + + const content = readGroupMd('slack_main'); + expect(content).toContain('User-modified content'); + expect(content).not.toContain('Admin Context'); + }); + + it('handles missing templates gracefully', () => { fs.unlinkSync(path.join(groupsDir, 'global', 'CLAUDE.md')); fs.unlinkSync(path.join(groupsDir, 'main', 'CLAUDE.md')); - const folder = 'discord_general'; - const folderDir = path.join(groupsDir, folder); - fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); + simulateRegister('discord_general', false); - const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = path.join(groupsDir, 'global', 'CLAUDE.md'); - - if (!fs.existsSync(dest)) { - if (fs.existsSync(templatePath)) { - fs.copyFileSync(templatePath, dest); - } - } - - // No crash, no file created - expect(fs.existsSync(dest)).toBe(false); + expect( + fs.existsSync(path.join(groupsDir, 'discord_general', 'CLAUDE.md')), + ).toBe(false); }); }); From 3207c35e50540618c990f8062b4c8a5d40ae0621 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 12:44:24 +0100 Subject: [PATCH 14/34] fix: promote CLAUDE.md to main template when group becomes main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-main group is re-registered with --is-main, the existing CLAUDE.md (copied from global template) lacked admin context. Now register.ts detects this promotion case and replaces it with the main template. Files that already contain "## Admin Context" are preserved. Adds tests for: - promoting non-main to main upgrades the template - cross-channel promotion (e.g. Telegram non-main → main) - promotion with custom assistant name - re-registration preserves user-modified main CLAUDE.md - re-registration preserves user-modified non-main CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 79 +++++++++++++++++++++++++++++++++++------- setup/register.ts | 29 ++++++++++++---- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 11f0f5f..859a457 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,18 +272,24 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy (register.ts lines 119-138) + // Template copy + promotion (register.ts lines 119-148) const dest = path.join(folderDir, 'CLAUDE.md'); - if (!fs.existsSync(dest)) { - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); + const fileExists = fs.existsSync(dest); + const needsPromotion = + isMain && + fileExists && + !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 140-165) + // Name update across all groups (register.ts lines 150-175) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -401,19 +407,66 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite user-modified CLAUDE.md', () => { + it('does not overwrite main CLAUDE.md that already has admin context', () => { simulateRegister('slack_main', true); - // User customizes the file - fs.writeFileSync( - path.join(groupsDir, 'slack_main', 'CLAUDE.md'), - '# Custom\n\nUser-modified content.', - ); + // User appends custom content to the main template + const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); + fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); + // Preserved: has both admin context AND user additions + expect(content).toContain('Admin Context'); + expect(content).toContain('My Custom Section'); + }); + + it('does not overwrite non-main CLAUDE.md on re-registration', () => { + simulateRegister('telegram_friends', false); + // User customizes the file + const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); + fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); + // Re-registering same folder as non-main + simulateRegister('telegram_friends', false); + + const content = readGroupMd('telegram_friends'); expect(content).toContain('User-modified content'); - expect(content).not.toContain('Admin Context'); + }); + + it('promotes non-main group to main when re-registered with isMain', () => { + // Initially registered as non-main (gets global template) + simulateRegister('telegram_main', false); + expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); + + // User switches this channel to main + simulateRegister('telegram_main', true); + expect(readGroupMd('telegram_main')).toContain('Admin Context'); + }); + + it('promotes across channels — WhatsApp non-main to Telegram main', () => { + // Start with WhatsApp as main, Telegram as non-main + simulateRegister('whatsapp_main', true); + simulateRegister('telegram_control', false); + + expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); + expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); + + // User decides Telegram should be the new main + simulateRegister('telegram_control', true); + expect(readGroupMd('telegram_control')).toContain('Admin Context'); + }); + + it('promotion updates assistant name in promoted file', () => { + // Register as non-main with default name + simulateRegister('slack_ops', false); + expect(readGroupMd('slack_ops')).toContain('You are Andy'); + + // Promote to main with custom name + simulateRegister('slack_ops', true, 'Nova'); + const content = readGroupMd('slack_ops'); + expect(content).toContain('Admin Context'); + expect(content).toContain('You are Nova'); + expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 6e32cd8..270ebfa 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,7 +116,7 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create CLAUDE.md in the new group folder from template if it doesn't exist. + // Create or upgrade CLAUDE.md in the group folder from the appropriate template. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. const groupClaudeMdPath = path.join( projectRoot, @@ -124,15 +124,30 @@ export async function run(args: string[]): Promise { parsed.folder, 'CLAUDE.md', ); - if (!fs.existsSync(groupClaudeMdPath)) { - const templatePath = parsed.isMain - ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') - : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); + const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); + const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; + const fileExists = fs.existsSync(groupClaudeMdPath); + + // Promotion case: group was registered as non-main (got global template) + // and is now being re-registered as main. Replace with main template. + const needsPromotion = + parsed.isMain && + fileExists && + !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); + + if (!fileExists || needsPromotion) { if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { file: groupClaudeMdPath, template: templatePath }, - 'Created CLAUDE.md from template', + { + file: groupClaudeMdPath, + template: templatePath, + promoted: needsPromotion, + }, + needsPromotion + ? 'Promoted CLAUDE.md to main template' + : 'Created CLAUDE.md from template', ); } } From 57085cc02e42ef9be73ca5b5da903da11b898971 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 24 Mar 2026 13:09:26 +0100 Subject: [PATCH 15/34] =?UTF-8?q?fix:=20revert=20promotion=20logic=20?= =?UTF-8?q?=E2=80=94=20never=20overwrite=20existing=20CLAUDE.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The promotion logic (overwriting CLAUDE.md when a group becomes main) is unsafe. Real-world setups use is_main for groups that intentionally lack admin context — e.g. a family chat (whatsapp_casa) with 144 lines of custom persona, PARA workspace, task management, and family context. Overwriting based on missing "## Admin Context" would destroy user work. register.ts now follows a simple rule: create template for new folders, never touch existing files. Tests updated to verify preservation across re-registration and main promotion scenarios. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/register.test.ts | 98 +++++++++++++++++------------------------- setup/register.ts | 32 +++++--------- 2 files changed, 50 insertions(+), 80 deletions(-) diff --git a/setup/register.test.ts b/setup/register.test.ts index 859a457..5a70740 100644 --- a/setup/register.test.ts +++ b/setup/register.test.ts @@ -272,24 +272,18 @@ describe('CLAUDE.md template copy', () => { const folderDir = path.join(groupsDir, folder); fs.mkdirSync(path.join(folderDir, 'logs'), { recursive: true }); - // Template copy + promotion (register.ts lines 119-148) + // Template copy — never overwrite existing (register.ts lines 119-135) const dest = path.join(folderDir, 'CLAUDE.md'); - const templatePath = isMain - ? path.join(groupsDir, 'main', 'CLAUDE.md') - : path.join(groupsDir, 'global', 'CLAUDE.md'); - const fileExists = fs.existsSync(dest); - const needsPromotion = - isMain && - fileExists && - !fs.readFileSync(dest, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(dest)) { + const templatePath = isMain + ? path.join(groupsDir, 'main', 'CLAUDE.md') + : path.join(groupsDir, 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, dest); } } - // Name update across all groups (register.ts lines 150-175) + // Name update across all groups (register.ts lines 140-165) if (assistantName !== 'Andy') { const mdFiles = fs .readdirSync(groupsDir) @@ -407,66 +401,54 @@ describe('CLAUDE.md template copy', () => { } }); - it('does not overwrite main CLAUDE.md that already has admin context', () => { + it('never overwrites existing CLAUDE.md on re-registration', () => { simulateRegister('slack_main', true); - // User appends custom content to the main template + // User customizes the file extensively (persona, workspace, rules) const mdPath = path.join(groupsDir, 'slack_main', 'CLAUDE.md'); - fs.appendFileSync(mdPath, '\n\n## My Custom Section\n\nUser notes here.'); + fs.writeFileSync( + mdPath, + '# Gambi\n\nCustom persona with workspace rules and family context.', + ); // Re-registering same folder (e.g. re-running /add-slack) simulateRegister('slack_main', true); const content = readGroupMd('slack_main'); - // Preserved: has both admin context AND user additions - expect(content).toContain('Admin Context'); - expect(content).toContain('My Custom Section'); + expect(content).toContain('Custom persona'); + expect(content).not.toContain('Admin Context'); }); - it('does not overwrite non-main CLAUDE.md on re-registration', () => { - simulateRegister('telegram_friends', false); - // User customizes the file - const mdPath = path.join(groupsDir, 'telegram_friends', 'CLAUDE.md'); - fs.writeFileSync(mdPath, '# Custom\n\nUser-modified content.'); - // Re-registering same folder as non-main - simulateRegister('telegram_friends', false); + it('never overwrites when non-main becomes main (isMain changes)', () => { + // User registers a family group as non-main + simulateRegister('whatsapp_casa', false); + // User extensively customizes it (PARA system, task management, etc.) + const mdPath = path.join(groupsDir, 'whatsapp_casa', 'CLAUDE.md'); + fs.writeFileSync( + mdPath, + '# Casa\n\nFamily group with PARA system, task management, shopping lists.', + ); + // Later, user promotes to main (no trigger required) — CLAUDE.md must be preserved + simulateRegister('whatsapp_casa', true); - const content = readGroupMd('telegram_friends'); - expect(content).toContain('User-modified content'); + const content = readGroupMd('whatsapp_casa'); + expect(content).toContain('PARA system'); + expect(content).not.toContain('Admin Context'); }); - it('promotes non-main group to main when re-registered with isMain', () => { - // Initially registered as non-main (gets global template) - simulateRegister('telegram_main', false); - expect(readGroupMd('telegram_main')).not.toContain('Admin Context'); - - // User switches this channel to main - simulateRegister('telegram_main', true); - expect(readGroupMd('telegram_main')).toContain('Admin Context'); - }); - - it('promotes across channels — WhatsApp non-main to Telegram main', () => { - // Start with WhatsApp as main, Telegram as non-main + it('preserves custom CLAUDE.md across channels when changing main', () => { + // Real-world scenario: WhatsApp main + customized Discord research channel simulateRegister('whatsapp_main', true); - simulateRegister('telegram_control', false); + simulateRegister('discord_main', false); + const discordPath = path.join(groupsDir, 'discord_main', 'CLAUDE.md'); + fs.writeFileSync( + discordPath, + '# Gambi HQ — Research Assistant\n\nResearch workflows for Laura and Ethan.', + ); + // Discord becomes main too — custom content must survive + simulateRegister('discord_main', true); + expect(readGroupMd('discord_main')).toContain('Research Assistant'); + // WhatsApp main also untouched expect(readGroupMd('whatsapp_main')).toContain('Admin Context'); - expect(readGroupMd('telegram_control')).not.toContain('Admin Context'); - - // User decides Telegram should be the new main - simulateRegister('telegram_control', true); - expect(readGroupMd('telegram_control')).toContain('Admin Context'); - }); - - it('promotion updates assistant name in promoted file', () => { - // Register as non-main with default name - simulateRegister('slack_ops', false); - expect(readGroupMd('slack_ops')).toContain('You are Andy'); - - // Promote to main with custom name - simulateRegister('slack_ops', true, 'Nova'); - const content = readGroupMd('slack_ops'); - expect(content).toContain('Admin Context'); - expect(content).toContain('You are Nova'); - expect(content).not.toContain('Andy'); }); it('handles missing templates gracefully', () => { diff --git a/setup/register.ts b/setup/register.ts index 270ebfa..c08d910 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -116,38 +116,26 @@ export async function run(args: string[]): Promise { recursive: true, }); - // Create or upgrade CLAUDE.md in the group folder from the appropriate template. + // Create CLAUDE.md in the new group folder from template if it doesn't exist. // The agent runs with CWD=/workspace/group and loads CLAUDE.md from there. + // Never overwrite an existing CLAUDE.md — users customize these extensively + // (persona, workspace structure, communication rules, family context, etc.) + // and a stock template replacement would destroy that work. const groupClaudeMdPath = path.join( projectRoot, 'groups', parsed.folder, 'CLAUDE.md', ); - const mainTemplatePath = path.join(projectRoot, 'groups', 'main', 'CLAUDE.md'); - const globalTemplatePath = path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); - const templatePath = parsed.isMain ? mainTemplatePath : globalTemplatePath; - const fileExists = fs.existsSync(groupClaudeMdPath); - - // Promotion case: group was registered as non-main (got global template) - // and is now being re-registered as main. Replace with main template. - const needsPromotion = - parsed.isMain && - fileExists && - !fs.readFileSync(groupClaudeMdPath, 'utf-8').includes('## Admin Context'); - - if (!fileExists || needsPromotion) { + if (!fs.existsSync(groupClaudeMdPath)) { + const templatePath = parsed.isMain + ? path.join(projectRoot, 'groups', 'main', 'CLAUDE.md') + : path.join(projectRoot, 'groups', 'global', 'CLAUDE.md'); if (fs.existsSync(templatePath)) { fs.copyFileSync(templatePath, groupClaudeMdPath); logger.info( - { - file: groupClaudeMdPath, - template: templatePath, - promoted: needsPromotion, - }, - needsPromotion - ? 'Promoted CLAUDE.md to main template' - : 'Created CLAUDE.md from template', + { file: groupClaudeMdPath, template: templatePath }, + 'Created CLAUDE.md from template', ); } } From d05a8dec495713a09bbe7ed8d73c480758d49910 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Tue, 24 Mar 2026 15:21:13 +0000 Subject: [PATCH 16/34] fix: refresh stale agent-runner source cache on code changes Closes #1361 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runner.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 1dc607f..47a8387 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,8 +191,17 @@ function buildVolumeMounts( group.folder, 'agent-runner-src', ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + if (fs.existsSync(agentRunnerSrc)) { + const srcIndex = path.join(agentRunnerSrc, 'index.ts'); + const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts'); + const needsCopy = + !fs.existsSync(groupAgentRunnerDir) || + !fs.existsSync(cachedIndex) || + (fs.existsSync(srcIndex) && + fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs); + if (needsCopy) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } } mounts.push({ hostPath: groupAgentRunnerDir, From 0015931e37c7d2f66251b02276fc2922c5529672 Mon Sep 17 00:00:00 2001 From: MrBob Date: Tue, 24 Mar 2026 12:26:17 -0300 Subject: [PATCH 17/34] fix: honor per-group trigger patterns --- src/config.ts | 22 +++++++++++++---- src/formatting.test.ts | 56 +++++++++++++++++++++++++++++++++++------- src/index.ts | 11 ++++++--- 3 files changed, 71 insertions(+), 18 deletions(-) diff --git a/src/config.ts b/src/config.ts index 63d1207..fc3fc95 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,7 +4,11 @@ import path from 'path'; import { readEnvFile } from './env.js'; // Read config values from .env (falls back to process.env). -const envConfig = readEnvFile(['ASSISTANT_NAME', 'ASSISTANT_HAS_OWN_NUMBER', 'ONECLI_URL']); +const envConfig = readEnvFile([ + 'ASSISTANT_NAME', + 'ASSISTANT_HAS_OWN_NUMBER', + 'ONECLI_URL', +]); export const ASSISTANT_NAME = process.env.ASSISTANT_NAME || envConfig.ASSISTANT_NAME || 'Andy'; @@ -58,10 +62,18 @@ function escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -export const TRIGGER_PATTERN = new RegExp( - `^@${escapeRegex(ASSISTANT_NAME)}\\b`, - 'i', -); +export function buildTriggerPattern(trigger: string): RegExp { + return new RegExp(`^${escapeRegex(trigger.trim())}\\b`, 'i'); +} + +export const DEFAULT_TRIGGER = `@${ASSISTANT_NAME}`; + +export function getTriggerPattern(trigger?: string): RegExp { + const normalizedTrigger = trigger?.trim(); + return buildTriggerPattern(normalizedTrigger || DEFAULT_TRIGGER); +} + +export const TRIGGER_PATTERN = buildTriggerPattern(DEFAULT_TRIGGER); // Timezone for scheduled tasks (cron expressions, etc.) // Uses system timezone by default diff --git a/src/formatting.test.ts b/src/formatting.test.ts index 8a2160c..a630f20 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { ASSISTANT_NAME, TRIGGER_PATTERN } from './config.js'; +import { + ASSISTANT_NAME, + getTriggerPattern, + TRIGGER_PATTERN, +} from './config.js'; import { escapeXml, formatMessages, @@ -161,6 +165,28 @@ describe('TRIGGER_PATTERN', () => { }); }); +describe('getTriggerPattern', () => { + it('uses the configured per-group trigger when provided', () => { + const pattern = getTriggerPattern('@Claw'); + + expect(pattern.test('@Claw hello')).toBe(true); + expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(false); + }); + + it('falls back to the default trigger when group trigger is missing', () => { + const pattern = getTriggerPattern(undefined); + + expect(pattern.test(`@${ASSISTANT_NAME} hello`)).toBe(true); + }); + + it('treats regex characters in custom triggers literally', () => { + const pattern = getTriggerPattern('@C.L.A.U.D.E'); + + expect(pattern.test('@C.L.A.U.D.E hello')).toBe(true); + expect(pattern.test('@CXLXAUXDXE hello')).toBe(false); + }); +}); + // --- Outbound formatting (internal tag stripping + prefix) --- describe('stripInternalTags', () => { @@ -207,7 +233,7 @@ describe('formatOutbound', () => { describe('trigger gating (requiresTrigger interaction)', () => { // Replicates the exact logic from processGroupMessages and startMessageLoop: - // if (!isMainGroup && group.requiresTrigger !== false) { check trigger } + // if (!isMainGroup && group.requiresTrigger !== false) { check group.trigger } function shouldRequireTrigger( isMainGroup: boolean, requiresTrigger: boolean | undefined, @@ -218,39 +244,51 @@ describe('trigger gating (requiresTrigger interaction)', () => { function shouldProcess( isMainGroup: boolean, requiresTrigger: boolean | undefined, + trigger: string | undefined, messages: NewMessage[], ): boolean { if (!shouldRequireTrigger(isMainGroup, requiresTrigger)) return true; - return messages.some((m) => TRIGGER_PATTERN.test(m.content.trim())); + const triggerPattern = getTriggerPattern(trigger); + return messages.some((m) => triggerPattern.test(m.content.trim())); } it('main group always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, undefined, msgs)).toBe(true); + expect(shouldProcess(true, undefined, undefined, msgs)).toBe(true); }); it('main group processes even with requiresTrigger=true', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(true, true, msgs)).toBe(true); + expect(shouldProcess(true, true, undefined, msgs)).toBe(true); }); it('non-main group with requiresTrigger=undefined requires trigger (defaults to true)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, undefined, msgs)).toBe(false); + expect(shouldProcess(false, undefined, undefined, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true requires trigger', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, true, msgs)).toBe(false); + expect(shouldProcess(false, true, undefined, msgs)).toBe(false); }); it('non-main group with requiresTrigger=true processes when trigger present', () => { const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; - expect(shouldProcess(false, true, msgs)).toBe(true); + expect(shouldProcess(false, true, undefined, msgs)).toBe(true); + }); + + it('non-main group uses its per-group trigger instead of the default trigger', () => { + const msgs = [makeMsg({ content: '@Claw do something' })]; + expect(shouldProcess(false, true, '@Claw', msgs)).toBe(true); + }); + + it('non-main group does not process when only the default trigger is present for a custom-trigger group', () => { + const msgs = [makeMsg({ content: `@${ASSISTANT_NAME} do something` })]; + expect(shouldProcess(false, true, '@Claw', msgs)).toBe(false); }); it('non-main group with requiresTrigger=false always processes (no trigger needed)', () => { const msgs = [makeMsg({ content: 'hello no trigger' })]; - expect(shouldProcess(false, false, msgs)).toBe(true); + expect(shouldProcess(false, false, undefined, msgs)).toBe(true); }); }); diff --git a/src/index.ts b/src/index.ts index 3f5e710..5116738 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,11 +5,12 @@ import { OneCLI } from '@onecli-sh/sdk'; import { ASSISTANT_NAME, + DEFAULT_TRIGGER, + getTriggerPattern, IDLE_TIMEOUT, ONECLI_URL, POLL_INTERVAL, TIMEZONE, - TRIGGER_PATTERN, } from './config.js'; import './channels/index.js'; import { @@ -194,10 +195,11 @@ async function processGroupMessages(chatJid: string): Promise { // For non-main groups, check if trigger is required and present if (!isMainGroup && group.requiresTrigger !== false) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = missedMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); if (!hasTrigger) return true; @@ -376,7 +378,7 @@ async function startMessageLoop(): Promise { } messageLoopRunning = true; - logger.info(`NanoClaw running (trigger: @${ASSISTANT_NAME})`); + logger.info(`NanoClaw running (default trigger: ${DEFAULT_TRIGGER})`); while (true) { try { @@ -422,10 +424,11 @@ async function startMessageLoop(): Promise { // Non-trigger messages accumulate in DB and get pulled as // context when a trigger eventually arrives. if (needsTrigger) { + const triggerPattern = getTriggerPattern(group.trigger); const allowlistCfg = loadSenderAllowlist(); const hasTrigger = groupMessages.some( (m) => - TRIGGER_PATTERN.test(m.content.trim()) && + triggerPattern.test(m.content.trim()) && (m.is_from_me || isTriggerAllowed(chatJid, m.sender, allowlistCfg)), ); From aeabfcc65a065d69bf59a56dade373d82442a911 Mon Sep 17 00:00:00 2001 From: nanoclaw3 Date: Wed, 25 Mar 2026 03:48:08 +0000 Subject: [PATCH 18/34] fix: enable loginctl linger so user service survives SSH logout Without linger enabled, systemd terminates all user-level processes (including the NanoClaw service) when the last SSH session closes. This adds `loginctl enable-linger` during setup for non-root users. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/service.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/setup/service.ts b/setup/service.ts index 71b3c63..c385267 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -266,6 +266,20 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; // Kill orphaned nanoclaw processes to avoid channel connection conflicts killOrphanedProcesses(projectRoot); + // Enable lingering so the user service survives SSH logout. + // Without linger, systemd terminates all user processes when the last session closes. + if (!runningAsRoot) { + try { + execSync('loginctl enable-linger', { stdio: 'ignore' }); + logger.info('Enabled loginctl linger for current user'); + } catch (err) { + logger.warn( + { err }, + 'loginctl enable-linger failed — service may stop on SSH logout', + ); + } + } + // Enable and start try { execSync(`${systemctlPrefix} daemon-reload`, { stdio: 'ignore' }); @@ -301,6 +315,7 @@ WantedBy=${runningAsRoot ? 'multi-user.target' : 'default.target'}`; UNIT_PATH: unitPath, SERVICE_LOADED: serviceLoaded, ...(dockerGroupStale ? { DOCKER_GROUP_STALE: true } : {}), + LINGER_ENABLED: !runningAsRoot, STATUS: 'success', LOG: 'logs/setup.log', }); From 2c46d74066c671b18cce04db551d65276f59d0a1 Mon Sep 17 00:00:00 2001 From: ingyukoh Date: Wed, 25 Mar 2026 15:21:44 +0900 Subject: [PATCH 19/34] fix: clarify WhatsApp phone number prompt to prevent auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example "1234567890" was ambiguous — users couldn't tell where the country code ended and the number began, and some included a leading "+" which caused pairing to fail. Use a realistic US example (14155551234) and explicit formatting rules in both the prompt and troubleshooting. Closes #447 --- .claude/skills/add-whatsapp/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 0774799..cbdf00b 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -40,7 +40,7 @@ Otherwise (macOS, desktop Linux, or WSL) → AskUserQuestion: How do you want to If they chose pairing code: -AskUserQuestion: What is your phone number? (Include country code without +, e.g., 1234567890) +AskUserQuestion: What is your phone number? (Digits only — country code followed by your 10-digit number, no + prefix, spaces, or dashes. Example: 14155551234 where 1 is the US country code and 4155551234 is the phone number.) ## Phase 2: Apply Code Changes @@ -308,7 +308,7 @@ rm -rf store/auth/ && npx tsx src/whatsapp-auth.ts --pairing-code --phone Date: Wed, 25 Mar 2026 16:17:26 +0900 Subject: [PATCH 20/34] fix: create CLAUDE.md from template when registering groups via IPC The registerGroup() function in index.ts creates the group folder and logs subdirectory but never copies the global CLAUDE.md template. Agents in newly registered groups start without identity or instructions until the container is manually fixed. Copy groups/global/CLAUDE.md into the new group folder on registration, substituting the assistant name if it differs from the default. Closes #1391 --- src/index.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/index.ts b/src/index.ts index 3f5e710..1465d56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { ASSISTANT_NAME, + GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, POLL_INTERVAL, @@ -133,6 +134,25 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // Create group folder fs.mkdirSync(path.join(groupDir, 'logs'), { recursive: true }); + // Copy CLAUDE.md template into the new group folder so agents have + // identity and instructions from the first run. (Fixes #1391) + const groupMdFile = path.join(groupDir, 'CLAUDE.md'); + if (!fs.existsSync(groupMdFile)) { + const templateFile = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); + if (fs.existsSync(templateFile)) { + let content = fs.readFileSync(templateFile, 'utf-8'); + if (ASSISTANT_NAME !== 'Andy') { + content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); + content = content.replace( + /You are Andy/g, + `You are ${ASSISTANT_NAME}`, + ); + } + fs.writeFileSync(groupMdFile, content); + logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); + } + } + // Ensure a corresponding OneCLI agent exists (best-effort, non-blocking) ensureOneCLIAgent(jid, group); From d622a79fe24aa10079a3bd39c5e777a45a9f0f8d Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 11:41:25 +0000 Subject: [PATCH 21/34] fix: suppress spurious chat message on script skip When a script returns wakeAgent=false, set result to null so the host doesn't forward an internal status string to the user's chat. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 382439f..25554f9 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -566,7 +566,7 @@ async function main(): Promise { log(`Script decided not to wake agent: ${reason}`); writeOutput({ status: 'success', - result: `Script: ${reason}`, + result: null, }); return; } From 80f6fb2b9abd57a844243b4d58a6d0e573dcdf16 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Wed, 25 Mar 2026 13:37:01 +0000 Subject: [PATCH 22/34] style: fix prettier formatting in registerGroup template copy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1465d56..f78d8e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -143,10 +143,7 @@ function registerGroup(jid: string, group: RegisteredGroup): void { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { content = content.replace(/^# Andy$/m, `# ${ASSISTANT_NAME}`); - content = content.replace( - /You are Andy/g, - `You are ${ASSISTANT_NAME}`, - ); + content = content.replace(/You are Andy/g, `You are ${ASSISTANT_NAME}`); } fs.writeFileSync(groupMdFile, content); logger.info({ folder: group.folder }, 'Created CLAUDE.md from template'); From b7434b8a76b7fb33ba85348e0f5054e048bfbd87 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Wed, 25 Mar 2026 14:07:59 +0000 Subject: [PATCH 23/34] fix: use explicit Read tool directive for diagnostics instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous wording ("Send diagnostics data by following ...") was too passive — Claude treated the backtick-quoted path as informational rather than an action, so the diagnostics file was never actually read and the PostHog prompt was silently skipped. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/setup/SKILL.md | 3 ++- .claude/skills/update-nanoclaw/SKILL.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/skills/setup/SKILL.md b/.claude/skills/setup/SKILL.md index e12e0ea..54c3d2d 100644 --- a/.claude/skills/setup/SKILL.md +++ b/.claude/skills/setup/SKILL.md @@ -287,4 +287,5 @@ Tell user to test: send a message in their registered chat. Show: `tail -f logs/ ## 9. Diagnostics -Send diagnostics data by following `.claude/skills/setup/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/setup/diagnostics.md`. +2. Follow every step in that file before completing setup. diff --git a/.claude/skills/update-nanoclaw/SKILL.md b/.claude/skills/update-nanoclaw/SKILL.md index 2207910..496d409 100644 --- a/.claude/skills/update-nanoclaw/SKILL.md +++ b/.claude/skills/update-nanoclaw/SKILL.md @@ -237,4 +237,5 @@ Tell the user: ## Diagnostics -Send diagnostics data by following `.claude/skills/update-nanoclaw/diagnostics.md`. +1. Use the Read tool to read `.claude/skills/update-nanoclaw/diagnostics.md`. +2. Follow every step in that file before finishing. From 0240f48751914a3ab648245fbaec7d4f38b7da76 Mon Sep 17 00:00:00 2001 From: NanoClaw User Date: Wed, 25 Mar 2026 14:43:08 +0000 Subject: [PATCH 24/34] fix: use main template for isMain groups in runtime registration Main groups (e.g. telegram_main) should get the full main template with Admin Context section, not the minimal global template. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index f78d8e9..b3746f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,7 +138,11 @@ function registerGroup(jid: string, group: RegisteredGroup): void { // identity and instructions from the first run. (Fixes #1391) const groupMdFile = path.join(groupDir, 'CLAUDE.md'); if (!fs.existsSync(groupMdFile)) { - const templateFile = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); + const templateFile = path.join( + GROUPS_DIR, + group.isMain ? 'main' : 'global', + 'CLAUDE.md', + ); if (fs.existsSync(templateFile)) { let content = fs.readFileSync(templateFile, 'utf-8'); if (ASSISTANT_NAME !== 'Andy') { From 31c03cf92406271a1c5866874ab8bb1a1489e14a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:45 +0000 Subject: [PATCH 25/34] chore: bump version to 1.2.28 --- 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 1e0a7e3..0b699ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 91746b0..a7f6a5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.27", + "version": "1.2.28", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 9391304e7043c9c07a839d72219c7ffbe92a704e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:27:47 +0000 Subject: [PATCH 26/34] =?UTF-8?q?docs:=20update=20token=20count=20to=2040.?= =?UTF-8?q?2k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 301a593..8c3b0c8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.1k tokens, 20% of context window + + 40.2k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.1k + + 40.2k From bb736f37f2ea8a23dab905c7d885492082ec2bf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:25 +0000 Subject: [PATCH 27/34] chore: bump version to 1.2.29 --- 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 0b699ed..4128040 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index a7f6a5f..0822ed9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.28", + "version": "1.2.29", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From df76dc6797807ee9702c56a30731086fca63dab6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:28:27 +0000 Subject: [PATCH 28/34] =?UTF-8?q?docs:=20update=20token=20count=20to=2041.?= =?UTF-8?q?0k=20tokens=20=C2=B7=2020%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 8c3b0c8..be808ed 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 40.2k tokens, 20% of context window + + 41.0k tokens, 20% of context window @@ -15,8 +15,8 @@ tokens - - 40.2k + + 41.0k From fd444681ef571f13a37153a4b80d6cd6a2f6b19f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:36:23 +0000 Subject: [PATCH 29/34] chore: bump version to 1.2.30 --- 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 4128040..68f9244 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 0822ed9..3ceb71f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.29", + "version": "1.2.30", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From b8f6a9b794a043e6bd9a0be496322d85a5c62eb9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:36:26 +0000 Subject: [PATCH 30/34] =?UTF-8?q?docs:=20update=20token=20count=20to=2041.?= =?UTF-8?q?2k=20tokens=20=C2=B7=2021%=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 be808ed..50f3af8 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 41.0k tokens, 20% of context window + + 41.2k tokens, 21% of context window @@ -15,8 +15,8 @@ tokens - - 41.0k + + 41.2k From 6d4f972ad02aba57a799dcf49c4160f2d1f2c0c8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 15:37:53 +0000 Subject: [PATCH 31/34] chore: bump version to 1.2.31 --- 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 68f9244..9cd9fae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 3ceb71f..056e931 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.30", + "version": "1.2.31", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js", From 7bba21af1e71422618891e1995f08ad4825eb504 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Wed, 25 Mar 2026 22:01:54 +0200 Subject: [PATCH 32/34] feat(skill): add channel-formatting skill Adds SKILL.md for channel-aware text formatting. When applied, converts Claude's Markdown output to each channel's native syntax (WhatsApp, Telegram, Slack) before delivery. Source code lives on the skill/channel-formatting branch. Co-Authored-By: Ken Bolton Co-Authored-By: Claude Sonnet 4.6 --- .claude/skills/channel-formatting/SKILL.md | 137 +++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .claude/skills/channel-formatting/SKILL.md diff --git a/.claude/skills/channel-formatting/SKILL.md b/.claude/skills/channel-formatting/SKILL.md new file mode 100644 index 0000000..b995fb8 --- /dev/null +++ b/.claude/skills/channel-formatting/SKILL.md @@ -0,0 +1,137 @@ +--- +name: channel-formatting +description: Convert Claude's Markdown output to each channel's native text syntax before delivery. Adds zero-dependency formatting for WhatsApp, Telegram, and Slack (marker substitution). Also ships a Signal rich-text helper (parseSignalStyles) used by the Signal skill. +--- + +# Channel Formatting + +This skill wires channel-aware Markdown conversion into the outbound pipeline so Claude's +responses render natively on each platform — no more literal `**asterisks**` in WhatsApp or +Telegram. + +| Channel | Transformation | +|---------|---------------| +| WhatsApp | `**bold**` → `*bold*`, `*italic*` → `_italic_`, headings → bold, links flattened | +| Telegram | same as WhatsApp | +| Slack | same as WhatsApp, but links become `` | +| Discord | passthrough (Discord already renders Markdown) | +| Signal | passthrough for `parseTextStyles`; `parseSignalStyles` in `src/text-styles.ts` produces plain text + native `textStyle` ranges for use by the Signal skill | + +Code blocks (fenced and inline) are always protected — their content is never transformed. + +## Phase 1: Pre-flight + +### Check if already applied + +```bash +test -f src/text-styles.ts && echo "already applied" || echo "not yet applied" +``` + +If `already applied`, skip to Phase 3 (Verify). + +## Phase 2: Apply Code Changes + +### Ensure the upstream remote + +```bash +git remote -v +``` + +If an `upstream` remote pointing to `https://github.com/qwibitai/nanoclaw.git` is missing, +add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/channel-formatting +git merge upstream/skill/channel-formatting +``` + +If there are merge conflicts on `package-lock.json`, resolve them by accepting the incoming +version and continuing: + +```bash +git checkout --theirs package-lock.json +git add package-lock.json +git merge --continue +``` + +For any other conflict, read the conflicted file and reconcile both sides manually. + +This merge adds: + +- `src/text-styles.ts` — `parseTextStyles(text, channel)` for marker substitution and + `parseSignalStyles(text)` for Signal native rich text +- `src/router.ts` — `formatOutbound` gains an optional `channel` parameter; when provided + it calls `parseTextStyles` after stripping `` tags +- `src/index.ts` — both outbound `sendMessage` paths pass `channel.name` to `formatOutbound` +- `src/formatting.test.ts` — test coverage for both functions across all channels + +### Validate + +```bash +npm install +npm run build +npx vitest run src/formatting.test.ts +``` + +All 73 tests should pass and the build should be clean before continuing. + +## Phase 3: Verify + +### Rebuild and restart + +```bash +npm run build +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +### Spot-check formatting + +Send a message through any registered WhatsApp or Telegram chat that will trigger a +response from Claude. Ask something that will produce formatted output, such as: + +> Summarise the three main advantages of TypeScript using bullet points and **bold** headings. + +Confirm that the response arrives with native bold (`*text*`) rather than raw double +asterisks. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log +``` + +## Signal Skill Integration + +If you have the Signal skill installed, `src/channels/signal.ts` can import +`parseSignalStyles` from the newly present `src/text-styles.ts`: + +```typescript +import { parseSignalStyles, SignalTextStyle } from '../text-styles.js'; +``` + +`parseSignalStyles` returns `{ text: string, textStyle: SignalTextStyle[] }` where +`textStyle` is an array of `{ style, start, length }` objects suitable for the +`signal-cli` JSON-RPC `textStyles` parameter (format: `"start:length:STYLE"`). + +## Removal + +```bash +# Remove the new file +rm src/text-styles.ts + +# Revert router.ts to remove the channel param +git diff upstream/main src/router.ts # review changes +git checkout upstream/main -- src/router.ts + +# Revert the index.ts sendMessage call sites to plain formatOutbound(rawText) +# (edit manually or: git checkout upstream/main -- src/index.ts) + +npm run build +``` \ No newline at end of file From 1f36232ef0e8b35018c0ac2e2318c23837b94be5 Mon Sep 17 00:00:00 2001 From: flobo3 Date: Wed, 25 Mar 2026 22:25:00 +0200 Subject: [PATCH 33/34] docs: add flobo3 to contributors --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 1d4a5de..ca7f3cb 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -13,3 +13,4 @@ Thanks to everyone who has contributed to NanoClaw! - [baijunjie](https://github.com/baijunjie) — BaiJunjie - [Michaelliv](https://github.com/Michaelliv) — Michael - [kk17](https://github.com/kk17) — Kyle Zhike Chen +- [flobo3](https://github.com/flobo3) — Flo From 3a26f69c7fa558a402c248f016435a8fd64410b2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Mar 2026 20:39:06 +0000 Subject: [PATCH 34/34] chore: bump version to 1.2.32 --- 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 9cd9fae..987b285 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "dependencies": { "@onecli-sh/sdk": "^0.2.0", "better-sqlite3": "11.10.0", diff --git a/package.json b/package.json index 056e931..0095817 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "1.2.31", + "version": "1.2.32", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "main": "dist/index.js",