From 0e9dadfaeef387bbfe8c2b97f2eeecbe03ec94e1 Mon Sep 17 00:00:00 2001 From: Ziv Daniel <5122000+ziv-daniel@users.noreply.github.com> Date: Sun, 3 May 2026 15:40:46 +0300 Subject: [PATCH 1/9] fix: accept media-only messages with empty text in onNewMessage /./ requires at least one character and silently drops messages with no text (e.g. Telegram photo/video/file sent without a caption). Switching to /[\s\S]*/ matches the empty string too, so media-only messages now reach the router and then the agent. --- src/channels/chat-sdk-bridge.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 18ab2cb..52c92ba 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -253,12 +253,12 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // Chat SDK dispatch (handling-events.mdx §"Handler dispatch order") is // exclusive: subscribed → onSubscribedMessage; unsubscribed+mention → // onNewMention; unsubscribed+pattern-match → onNewMessage. Registering - // with `/./` lets the router see every plain message on every - // unsubscribed thread the bot can see. The router short-circuits via + // with `/[\s\S]*/` lets the router see every plain message (including + // media-only messages with empty text) on every unsubscribed thread the // getMessagingGroupWithAgentCount (~1 DB read) for unwired channels, // so forwarding every one is cheap enough to not need a bridge-side // flood gate. - chat.onNewMessage(/./, async (thread, message) => { + chat.onNewMessage(/[\s\S]*/, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); From 7fc68a100814b21bb2adc45e43a3e021dd4927f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 3 May 2026 14:04:59 +0000 Subject: [PATCH 2/9] chore: bump version to 2.0.28 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 446bc1b..f305bec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.27", + "version": "2.0.28", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5dc54194abdeec33f0c7e6e054e5b98e3b9cacff Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Sun, 3 May 2026 09:16:13 -0700 Subject: [PATCH 3/9] Recognize ANTHROPIC_AUTH_TOKEN in setup verification The credential proxy already reads ANTHROPIC_AUTH_TOKEN (credential-proxy.ts line 33) and uses it for OAuth-mode authentication, but setup/verify.ts did not include it in its credential-detection regex. Users with ANTHROPIC_AUTH_TOKEN in .env saw 'CREDENTIALS: missing' even though their credentials were valid at runtime. Add ANTHROPIC_AUTH_TOKEN to the regex and add a matching test case. Closes gh-853 --- setup/environment.test.ts | 13 ++++++++++--- setup/verify.ts | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/setup/environment.test.ts b/setup/environment.test.ts index 7765693..93f61c3 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -84,21 +84,28 @@ describe('credentials detection', () => { const content = 'SOME_KEY=value\nANTHROPIC_API_KEY=sk-ant-test123\nOTHER=foo'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(true); }); it('detects CLAUDE_CODE_OAUTH_TOKEN in env content', () => { const content = 'CLAUDE_CODE_OAUTH_TOKEN=token123'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); + expect(hasCredentials).toBe(true); + }); + + it('detects ANTHROPIC_AUTH_TOKEN in env content', () => { + const content = 'ANTHROPIC_AUTH_TOKEN=token123\nANTHROPIC_BASE_URL=http://localhost:8080'; + const hasCredentials = + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(true); }); it('returns false when no credentials', () => { const content = 'ASSISTANT_NAME="Andy"\nOTHER=foo'; const hasCredentials = - /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY)=/m.test(content); + /^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(content); expect(hasCredentials).toBe(false); }); }); diff --git a/setup/verify.ts b/setup/verify.ts index de1160c..683398e 100644 --- a/setup/verify.ts +++ b/setup/verify.ts @@ -139,7 +139,7 @@ export async function run(_args: string[]): Promise { const envFile = path.join(projectRoot, '.env'); if (fs.existsSync(envFile)) { const envContent = fs.readFileSync(envFile, 'utf-8'); - if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ONECLI_URL)=/m.test(envContent)) { + if (/^(CLAUDE_CODE_OAUTH_TOKEN|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|ONECLI_URL)=/m.test(envContent)) { credentials = 'configured'; } } From 6a05e41afe620c5b8a2bdef5a75f90426ab16a38 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 14:47:52 +0300 Subject: [PATCH 4/9] fix: require interactive terminal for migrate-v2.sh The migration script has interactive prompts and streams progress output that gets collapsed when run via Claude Code's Bash tool. Add a TTY guard that exits early with instructions to use the ! prefix instead. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- migrate-v2.sh | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e65515a..3104b32 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca **Do this instead:** 1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). -2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `bash migrate-v2.sh` to migrate instead."* -3. Wait for the user to confirm before doing anything else. Do not attempt the migration yourself. +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `! bash migrate-v2.sh` to migrate instead."* +3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and must be run by the user with the `!` prefix. If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. diff --git a/migrate-v2.sh b/migrate-v2.sh index eb5a381..ac9f4e0 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -5,6 +5,9 @@ # Run from the v2 directory: # bash migrate-v2.sh # +# From Claude Code, use the ! prefix so it runs in your terminal: +# ! bash migrate-v2.sh +# # Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). # Installs prerequisites (Node, pnpm, deps) via the existing setup.sh # bootstrap, then runs the migration steps. @@ -17,6 +20,19 @@ set -uo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$PROJECT_ROOT" +# This script has interactive prompts (channel selection, service switchover) +# and streams progress output — it must run in a real terminal, not inside +# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output). +if ! [ -t 0 ] || ! [ -t 1 ]; then + echo "This script requires an interactive terminal." + echo "" + echo "If you're in Claude Code, run it directly with the ! prefix:" + echo " ! bash migrate-v2.sh" + echo "" + echo "Or run it in a separate terminal session." + exit 1 +fi + LOGS_DIR="$PROJECT_ROOT/logs" STEPS_DIR="$LOGS_DIR/migrate-steps" MIGRATE_LOG="$LOGS_DIR/migrate-v2.log" From d88b0807e60dd63b787432fc37288a0d962264ab Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 13:02:34 +0000 Subject: [PATCH 5/9] fix: retire legacy v1 service file after migration switchover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After migration keeps v2, the old unslugged `nanoclaw.service` (or `com.nanoclaw.plist`) was only disabled — the unit file stayed on disk. A `systemctl --user restart nanoclaw` would start v1 instead of v2. Now the migration removes the old file and symlinks it to the v2 unit, so the legacy name transparently starts v2. Handles systemd (Linux/WSL) and launchd (macOS). Idempotent — skips if the symlink already exists. Co-Authored-By: Claude Opus 4.6 --- migrate-v2.sh | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index ac9f4e0..aedbf0f 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -563,6 +563,51 @@ echo echo "$(bold 'Service switchover')" echo +# Retire the legacy v1 service file and alias it to the v2 unit. +# Called after the user confirms "keep v2", or when v1 wasn't running. +# Idempotent — safe to call multiple times. +retire_v1_service() { + if [ -z "$V2_SERVICE" ]; then + return + fi + + if [ "$PLATFORM_SERVICE" = "systemd" ]; then + local unit_dir="$HOME/.config/systemd/user" + local v1_file="$unit_dir/${V1_SERVICE}.service" + local v2_file="${V2_SERVICE}.service" + + # Already a correct symlink — nothing to do + if [ -L "$v1_file" ] && [ "$(readlink "$v1_file")" = "$v2_file" ]; then + return + fi + + # Only retire if the file exists (as a regular file or stale symlink) + if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then + systemctl --user stop "$V1_SERVICE" 2>/dev/null || true + systemctl --user disable "$V1_SERVICE" 2>/dev/null || true + rm -f "$v1_file" + ln -s "$v2_file" "$v1_file" + systemctl --user daemon-reload 2>/dev/null || true + step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + fi + + elif [ "$PLATFORM_SERVICE" = "launchd" ]; then + local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist" + local v2_plist="${V2_SERVICE}.plist" + + if [ -L "$v1_plist" ] && [ "$(readlink "$v1_plist")" = "$v2_plist" ]; then + return + fi + + if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then + launchctl unload "$v1_plist" 2>/dev/null || true + rm -f "$v1_plist" + ln -s "$v2_plist" "$v1_plist" + step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + fi + fi +} + # Detect platform and service names V1_SERVICE="" V2_SERVICE="" @@ -651,16 +696,14 @@ if [ "$V1_RUNNING" = "true" ]; then SERVICE_SWITCHED=false else step_ok "Keeping v2 service" - # Disable v1 from auto-starting - if [ "$PLATFORM_SERVICE" = "systemd" ]; then - systemctl --user disable "$V1_SERVICE" 2>/dev/null || true - fi + retire_v1_service fi else step_skip "Service switchover skipped" fi else step_skip "v1 service not running — nothing to switch" + retire_v1_service fi echo From 58e4df44e24bb05e051001e68cd4c6c0ed14a361 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 16:23:52 +0300 Subject: [PATCH 6/9] fix: add hint to channel multiselect in migration Co-Authored-By: Claude Opus 4.6 --- setup/migrate-v2/select-channels.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setup/migrate-v2/select-channels.ts b/setup/migrate-v2/select-channels.ts index eecf1ab..a2c8b21 100644 --- a/setup/migrate-v2/select-channels.ts +++ b/setup/migrate-v2/select-channels.ts @@ -12,6 +12,7 @@ import fs from 'fs'; import * as p from '@clack/prompts'; +import { styleText } from 'node:util'; const CHANNELS = [ { value: 'telegram', label: 'Telegram' }, @@ -47,7 +48,7 @@ async function main(): Promise { } const selected = await p.multiselect({ - message: 'Which channels do you want to set up?', + message: 'Which channels do you want to set up?\n' + styleText('dim', ' space to select, enter to confirm') + '\n', options: CHANNELS, required: false, }); From 6daa1a3ffe5d04d3cc6a9483305e8b9c4590b66a Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 17:09:31 +0300 Subject: [PATCH 7/9] fix: preserve v1 service file for rollback instead of symlinking The previous approach deleted the v1 unit file and symlinked it to v2, making rollback impossible. Now we just disable v1 and leave the file on disk so users can switch back with a single command. Also adds rollback instructions to the migration summary output. Co-Authored-By: Claude Opus 4.6 --- migrate-v2.sh | 51 ++++++++++++++++++--------------------------------- 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/migrate-v2.sh b/migrate-v2.sh index aedbf0f..dec497f 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -563,47 +563,22 @@ echo echo "$(bold 'Service switchover')" echo -# Retire the legacy v1 service file and alias it to the v2 unit. -# Called after the user confirms "keep v2", or when v1 wasn't running. +# Disable the v1 service so it doesn't auto-start, but leave the unit file +# on disk so the user can rollback with: systemctl --user start nanoclaw # Idempotent — safe to call multiple times. -retire_v1_service() { - if [ -z "$V2_SERVICE" ]; then - return - fi - +disable_v1_service() { if [ "$PLATFORM_SERVICE" = "systemd" ]; then - local unit_dir="$HOME/.config/systemd/user" - local v1_file="$unit_dir/${V1_SERVICE}.service" - local v2_file="${V2_SERVICE}.service" - - # Already a correct symlink — nothing to do - if [ -L "$v1_file" ] && [ "$(readlink "$v1_file")" = "$v2_file" ]; then - return - fi - - # Only retire if the file exists (as a regular file or stale symlink) + local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service" if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then systemctl --user stop "$V1_SERVICE" 2>/dev/null || true systemctl --user disable "$V1_SERVICE" 2>/dev/null || true - rm -f "$v1_file" - ln -s "$v2_file" "$v1_file" - systemctl --user daemon-reload 2>/dev/null || true - step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + step_ok "Disabled $V1_SERVICE (unit file kept for rollback)" fi - elif [ "$PLATFORM_SERVICE" = "launchd" ]; then local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist" - local v2_plist="${V2_SERVICE}.plist" - - if [ -L "$v1_plist" ] && [ "$(readlink "$v1_plist")" = "$v2_plist" ]; then - return - fi - if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then launchctl unload "$v1_plist" 2>/dev/null || true - rm -f "$v1_plist" - ln -s "$v2_plist" "$v1_plist" - step_ok "Aliased $V1_SERVICE → $V2_SERVICE" + step_ok "Unloaded $V1_SERVICE (plist kept for rollback)" fi fi } @@ -696,14 +671,14 @@ if [ "$V1_RUNNING" = "true" ]; then SERVICE_SWITCHED=false else step_ok "Keeping v2 service" - retire_v1_service + disable_v1_service fi else step_skip "Service switchover skipped" fi else step_skip "v1 service not running — nothing to switch" - retire_v1_service + disable_v1_service fi echo @@ -735,6 +710,16 @@ echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}" fi echo " $(green '✓') Container skills copied" echo " $(green '✓') Container image built" +if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then +echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")" +echo +echo " $(bold 'Rollback to v1:')" +if [ "$PLATFORM_SERVICE" = "systemd" ]; then +echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE" +elif [ "$PLATFORM_SERVICE" = "launchd" ]; then +echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist" +fi +fi echo echo " $(bold 'What still needs a human:')" if [ "$ONECLI_OK" = "false" ]; then From 5deccc44eaa47fedf1a1c483f1864909116101b4 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 17:26:46 +0300 Subject: [PATCH 8/9] fix: direct users to exit Claude Code for migration instead of using ! prefix Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 ++-- migrate-v2.sh | 9 ++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3104b32..c17001b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,8 @@ This is NanoClaw v2, a ground-up rewrite with breaking changes throughout. It ca **Do this instead:** 1. Abort the merge: `git merge --abort` (or `git reset --hard ORIG_HEAD` if the merge already completed). -2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Run `! bash migrate-v2.sh` to migrate instead."* -3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and must be run by the user with the `!` prefix. +2. Tell the user, verbatim: *"This is the v2 rewrite — it can't be merged into your existing install. Exit Claude Code (or open a separate terminal) and run `bash migrate-v2.sh` from the shell."* +3. Wait for the user to confirm before doing anything else. Do not run the migration script yourself — it requires an interactive terminal and cannot be run from within Claude Code. If you are a fresh install (you ran `git clone`, not `git pull`) and there are no conflicts, ignore this banner and continue below. diff --git a/migrate-v2.sh b/migrate-v2.sh index dec497f..f06a548 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -5,8 +5,7 @@ # Run from the v2 directory: # bash migrate-v2.sh # -# From Claude Code, use the ! prefix so it runs in your terminal: -# ! bash migrate-v2.sh +# If you're in Claude Code, exit first or open a separate terminal. # # Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH). # Installs prerequisites (Node, pnpm, deps) via the existing setup.sh @@ -26,10 +25,10 @@ cd "$PROJECT_ROOT" if ! [ -t 0 ] || ! [ -t 1 ]; then echo "This script requires an interactive terminal." echo "" - echo "If you're in Claude Code, run it directly with the ! prefix:" - echo " ! bash migrate-v2.sh" + echo "If you're in Claude Code, exit first or open a separate terminal," + echo "then run:" + echo " bash migrate-v2.sh" echo "" - echo "Or run it in a separate terminal session." exit 1 fi From 37d6335ebc2ead019a8e0d9675d786011bd98772 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Sun, 3 May 2026 20:12:37 +0000 Subject: [PATCH 9/9] fix(setup): clean up legacy OneCLI containers before installer runs The OneCLI installer (curl onecli.sh/install | sh) doesn't pass --remove-orphans to docker compose up. After the upstream service rename (app -> onecli), the legacy onecli-app-1 container keeps :10254 bound and crashes the new bring-up. This breaks /migrate-v2.sh on any host that has a pre-rename OneCLI installed. Workaround: before invoking the installer, remove containers in the "onecli" compose project whose service name isn't in the v2 set ({onecli, postgres}). Label-keyed and no-op on fresh installs. Filed upstream; remove this once the installer adds --remove-orphans. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/onecli.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/setup/onecli.ts b/setup/onecli.ts index fbf76a9..8f758bb 100644 --- a/setup/onecli.ts +++ b/setup/onecli.ts @@ -115,9 +115,43 @@ function installOnecliCliOnly(): { stdout: string; ok: boolean } { return { stdout: upstream.stdout + (upstream.stderr ?? '') + '\n' + fallback.stdout, ok: fallback.ok }; } +// Remove containers in the "onecli" compose project whose service name isn't +// in the v2 set. Pre-v2 OneCLI used service "app" (container onecli-app-1); +// v2 uses "onecli". Compose flags the old container as an orphan but won't +// stop it without --remove-orphans, leaving port 10254 bound and crashing +// the new bring-up. Filed upstream; this is the downstream workaround. +function removeLegacyOnecliContainers(): string { + const out: string[] = []; + let list = ''; + try { + list = execSync( + `docker ps -a --filter "label=com.docker.compose.project=onecli" --format '{{.Names}}|{{.Label "com.docker.compose.service"}}'`, + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] }, + ).trim(); + } catch { + return ''; + } + if (!list) return ''; + const v2Services = new Set(['onecli', 'postgres']); + for (const line of list.split('\n')) { + const [name, service] = line.split('|'); + if (!name || !service || v2Services.has(service)) continue; + out.push(`Removing legacy OneCLI container: ${name} (service=${service})`); + try { + execSync(`docker rm -f ${JSON.stringify(name)}`, { stdio: ['ignore', 'pipe', 'pipe'] }); + } catch (err) { + out.push(` rm failed (continuing): ${(err as Error).message}`); + } + } + return out.join('\n'); +} + function installOnecli(): { stdout: string; ok: boolean } { let stdout = ''; + const cleanup = removeLegacyOnecliContainers(); + if (cleanup) stdout += cleanup + '\n'; + // Gateway install (docker-compose based, no rate-limit concerns). const gw = runInstall('curl -fsSL onecli.sh/install | sh'); stdout += gw.stdout;