From ceb0b9cf5f30360ab83bc1b097f747149a9c38d9 Mon Sep 17 00:00:00 2001 From: Mike Nolet Date: Sat, 2 May 2026 08:45:23 +0200 Subject: [PATCH 01/32] fix(test-infra): openInboundDb honors in-memory test DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initTestSessionDb() creates an in-memory inbound singleton, but openInboundDb() always opened the hardcoded /workspace/inbound.db path. Every test that exercised getPendingMessages — directly, or via test fixtures that load data through it (e.g. poll-loop.test.ts:29 loads formatter test rows via getPendingMessages) — failed with SQLITE_CANTOPEN under `bun test` outside a real container. Baseline on main: 34 pass, 25 fail across 6 files. After this fix: 59 pass, 0 fail. In test mode, openInboundDb returns the in-memory singleton. The singleton's .close() is no-op'd in initTestSessionDb so caller try/finally cleanup doesn't tear down the shared DB; closeSessionDb invokes the saved original close to do the real teardown. Production behavior is unchanged — _inboundIsTest only flips inside initTestSessionDb, which is never called outside the test runner. Co-Authored-By: Claude Opus 4.7 (1M context) --- container/agent-runner/src/db/connection.ts | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3ca44a8..ac563fa 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -27,6 +27,13 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; +// True when initTestSessionDb() set _inbound to an in-memory DB. Used by +// openInboundDb() so tests don't try to open the missing /workspace path. +let _inboundIsTest = false; +// Saved real close() for the in-memory inbound singleton. We no-op the +// public .close() during tests so caller try/finally doesn't tear down +// the shared DB; closeSessionDb() invokes this to do the real teardown. +let _inboundOriginalClose: (() => void) | null = null; /** * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. @@ -42,6 +49,12 @@ let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; * Cost is microseconds per query, so safe for universal use. */ export function openInboundDb(): Database { + // In test mode the inbound DB is an in-memory singleton — there is no + // file at DEFAULT_INBOUND_PATH. Return the singleton directly; its + // .close() was no-op'd in initTestSessionDb so caller try/finally + // cleanup doesn't tear down the shared DB. + if (_inboundIsTest && _inbound) return _inbound; + const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); db.exec('PRAGMA busy_timeout = 5000'); db.exec('PRAGMA mmap_size = 0'); @@ -171,6 +184,12 @@ export function clearStaleProcessingAcks(): void { /** For tests — creates in-memory DBs with the session schemas. */ export function initTestSessionDb(): { inbound: Database; outbound: Database } { _inbound = new Database(':memory:'); + _inboundIsTest = true; + // No-op .close() so callers using openInboundDb()'s try/finally pattern + // don't tear down our shared singleton. closeSessionDb() does the real + // teardown via the saved original. + _inboundOriginalClose = _inbound.close.bind(_inbound); + _inbound.close = () => {}; _inbound.exec('PRAGMA foreign_keys = ON'); _inbound.exec(` CREATE TABLE messages_in ( @@ -244,8 +263,14 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { } export function closeSessionDb(): void { - _inbound?.close(); + if (_inboundOriginalClose) { + _inboundOriginalClose(); + _inboundOriginalClose = null; + } else { + _inbound?.close(); + } _inbound = null; + _inboundIsTest = false; _outbound?.close(); _outbound = null; } From e4181f5451f1ac514c94ab6d4e737bfad5cb5076 Mon Sep 17 00:00:00 2001 From: Charlie Savage Date: Sat, 2 May 2026 22:45:23 -0700 Subject: [PATCH 02/32] =?UTF-8?q?fix(host-sweep):=20regression=20in=20#218?= =?UTF-8?q?3=20=E2=80=94=20orphan-claim=20delete=20missed=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2183 added orphan-claim cleanup that reopens `outbound.db` by session path (`openOutboundDbRw(session.agent_group_id, session.id)`) so the delete runs against a writable handle even when callers pass a readonly one. That works for the production caller — there's a real on-disk session DB at the expected path. The test wrapper `_resetStuckProcessingRowsForTesting` (introduced in the same series, #2151) is called with in-memory DBs that have no on-disk path. The reopen creates a fresh empty file at `/v2-sessions/ag-test/sess-test/outbound.db`, runs the delete against that, and leaves the in-memory `outDb` (which the test reads afterward) untouched. The two `resetStuckProcessingRows — orphan claim cleanup` tests assert `getProcessingClaims(outDb).toEqual([])` after the call and fail on the row that's still there. Fix: drop the `_…ForTesting` wrapper, export `resetStuckProcessingRows` directly with an optional `writableOutDb` parameter. When omitted (production), the function reopens `outbound.db` RW by session path — existing behavior, existing safety guarantee. When provided (tests, or any future caller that already holds a writable handle), the function uses it directly and skips the reopen. The optional parameter has a real meaning, not a "for tests" hack. Public API surface change: `_resetStuckProcessingRowsForTesting` is gone, `resetStuckProcessingRows` is now exported. No other callers inside the repo besides the test. --- src/host-sweep.test.ts | 11 +++-------- src/host-sweep.ts | 40 +++++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index bd2e233..155b1b1 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -7,12 +7,7 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { deleteOrphanProcessingClaims, getProcessingClaims } from './db/session-db.js'; -import { - ABSOLUTE_CEILING_MS, - CLAIM_STUCK_MS, - _resetStuckProcessingRowsForTesting, - decideStuckAction, -} from './host-sweep.js'; +import { ABSOLUTE_CEILING_MS, CLAIM_STUCK_MS, resetStuckProcessingRows, decideStuckAction } from './host-sweep.js'; import type { Session } from './types.js'; const BASE = Date.parse('2026-04-20T12:00:00.000Z'); @@ -253,7 +248,7 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { // Sanity: the orphan claim is what would trip claim-stuck. expect(getProcessingClaims(outDb)).toHaveLength(1); - _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'absolute-ceiling'); + resetStuckProcessingRows(inDb, outDb, fakeSession(), 'absolute-ceiling', outDb); // Regression assertion: orphan claim is gone — next sweep tick will see // an empty claims list and not kill the freshly respawned container. @@ -285,7 +280,7 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { .run(claimedAt, future); outDb.prepare("INSERT INTO processing_ack VALUES ('m-2', 'processing', ?)").run(claimedAt); - _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'claim-stuck'); + resetStuckProcessingRows(inDb, outDb, fakeSession(), 'claim-stuck', outDb); expect(getProcessingClaims(outDb)).toEqual([]); const row = inDb.prepare('SELECT tries FROM messages_in WHERE id = ?').get('m-2') as { tries: number }; diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 09c82ac..b10ee0d 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -250,20 +250,28 @@ function enforceRunningContainerSla( resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck'); } -export function _resetStuckProcessingRowsForTesting( - inDb: Database.Database, - outDb: Database.Database, - session: Session, - reason: string, -): void { - resetStuckProcessingRows(inDb, outDb, session, reason); -} - -function resetStuckProcessingRows( +/** + * Reset retries on inbound rows the container claimed but never acked, and + * delete the orphan `processing_ack` rows so the next sweep tick doesn't + * see them. + * + * Safe to call only when the container that owned `outbound.db` is dead — + * production callers invoke this either in the `!alive` branch or right + * after `killContainer`. Without that guarantee, the orphan-claim delete + * would race the container's own writer. + * + * `writableOutDb` is the same handle outbound writes go through. When + * omitted (typical production path) the function reopens `outbound.db` + * read-write by session path for the delete and closes that handle on + * exit. Callers that already hold a writable handle — including tests + * using in-memory DBs — can pass it in to skip the reopen. + */ +export function resetStuckProcessingRows( inDb: Database.Database, outDb: Database.Database, session: Session, reason: string, + writableOutDb?: Database.Database, ): void { const claims = getProcessingClaims(outDb); const now = Date.now(); @@ -300,19 +308,17 @@ function resetStuckProcessingRows( // would re-read them, see the old status_changed timestamp, conclude the // freshly respawned container is stuck, and SIGKILL it before its // agent-runner has a chance to run clearStaleProcessingAcks() on startup. - // We're safe to write outbound.db here because we just killed the container - // that owned it (or it crashed and left no writer behind). - // outDb was opened readonly for reads above; reopen with write access for this delete. - let outDbRw: Database.Database | null = null; + const ownsDb = !writableOutDb; + let useDb: Database.Database | null = writableOutDb ?? null; try { - outDbRw = openOutboundDbRw(session.agent_group_id, session.id); - const cleared = deleteOrphanProcessingClaims(outDbRw); + if (!useDb) useDb = openOutboundDbRw(session.agent_group_id, session.id); + const cleared = deleteOrphanProcessingClaims(useDb); if (cleared > 0) { log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason }); } } catch (err) { log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err }); } finally { - outDbRw?.close(); + if (ownsDb) useDb?.close(); } } From f68f6da406fe7637ef4b764fe58d86bcf8cb2da8 Mon Sep 17 00:00:00 2001 From: Alex Mashkovtsev Date: Mon, 4 May 2026 16:49:53 +0800 Subject: [PATCH 03/32] fix(agent-runner): derive MCP allowedTools from registered mcpServers Claude Code 2.1.116+ treats SDK `allowedTools` as a hard whitelist: servers whose namespace isnt listed are filtered out before the agent ever sees them, regardless of `permissionMode: bypassPermissions` or any `permissions.allow` in settings. The static TOOL_ALLOWLIST only contained `mcp__nanoclaw__*`, so any MCP wired via add_mcp_server (or directly in container.json) was silently dropped. Derive `mcp____*` entries at the SDK call site from the already-aggregated `this.mcpServers` map, mirroring the SDKs own sanitization rule (chars outside [A-Za-z0-9_-] become _). Prior diagnosis by @jsboige in #2028 (withdrawn, not upstreamed). --- .../agent-runner/src/providers/claude.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index c9478b8..6c30cc2 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -34,7 +34,11 @@ const SDK_DISALLOWED_TOOLS = [ 'ExitWorktree', ]; -// Tool allowlist for NanoClaw agent containers +// Tool allowlist for NanoClaw agent containers. MCP-tool entries are derived +// at the call site from the registered `mcpServers` map so that any server +// added via `add_mcp_server` (or wired in container.json directly) is +// reachable to the agent — without this, the SDK's allowedTools filter +// silently drops every MCP namespace not listed here. const TOOL_ALLOWLIST = [ 'Bash', 'Read', @@ -54,9 +58,15 @@ const TOOL_ALLOWLIST = [ 'ToolSearch', 'Skill', 'NotebookEdit', - 'mcp__nanoclaw__*', ]; +// MCP server names are sanitized by the SDK when forming tool prefixes: +// any character outside [A-Za-z0-9_-] becomes '_'. Mirror that here so our +// allowlist patterns match what the SDK actually exposes. +function mcpAllowPattern(serverName: string): string { + return `mcp__${serverName.replace(/[^a-zA-Z0-9_-]/g, '_')}__*`; +} + interface SDKUserMessage { type: 'user'; message: { role: 'user'; content: string }; @@ -277,7 +287,10 @@ export class ClaudeProvider implements AgentProvider { resume: input.continuation, pathToClaudeCodeExecutable: '/pnpm/claude', systemPrompt: instructions ? { type: 'preset' as const, preset: 'claude_code' as const, append: instructions } : undefined, - allowedTools: TOOL_ALLOWLIST, + allowedTools: [ + ...TOOL_ALLOWLIST, + ...Object.keys(this.mcpServers).map(mcpAllowPattern), + ], disallowedTools: SDK_DISALLOWED_TOOLS, env: this.env, permissionMode: 'bypassPermissions', From b33f6654fdece99c5b11e06fc160917f1ac6fb61 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 09:23:43 +0000 Subject: [PATCH 04/32] fix(setup): use fmtDuration in the container-build spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup/lib/windowed-runner.ts was the one place on main still printing elapsed time as raw seconds (`(170s)`) instead of using the minute-aware `fmtDuration` helper from #2108. Two spots — the live spinner suffix that ticks during the build, and the success/error completion suffix — both now go through `fmtDuration`, so anything past 60 seconds renders as `Xm Ys` (e.g. `2m 50s`) like the rest of the setup flow. The miss happened because a separate PR (closed) was supposed to remove the timer entirely from this file, so #2108 deliberately skipped it. With that other PR closed, applying `fmtDuration` here is the consistent fix. Pure formatting change. The helper itself is unchanged from #2108; behavior under 60s is identical (`Xs`); behavior past 60s now matches everywhere else. --- setup/lib/windowed-runner.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/setup/lib/windowed-runner.ts b/setup/lib/windowed-runner.ts index 6f165a4..87c971e 100644 --- a/setup/lib/windowed-runner.ts +++ b/setup/lib/windowed-runner.ts @@ -23,7 +23,7 @@ import { emit as phEmit } from './diagnostics.js'; import type { StepResult, SpinnerLabels } from './runner.js'; import { dumpTranscriptOnFailure, spawnStep, writeStepEntry } from './runner.js'; import * as setupLog from '../logs.js'; -import { brandBody, fitToWidth } from './theme.js'; +import { brandBody, fitToWidth, fmtDuration } from './theme.js'; const WINDOW_SIZE = 3; const SPINNER_FRAMES = ['◒', '◐', '◓', '◑']; @@ -85,9 +85,8 @@ async function runUnderWindow( const redraw = (): void => { if (stallPromptActive) return; out.write(`\x1b[${WINDOW_SIZE + 1}A`); - const elapsed = Math.round((Date.now() - start) / 1000); const icon = SPINNER_FRAMES[frameIdx % SPINNER_FRAMES.length]; - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; const header = fitToWidth(labels.running, suffix); out.write(`\x1b[2K${k.cyan(icon)} ${header}${k.dim(suffix)}\n`); @@ -164,8 +163,7 @@ async function runUnderWindow( out.write(SHOW_CURSOR); process.off('exit', restoreCursorOnExit); - const elapsed = Math.round((Date.now() - start) / 1000); - const suffix = ` (${elapsed}s)`; + const suffix = ` (${fmtDuration(Date.now() - start)})`; if (result.ok) { const isSkipped = result.terminal?.fields.STATUS === 'skipped'; const msg = isSkipped && labels.skipped ? labels.skipped : labels.done; From 9e4feb08001c8901aa41ddf2ce1ec7e0b47331df Mon Sep 17 00:00:00 2001 From: koshkoshinsk Date: Mon, 4 May 2026 12:54:54 +0000 Subject: [PATCH 05/32] feat(setup): warn when host is below recommended hardware specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-flight check in nanoclaw.sh that detects available RAM and free disk on the project-root partition (Linux + macOS) before the bootstrap spinner runs. Below 3700 MB RAM or 20 GB free disk, surfaces a "likely cannot run" warning with a Try-anyway prompt defaulting to abort. The 3700 MB floor sits below 4 GB because "4 GB" VMs typically report 3700–3900 MB after kernel reserves (Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). Cheaper to fail here than to wait through pnpm install on a host that can't run the agent container. Diagnostic events fire on continue/abort. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index 82d445a..c2b2614 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -137,6 +137,69 @@ write_header # NANOCLAW_BOOTSTRAPPED=1 and skips re-printing the wordmark. cat "$PROJECT_ROOT/assets/setup-splash.txt" +# ─── pre-flight: minimum hardware specs ──────────────────────────────── +# NanoClaw runs an agent container per session. Below these thresholds the +# host + container + agent will struggle (OOM under load, image + session +# DBs filling the disk). Soft warn — `df` only sees the partition that +# $PROJECT_ROOT lives on, which can underreport on hosts with separate +# /home or /var mounts, so the user can override. + +# RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB +# after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). +MIN_MEM_MB=3700 +MIN_DISK_GB=20 + +detect_mem_mb() { + case "$(uname -s)" in + Linux) + awk '/^MemTotal:/ {printf "%d", $2 / 1024}' /proc/meminfo 2>/dev/null + ;; + Darwin) + local bytes + bytes=$(sysctl -n hw.memsize 2>/dev/null || echo 0) + echo $(( bytes / 1024 / 1024 )) + ;; + esac +} + +detect_disk_gb() { + # -P: POSIX format (no line-wrapping); -k: 1024-byte blocks. Avail is col 4. + df -Pk "$PROJECT_ROOT" 2>/dev/null \ + | awk 'NR==2 { printf "%d", $4 / 1024 / 1024 }' +} + +MEM_MB=$(detect_mem_mb) +DISK_GB=$(detect_disk_gb) +: "${MEM_MB:=0}" +: "${DISK_GB:=0}" + +LOW_MEM=false; LOW_DISK=false +[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true +[ "$DISK_GB" -gt 0 ] && [ "$DISK_GB" -lt "$MIN_DISK_GB" ] && LOW_DISK=true + +if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then + printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')" + printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ machine with 20 GB+ free disk. Below this,')" + printf ' %s\n' "$(dim 'the host + agent container will run out of memory or disk under most')" + printf ' %s\n' "$(dim 'workloads. A stronger machine is strongly recommended.')" + [ "$LOW_MEM" = true ] && printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")" + [ "$LOW_DISK" = true ] && printf ' %s\n' "$(dim " · Free disk on $PROJECT_ROOT: ${DISK_GB} GB")" + printf '\n' + read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS Date: Sat, 2 May 2026 07:48:41 -0700 Subject: [PATCH 06/32] Add namespacedPlatformId exclusion for DeltaChat (cherry picked from commit 5987fdc189f60b5afa76fd08c3d01ccc8a7a3a43) --- src/platform-id.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/platform-id.ts b/src/platform-id.ts index 1c49325..dfd5568 100644 --- a/src/platform-id.ts +++ b/src/platform-id.ts @@ -9,15 +9,17 @@ * will later emit as event.platformId, or router lookups miss and messages * get silently dropped. * - * Native adapters (Signal, WhatsApp, iMessage) use their own ID formats and - * send them as-is — no channel prefix. WhatsApp/iMessage emit JIDs/emails - * containing '@'. Signal emits raw phone numbers ('+15551234567') for DMs - * and 'group:' for group chats. Prefixing any of these would cause a - * mismatch with what the adapter later emits. + * Native adapters (Signal, WhatsApp, iMessage, DeltaChat) use their own ID + * formats and send them as-is — no channel prefix. WhatsApp/iMessage emit + * JIDs/emails containing '@'. Signal emits raw phone numbers ('+15551234567') + * for DMs and 'group:' for group chats. DeltaChat emits numeric chat IDs + * ('12'). Prefixing any of these would cause a mismatch with what the adapter + * later emits. */ export function namespacedPlatformId(channel: string, raw: string): string { if (raw.startsWith(`${channel}:`)) return raw; if (raw.includes('@')) return raw; if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + if (channel === 'deltachat') return raw; return `${channel}:${raw}`; } From 251b31cd7847bf4c1faf610b1f1c954d64931852 Mon Sep 17 00:00:00 2001 From: koshkoshinsk Date: Mon, 4 May 2026 14:27:07 +0000 Subject: [PATCH 07/32] feat(setup): warn when running on a Google Compute Engine VM NanoClaw is known to not run reliably on GCE instances. Detect via DMI during pre-flight (between the spec check and root warning) and let the user abort before sinking time into bootstrap. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/nanoclaw.sh b/nanoclaw.sh index c2b2614..c17966e 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -200,6 +200,33 @@ if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then esac fi +# ─── pre-flight: Google Cloud VM warning (Linux) ────────────────────── +# NanoClaw is known to not run reliably on Google Compute Engine instances. +# Warn early — before the root check or bootstrap spinner — so users can +# switch providers before sinking time into setup. Detection uses DMI +# (no network round-trip), which on GCE reports "Google" / "Google +# Compute Engine". +if [ "$(uname -s)" = "Linux" ] \ + && { grep -qi 'Google' /sys/class/dmi/id/product_name 2>/dev/null \ + || grep -qi 'Google' /sys/class/dmi/id/sys_vendor 2>/dev/null; }; then + printf ' %s\n' "$(red 'Warning: Google Cloud VM detected.')" + printf ' %s\n' "$(dim 'Google blocks sudo commands, so NanoClaw is unlikely to run successfully on this VM.')" + printf ' %s\n\n' "$(dim 'If you want to run NanoClaw successfully, switch to a different provider (Hetzner, Hostinger, exe.dev and others..).')" + read -r -p " $(bold 'Try anyway?') [y/N] " GCE_ANS Date: Mon, 4 May 2026 15:31:09 +0000 Subject: [PATCH 08/32] chore: bump version to 2.0.29 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f305bec..032c7f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.28", + "version": "2.0.29", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 1404f7feb632fca83dcd0cfe81a09d5be7763dc1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 4 May 2026 15:32:34 +0000 Subject: [PATCH 09/32] chore: bump version to 2.0.30 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 032c7f8..f92ed88 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.29", + "version": "2.0.30", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 30a898508af2ccef8aa8eee2b3ea07a4d01a9e77 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 4 May 2026 21:58:57 +0000 Subject: [PATCH 10/32] fix(migrate): drop WhatsApp LID dual-row migration step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove step 2d (whatsapp-resolve-lids.ts) which pre-created duplicate messaging_groups rows keyed by @lid alongside the phone-keyed rows. This caused split sessions — the same contact got separate sessions depending on which JID format arrived. With the Baileys v7 upgrade (PR #2259 on channels), the adapter resolves every LID to a phone JID via extractAddressingContext before the message reaches the router, making dual rows unnecessary. Co-Authored-By: Claude Opus 4.6 (1M context) --- migrate-v2.sh | 20 +-- setup/migrate-v2/whatsapp-resolve-lids.ts | 192 ---------------------- 2 files changed, 6 insertions(+), 206 deletions(-) delete mode 100644 setup/migrate-v2/whatsapp-resolve-lids.ts diff --git a/migrate-v2.sh b/migrate-v2.sh index f06a548..2325edd 100644 --- a/migrate-v2.sh +++ b/migrate-v2.sh @@ -408,20 +408,12 @@ else fi done - # 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys - # is on disk) and auth files have been copied (so we can connect with - # the migrated identity), boot Baileys briefly to learn LID↔phone - # mappings during initial sync, then write paired LID-keyed - # messaging_groups. Best-effort: any failure degrades to runtime - # approval flow, which the WA adapter's isMention=true on DMs handles. - for ch in "${SELECTED_CHANNELS[@]}"; do - if [ "$ch" = "whatsapp" ]; then - run_step "2d-whatsapp-lids" \ - "Resolve WhatsApp LIDs for migrated DMs" \ - "setup/migrate-v2/whatsapp-resolve-lids.ts" - break - fi - done + # 2d. (Removed) WhatsApp LID resolution was previously needed because the + # v6 adapter couldn't reliably translate LID→phone JIDs, so the migration + # pre-created dual messaging_groups rows. With Baileys v7, the adapter + # resolves LIDs via extractAddressingContext + signalRepository.lidMapping + # on every inbound message, so dual rows are unnecessary and were causing + # split sessions. fi echo diff --git a/setup/migrate-v2/whatsapp-resolve-lids.ts b/setup/migrate-v2/whatsapp-resolve-lids.ts deleted file mode 100644 index 7a5eb8b..0000000 --- a/setup/migrate-v2/whatsapp-resolve-lids.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * migrate-v2 step: resolve WhatsApp LIDs for migrated DM messaging_groups. - * - * Why this exists - * ─────────────── - * v1 stored every WhatsApp DM as `@s.whatsapp.net`. v2's WA adapter - * sometimes resolves the chat to `@lid` instead — when WhatsApp - * delivers a message via the LID protocol and Baileys hasn't yet learned - * a LID→phone mapping for that contact (cold cache after migration). The - * router then can't find the phone-keyed messaging_group and silently - * drops the message at router.ts:184 — until the LID is learned (which - * happens lazily, message-by-message, via `chats.phoneNumberShare`). - * - * Baileys persists LID↔phone mappings to disk as - * `store/auth/lid-mapping-_reverse.json` (LID → phone) and - * `lid-mapping-.json` (phone → LID). v1 will already have populated - * these for every contact it talked to. This step parses the reverse - * files and writes paired LID-keyed `messaging_groups` + - * `messaging_group_agents` rows so both `@s.whatsapp.net` and - * `@lid` route to the same agent_group with the same engage rules. - * - * No Baileys boot, no network — pure filesystem read. If store/auth is - * missing or has no reverse mappings, exits 0 with a SKIPPED. Runtime - * fallback (WA adapter sets isMention=true on DMs → router auto-creates - * with `unknown_sender_policy=request_approval`) handles anything we - * miss. - * - * Usage: pnpm exec tsx setup/migrate-v2/whatsapp-resolve-lids.ts - */ -import fs from 'fs'; -import path from 'path'; - -import { DATA_DIR } from '../../src/config.js'; -import { initDb } from '../../src/db/connection.js'; -import { - createMessagingGroup, - createMessagingGroupAgent, - getMessagingGroupAgentByPair, - getMessagingGroupByPlatform, -} from '../../src/db/messaging-groups.js'; -import { runMigrations } from '../../src/db/migrations/index.js'; -import { generateId } from './shared.js'; - -interface RawMessagingGroup { - id: string; - channel_type: string; - platform_id: string; -} - -interface RawWiring { - id: string; - messaging_group_id: string; - agent_group_id: string; - engage_mode: string; - engage_pattern: string | null; - sender_scope: string; - ignored_message_policy: string; - session_mode: string; - priority: number; -} - -const REVERSE_FILE_RE = /^lid-mapping-(\d+)_reverse\.json$/; - -/** - * Read store/auth/lid-mapping-*_reverse.json into a Map. - * Returns an empty Map if the directory doesn't exist. - */ -function readReverseMappings(authDir: string): Map { - const out = new Map(); - if (!fs.existsSync(authDir)) return out; - for (const entry of fs.readdirSync(authDir)) { - const m = REVERSE_FILE_RE.exec(entry); - if (!m) continue; - const lidUser = m[1]; - try { - const raw = fs.readFileSync(path.join(authDir, entry), 'utf-8').trim(); - // The file content is a JSON-encoded string: `""` - const phoneUser = JSON.parse(raw); - if (typeof phoneUser !== 'string' || phoneUser.length === 0) continue; - out.set(lidUser, phoneUser); - } catch { - // Skip malformed entries — best-effort. - } - } - return out; -} - -function phoneUserOf(jid: string): string { - return jid.split('@')[0].split(':')[0]; -} - -function main(): void { - const authDir = path.join(process.cwd(), 'store', 'auth'); - const reverse = readReverseMappings(authDir); - - if (reverse.size === 0) { - console.log('SKIPPED:no lid-mapping-*_reverse.json files in store/auth'); - process.exit(0); - } - - // phoneUser → lidJid (the form we'll write to messaging_groups) - const phoneUserToLidJid = new Map(); - for (const [lidUser, phoneUser] of reverse) { - phoneUserToLidJid.set(phoneUser, `${lidUser}@lid`); - } - - const v2DbPath = path.join(DATA_DIR, 'v2.db'); - if (!fs.existsSync(v2DbPath)) { - console.error('FAIL:v2.db not found — run db step first'); - process.exit(1); - } - - const v2Db = initDb(v2DbPath); - runMigrations(v2Db); - - const phoneRows = v2Db - .prepare( - `SELECT id, channel_type, platform_id FROM messaging_groups - WHERE channel_type='whatsapp' AND platform_id LIKE '%@s.whatsapp.net'`, - ) - .all() as RawMessagingGroup[]; - - if (phoneRows.length === 0) { - console.log('SKIPPED:no whatsapp DM messaging_groups to resolve'); - v2Db.close(); - process.exit(0); - } - - // Pull existing wirings so each new alias gets the same agent_group + - // engage rules as the phone-keyed row. - const placeholders = phoneRows.map(() => '?').join(','); - const wiringRows = v2Db - .prepare(`SELECT * FROM messaging_group_agents WHERE messaging_group_id IN (${placeholders})`) - .all(...phoneRows.map((r) => r.id)) as RawWiring[]; - - const wiringsByMg = new Map(); - for (const w of wiringRows) { - const arr = wiringsByMg.get(w.messaging_group_id) ?? []; - arr.push(w); - wiringsByMg.set(w.messaging_group_id, arr); - } - - let resolved = 0; - let aliased = 0; - const createdAt = new Date().toISOString(); - - for (const row of phoneRows) { - const phoneUser = phoneUserOf(row.platform_id); - const lidJid = phoneUserToLidJid.get(phoneUser); - if (!lidJid) continue; - resolved++; - - let lidMg = getMessagingGroupByPlatform('whatsapp', lidJid); - if (!lidMg) { - createMessagingGroup({ - id: generateId('mg'), - channel_type: 'whatsapp', - platform_id: lidJid, - name: null, - is_group: 0, - unknown_sender_policy: 'public', - created_at: createdAt, - }); - lidMg = getMessagingGroupByPlatform('whatsapp', lidJid)!; - } - - const wirings = wiringsByMg.get(row.id) ?? []; - for (const w of wirings) { - if (getMessagingGroupAgentByPair(lidMg.id, w.agent_group_id)) continue; - createMessagingGroupAgent({ - id: generateId('mga'), - messaging_group_id: lidMg.id, - agent_group_id: w.agent_group_id, - engage_mode: w.engage_mode as 'pattern' | 'mention' | 'mention-sticky', - engage_pattern: w.engage_pattern, - sender_scope: w.sender_scope as 'all' | 'admins', - ignored_message_policy: w.ignored_message_policy as 'drop' | 'queue', - session_mode: w.session_mode as 'shared' | 'thread', - priority: w.priority, - created_at: createdAt, - }); - aliased++; - } - } - - v2Db.close(); - console.log( - `OK:reverse_mappings=${reverse.size},phone_dms=${phoneRows.length},lids_resolved=${resolved},aliased=${aliased}`, - ); -} - -main(); From 32dba601fe68855e657e72282a1b5f9dec1fb0cc Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:24:37 +0200 Subject: [PATCH 11/32] fix(channels): support display cards (send_card) in Chat SDK bridge The send_card MCP tool wrote outbound rows with type='card' but the chat-sdk-bridge deliver() had no branch for them, so the payload fell through to the text fallback (where text is undefined) and silently returned without calling the adapter. delivery.ts then marked the message delivered with platformMsgId=undefined and the user saw nothing. Add a dedicated card branch mirroring the ask_question structure: - Build Card from title, description, and string-or-{text} children - Render only URL actions as LinkButtons (send_card is fire-and-forget per its docstring, so callback buttons would have nowhere to land) - Drop empty cards with a warn log instead of posting blank - Fall back text: content.fallbackText > description > title Affects every Chat SDK adapter that goes through the bridge: Discord, Telegram, Slack, Teams, GChat, GitHub, Linear, WhatsApp Cloud, iMessage, Matrix, Webex, Resend. Tests: adds five cases covering normal render, action filtering, link-button rendering, empty-card skip, and a regression check that non-card chat-sdk payloads still flow through the text branch. Closes #2263 --- src/channels/chat-sdk-bridge.test.ts | 132 ++++++++++++++++++++++++++- src/channels/chat-sdk-bridge.ts | 63 +++++++++++++ 2 files changed, 194 insertions(+), 1 deletion(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 7e3c4ff..3697233 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { Adapter } from 'chat'; +import type { Adapter, AdapterPostableMessage, RawMessage } from 'chat'; import { createChatSdkBridge, splitForLimit } from './chat-sdk-bridge.js'; @@ -8,6 +8,23 @@ function stubAdapter(partial: Partial): Adapter { return { name: 'stub', ...partial } as unknown as Adapter; } +interface PostCall { + threadId: string; + message: AdapterPostableMessage; +} + +function makePostCapture() { + const calls: PostCall[] = []; + const postMessage = async ( + threadId: string, + message: AdapterPostableMessage, + ): Promise> => { + calls.push({ threadId, message }); + return { id: 'msg-stub', threadId, raw: {} }; + }; + return { calls, postMessage }; +} + describe('splitForLimit', () => { it('returns a single chunk when text fits', () => { expect(splitForLimit('short text', 100)).toEqual(['short text']); @@ -78,3 +95,116 @@ describe('createChatSdkBridge', () => { expect(typeof bridge.subscribe).toBe('function'); }); }); + +describe('createChatSdkBridge.deliver — display cards (send_card)', () => { + // The send_card MCP tool writes outbound rows with `{ type: 'card', card, fallbackText }`. + // Before this branch existed the bridge silently dropped them: cards have no + // `text` / `markdown`, so the trailing fallback `if (text)` was false and the + // function returned without calling the adapter. These tests pin the contract + // for the dedicated card branch. + + it('renders title, description, and string children, then posts via the adapter', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + const id = await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Daily', + description: 'Your plate today', + children: ['• item one', '• item two'], + }, + fallbackText: 'Daily: your plate', + }, + }); + expect(id).toBe('msg-stub'); + expect(calls).toHaveLength(1); + const msg = calls[0].message as { card?: unknown; fallbackText?: string }; + expect(msg.fallbackText).toBe('Daily: your plate'); + expect(msg.card).toBeDefined(); + }); + + it('drops actions without url (send_card is fire-and-forget; non-URL buttons would have nowhere to land)', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('discord:guild:chan', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Card', + description: 'has only label-only actions', + actions: [{ label: 'Add' }, { label: 'Skip' }], + }, + }, + }); + expect(calls).toHaveLength(1); + // Cast through the public Card shape to read the children we set + const msg = calls[0].message as { card?: { children?: Array<{ type?: string }> } }; + const childTypes = (msg.card?.children ?? []).map((c) => c.type); + expect(childTypes).not.toContain('actions'); + }); + + it('renders url actions as link buttons inside an Actions row', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('discord:guild:chan', null, { + kind: 'chat-sdk', + content: { + type: 'card', + card: { + title: 'Docs', + actions: [{ label: 'Open', url: 'https://example.com' }, { label: 'No-link' }], + }, + }, + }); + const msg = calls[0].message as { + card?: { children?: Array<{ type?: string; children?: Array<{ type?: string; url?: string }> }> }; + }; + const actionsRow = msg.card?.children?.find((c) => c.type === 'actions'); + expect(actionsRow).toBeDefined(); + const buttons = actionsRow?.children ?? []; + expect(buttons).toHaveLength(1); + expect(buttons[0].type).toBe('link-button'); + expect(buttons[0].url).toBe('https://example.com'); + }); + + it('skips delivery when the card has neither title nor body content', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + const id = await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { type: 'card', card: {} }, + }); + expect(id).toBeUndefined(); + expect(calls).toHaveLength(0); + }); + + it('falls through to the text branch for non-card chat-sdk payloads (no regression)', async () => { + const { calls, postMessage } = makePostCapture(); + const bridge = createChatSdkBridge({ + adapter: stubAdapter({ postMessage }), + supportsThreads: false, + }); + await bridge.deliver('telegram:42', null, { + kind: 'chat-sdk', + content: { text: 'plain hello' }, + }); + expect(calls).toHaveLength(1); + const msg = calls[0].message as { markdown?: string }; + expect(msg.markdown).toBe('plain hello'); + }); +}); diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 52c92ba..a28d82e 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -12,6 +12,8 @@ import { CardText, Actions, Button, + LinkButton, + type CardChild, type Adapter, type ConcurrencyStrategy, type Message as ChatMessage, @@ -399,6 +401,67 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter return result?.id; } + // Display card (send_card MCP tool) — returns immediately, no callback flow. + // Non-URL actions are dropped: send_card's contract is fire-and-forget, so a + // callback button would have nowhere to land. URL actions render as link buttons. + if (content.type === 'card' && content.card && typeof content.card === 'object') { + const cardSpec = content.card as Record; + const title = (cardSpec.title as string) || ''; + const fallbackText = + (content.fallbackText as string) || + (cardSpec.description as string) || + title || + ''; + + const cardChildren: CardChild[] = []; + if (typeof cardSpec.description === 'string' && cardSpec.description) { + cardChildren.push(CardText(cardSpec.description)); + } + if (Array.isArray(cardSpec.children)) { + for (const child of cardSpec.children) { + if (typeof child === 'string' && child) { + cardChildren.push(CardText(child)); + } else if ( + child && + typeof child === 'object' && + typeof (child as Record).text === 'string' + ) { + cardChildren.push(CardText((child as Record).text)); + } + } + } + if (Array.isArray(cardSpec.actions)) { + const linkButtons = (cardSpec.actions as Array>) + .filter( + (a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label, + ) + .map((a) => { + const style = a.style; + const safeStyle: 'primary' | 'danger' | 'default' | undefined = + style === 'primary' || style === 'danger' || style === 'default' + ? style + : undefined; + return LinkButton({ + label: a.label as string, + url: a.url as string, + style: safeStyle, + }); + }); + if (linkButtons.length > 0) { + cardChildren.push(Actions(linkButtons)); + } + } + + if (cardChildren.length === 0 && !title) { + log.warn('send_card payload empty, skipping delivery'); + return; + } + + const card = Card({ title, children: cardChildren }); + const result = await adapter.postMessage(tid, { card, fallbackText }); + return result?.id; + } + // Normal message const rawText = (content.markdown as string) || (content.text as string); const text = rawText ? transformText(rawText) : rawText; From 9633788a1b5fefe1737ed6cbbf826b1b6e28983e Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:28:25 +0200 Subject: [PATCH 12/32] fix(skills): bump @chat-adapter/* cohort to 4.27.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @chat-adapter/discord@4.27.0 includes vercel/chat#256, which fixes the Discord adapter unconditionally setting payload.content alongside payload.embeds when posting a card. In 4.26.0 every Discord card appeared twice (text content above the embed, identical content inside the embed) — every new install reproduced this on the welcome tour and on every approval card. The other 7 skills bump in lockstep because @chat-adapter/discord@4.27.0 depends on chat@4.27.0 while @chat-adapter/@4.26.0 depend on chat@4.26.0. Mixing the cohort produces a TypeScript dual-version conflict between the bridge and adapter ChatInstance types. Files updated (one line per file in each pnpm install command): - add-discord (the user-visible bug fix) - add-gchat, add-github, add-linear, add-slack, add-teams, add-telegram, add-whatsapp-cloud (cohort consistency) Out of scope: add-imessage, add-matrix, add-webex, add-resend use third-party packages with independent versioning. Closes #2264 --- .claude/skills/add-discord/SKILL.md | 2 +- .claude/skills/add-gchat/SKILL.md | 2 +- .claude/skills/add-github/SKILL.md | 2 +- .claude/skills/add-linear/SKILL.md | 2 +- .claude/skills/add-slack/SKILL.md | 2 +- .claude/skills/add-teams/SKILL.md | 2 +- .claude/skills/add-telegram/SKILL.md | 2 +- .claude/skills/add-whatsapp-cloud/SKILL.md | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.claude/skills/add-discord/SKILL.md b/.claude/skills/add-discord/SKILL.md index 6d3ccc8..f22c0c7 100644 --- a/.claude/skills/add-discord/SKILL.md +++ b/.claude/skills/add-discord/SKILL.md @@ -44,7 +44,7 @@ import './discord.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/discord@4.26.0 +pnpm install @chat-adapter/discord@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-gchat/SKILL.md b/.claude/skills/add-gchat/SKILL.md index c4d8dfd..b3b7d1b 100644 --- a/.claude/skills/add-gchat/SKILL.md +++ b/.claude/skills/add-gchat/SKILL.md @@ -44,7 +44,7 @@ import './gchat.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/gchat@4.26.0 +pnpm install @chat-adapter/gchat@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md index 78366f3..2441f13 100644 --- a/.claude/skills/add-github/SKILL.md +++ b/.claude/skills/add-github/SKILL.md @@ -48,7 +48,7 @@ import './github.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/github@4.26.0 +pnpm install @chat-adapter/github@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md index dc657af..237aaa0 100644 --- a/.claude/skills/add-linear/SKILL.md +++ b/.claude/skills/add-linear/SKILL.md @@ -87,7 +87,7 @@ Linear OAuth apps can't be @-mentioned, so the bridge's `onNewMention` handler n ### 5. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/linear@4.26.0 +pnpm install @chat-adapter/linear@4.27.0 ``` ### 6. Build diff --git a/.claude/skills/add-slack/SKILL.md b/.claude/skills/add-slack/SKILL.md index d09db61..0b67b50 100644 --- a/.claude/skills/add-slack/SKILL.md +++ b/.claude/skills/add-slack/SKILL.md @@ -44,7 +44,7 @@ import './slack.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/slack@4.26.0 +pnpm install @chat-adapter/slack@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-teams/SKILL.md b/.claude/skills/add-teams/SKILL.md index 10bce29..f6eeaf9 100644 --- a/.claude/skills/add-teams/SKILL.md +++ b/.claude/skills/add-teams/SKILL.md @@ -44,7 +44,7 @@ import './teams.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/teams@4.26.0 +pnpm install @chat-adapter/teams@4.27.0 ``` ### 5. Build diff --git a/.claude/skills/add-telegram/SKILL.md b/.claude/skills/add-telegram/SKILL.md index f605b41..03247c5 100644 --- a/.claude/skills/add-telegram/SKILL.md +++ b/.claude/skills/add-telegram/SKILL.md @@ -58,7 +58,7 @@ In `setup/index.ts`, add this entry to the `STEPS` map (right after the `registe ### 5. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/telegram@4.26.0 +pnpm install @chat-adapter/telegram@4.27.0 ``` ### 6. Build diff --git a/.claude/skills/add-whatsapp-cloud/SKILL.md b/.claude/skills/add-whatsapp-cloud/SKILL.md index d08f375..7e8bd1c 100644 --- a/.claude/skills/add-whatsapp-cloud/SKILL.md +++ b/.claude/skills/add-whatsapp-cloud/SKILL.md @@ -44,7 +44,7 @@ import './whatsapp-cloud.js'; ### 4. Install the adapter package (pinned) ```bash -pnpm install @chat-adapter/whatsapp@4.26.0 +pnpm install @chat-adapter/whatsapp@4.27.0 ``` ### 5. Build From a57bb8fec032d27a80d0f5488415dd30b5c0fff4 Mon Sep 17 00:00:00 2001 From: glifocat Date: Tue, 5 May 2026 00:42:04 +0200 Subject: [PATCH 13/32] style: apply prettier to chat-sdk-bridge card branch --- src/channels/chat-sdk-bridge.test.ts | 5 +---- src/channels/chat-sdk-bridge.ts | 14 +++----------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 3697233..3049c29 100644 --- a/src/channels/chat-sdk-bridge.test.ts +++ b/src/channels/chat-sdk-bridge.test.ts @@ -15,10 +15,7 @@ interface PostCall { function makePostCapture() { const calls: PostCall[] = []; - const postMessage = async ( - threadId: string, - message: AdapterPostableMessage, - ): Promise> => { + const postMessage = async (threadId: string, message: AdapterPostableMessage): Promise> => { calls.push({ threadId, message }); return { id: 'msg-stub', threadId, raw: {} }; }; diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index a28d82e..f403dfa 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -407,11 +407,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter if (content.type === 'card' && content.card && typeof content.card === 'object') { const cardSpec = content.card as Record; const title = (cardSpec.title as string) || ''; - const fallbackText = - (content.fallbackText as string) || - (cardSpec.description as string) || - title || - ''; + const fallbackText = (content.fallbackText as string) || (cardSpec.description as string) || title || ''; const cardChildren: CardChild[] = []; if (typeof cardSpec.description === 'string' && cardSpec.description) { @@ -432,15 +428,11 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter } if (Array.isArray(cardSpec.actions)) { const linkButtons = (cardSpec.actions as Array>) - .filter( - (a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label, - ) + .filter((a) => typeof a.url === 'string' && a.url && typeof a.label === 'string' && a.label) .map((a) => { const style = a.style; const safeStyle: 'primary' | 'danger' | 'default' | undefined = - style === 'primary' || style === 'danger' || style === 'default' - ? style - : undefined; + style === 'primary' || style === 'danger' || style === 'default' ? style : undefined; return LinkButton({ label: a.label as string, url: a.url as string, From e753d09e64fe668bc6caf118794237192b75daae Mon Sep 17 00:00:00 2001 From: koshkoshinsk Date: Tue, 5 May 2026 07:01:04 +0000 Subject: [PATCH 14/32] setup: drop disk-space pre-flight check, keep RAM only The disk threshold was unreliable on hosts with separate /home or /var mounts where df underreports free space. Simplify the pre-flight to a RAM-only check. Co-Authored-By: Claude Opus 4.7 (1M context) --- nanoclaw.sh | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/nanoclaw.sh b/nanoclaw.sh index c17966e..bcf4e49 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -138,16 +138,13 @@ write_header cat "$PROJECT_ROOT/assets/setup-splash.txt" # ─── pre-flight: minimum hardware specs ──────────────────────────────── -# NanoClaw runs an agent container per session. Below these thresholds the -# host + container + agent will struggle (OOM under load, image + session -# DBs filling the disk). Soft warn — `df` only sees the partition that -# $PROJECT_ROOT lives on, which can underreport on hosts with separate -# /home or /var mounts, so the user can override. +# NanoClaw runs an agent container per session. Below this threshold the +# host + container + agent will struggle (OOM under load). Soft warn — the +# user can override. # RAM floor is set below 4 GB because "4 GB" VMs typically report 3700–3900 MB # after kernel reserves (e.g. Hetzner CX21 ≈ 3814, AWS t3.medium ≈ 3800). MIN_MEM_MB=3700 -MIN_DISK_GB=20 detect_mem_mb() { case "$(uname -s)" in @@ -162,39 +159,29 @@ detect_mem_mb() { esac } -detect_disk_gb() { - # -P: POSIX format (no line-wrapping); -k: 1024-byte blocks. Avail is col 4. - df -Pk "$PROJECT_ROOT" 2>/dev/null \ - | awk 'NR==2 { printf "%d", $4 / 1024 / 1024 }' -} - MEM_MB=$(detect_mem_mb) -DISK_GB=$(detect_disk_gb) : "${MEM_MB:=0}" -: "${DISK_GB:=0}" -LOW_MEM=false; LOW_DISK=false -[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true -[ "$DISK_GB" -gt 0 ] && [ "$DISK_GB" -lt "$MIN_DISK_GB" ] && LOW_DISK=true +LOW_MEM=false +[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true -if [ "$LOW_MEM" = true ] || [ "$LOW_DISK" = true ]; then +if [ "$LOW_MEM" = true ]; then printf ' %s\n' "$(red 'Warning: this machine likely cannot run NanoClaw.')" - printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ machine with 20 GB+ free disk. Below this,')" - printf ' %s\n' "$(dim 'the host + agent container will run out of memory or disk under most')" - printf ' %s\n' "$(dim 'workloads. A stronger machine is strongly recommended.')" - [ "$LOW_MEM" = true ] && printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")" - [ "$LOW_DISK" = true ] && printf ' %s\n' "$(dim " · Free disk on $PROJECT_ROOT: ${DISK_GB} GB")" + printf ' %s\n' "$(dim 'NanoClaw recommends a 4 GB+ RAM machine. Below this, the host + agent')" + printf ' %s\n' "$(dim 'container will run out of memory under most workloads. A stronger')" + printf ' %s\n' "$(dim 'machine is strongly recommended.')" + printf ' %s\n' "$(dim " · Detected RAM: ${MEM_MB} MB")" printf '\n' read -r -p " $(bold 'Try anyway?') [y/N] " SPECS_ANS Date: Tue, 5 May 2026 07:11:26 +0000 Subject: [PATCH 15/32] improve node install to use uvx --- setup/install-node.sh | 52 ++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/setup/install-node.sh b/setup/install-node.sh index e100ccd..4ecb1c5 100755 --- a/setup/install-node.sh +++ b/setup/install-node.sh @@ -17,30 +17,40 @@ if command -v node >/dev/null 2>&1; then exit 0 fi -case "$(uname -s)" in - Darwin) - echo "STEP: brew-install-node" - if ! command -v brew >/dev/null 2>&1; then +if command -v uvx >/dev/null 2>&1; then + echo "STEP: uvx-nodeenv" + uvx nodeenv -n lts ~/node + mkdir -p ~/.local/bin + ln -sf ~/node/bin/node ~/.local/bin/node + ln -sf ~/node/bin/npm ~/.local/bin/npm + ln -sf ~/node/bin/npx ~/.local/bin/npx + ln -sf ~/node/bin/pnpm ~/.local/bin/pnpm +else + case "$(uname -s)" in + Darwin) + echo "STEP: brew-install-node" + if ! command -v brew >/dev/null 2>&1; then + echo "STATUS: failed" + echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "=== END ===" + exit 1 + fi + brew install node@22 + ;; + Linux) + echo "STEP: nodesource-setup" + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + echo "STEP: apt-install-nodejs" + sudo apt-get install -y nodejs + ;; + *) echo "STATUS: failed" - echo "ERROR: Homebrew not installed. Install brew first (https://brew.sh) then re-run." + echo "ERROR: Unsupported platform: $(uname -s)" echo "=== END ===" exit 1 - fi - brew install node@22 - ;; - Linux) - echo "STEP: nodesource-setup" - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - echo "STEP: apt-install-nodejs" - sudo apt-get install -y nodejs - ;; - *) - echo "STATUS: failed" - echo "ERROR: Unsupported platform: $(uname -s)" - echo "=== END ===" - exit 1 - ;; -esac + ;; + esac +fi if ! command -v node >/dev/null 2>&1; then echo "STATUS: failed" From 3c5ae96cdd63ef673be8d8d908f63f248bb11ea4 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 07:23:37 +0000 Subject: [PATCH 16/32] use node 22 with nvx --- setup/install-node.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/install-node.sh b/setup/install-node.sh index 4ecb1c5..229f7db 100755 --- a/setup/install-node.sh +++ b/setup/install-node.sh @@ -19,7 +19,7 @@ fi if command -v uvx >/dev/null 2>&1; then echo "STEP: uvx-nodeenv" - uvx nodeenv -n lts ~/node + uvx nodeenv -n 22 ~/node mkdir -p ~/.local/bin ln -sf ~/node/bin/node ~/.local/bin/node ln -sf ~/node/bin/npm ~/.local/bin/npm From 948a0dcadad423fac9b1d7eae7b79a7dfce91e77 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 07:28:48 +0000 Subject: [PATCH 17/32] fix: use nodeenv lts instead of pinned node 22 nodeenv doesn't support major-only version specifiers. Use lts which resolves to the latest LTS release. Co-Authored-By: Claude Opus 4.6 (1M context) --- setup/install-node.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup/install-node.sh b/setup/install-node.sh index 229f7db..4ecb1c5 100755 --- a/setup/install-node.sh +++ b/setup/install-node.sh @@ -19,7 +19,7 @@ fi if command -v uvx >/dev/null 2>&1; then echo "STEP: uvx-nodeenv" - uvx nodeenv -n 22 ~/node + uvx nodeenv -n lts ~/node mkdir -p ~/.local/bin ln -sf ~/node/bin/node ~/.local/bin/node ln -sf ~/node/bin/npm ~/.local/bin/npm From c795ecff6ec794e78718007b1ea2d9c8a5518cd3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:20:17 +0000 Subject: [PATCH 18/32] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Discord,=20WhatsApp,=20iMessage=20channel=20flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picking the wrong messaging channel during setup left users with no way to bail out — they had to either complete the chosen flow or kill setup and start over. This adds a Back option to the first prompt of three channel sub-flows that share the same simple shape (one leading brightSelect that's easy to extend). Mechanics: - New `setup/lib/back-nav.ts` exports a BACK_TO_CHANNEL_SELECTION sentinel and ChannelFlowResult type. - `setup/auto.ts` wraps the channel dispatch in a while-loop; channels return BACK_TO_CHANNEL_SELECTION to bounce back to the chooser without restarting setup. Channels not yet wired return void and the loop exits after one pass, so the change is backwards compatible. - Discord, WhatsApp, iMessage each add a `← Back to channel selection` option to their first prompt. Telegram, Slack, Teams, and Signal will follow as separate PRs — they each need a slightly different shape (extra prompt insertions, gating inside multi-step flows, etc.) and are easier to review independently. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 67 ++++++++++++++++++++++---------------- setup/channels/discord.ts | 12 ++++--- setup/channels/imessage.ts | 54 ++++++++++++++++-------------- setup/channels/whatsapp.ts | 14 +++++--- setup/lib/back-nav.ts | 17 ++++++++++ 5 files changed, 104 insertions(+), 60 deletions(-) create mode 100644 setup/lib/back-nav.ts diff --git a/setup/auto.ts b/setup/auto.ts index b57672f..4b7ca46 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -29,6 +29,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION } from './lib/back-nav.js'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; import { runSignalChannel } from './channels/signal.js'; @@ -440,35 +441,45 @@ async function main(): Promise { let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { - channelChoice = await askChannelChoice(); - if (channelChoice !== 'skip' && channelChoice !== 'other') { - await resolveDisplayName(); - } - if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); - } else if (channelChoice === 'discord') { - await runDiscordChannel(displayName!); - } else if (channelChoice === 'whatsapp') { - await runWhatsAppChannel(displayName!); - } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); - } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); - } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); - } else if (channelChoice === 'imessage') { - await runIMessageChannel(displayName!); - } else if (channelChoice === 'other') { - await askOtherChannelName(); - } else { - p.log.info( - brandBody( - wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', - 4, + // Loop so a channel sub-flow can return BACK_TO_CHANNEL_SELECTION on + // its first prompt and bounce the user back to the chooser without + // restarting setup. Channels not yet wired with the back option just + // return void and the loop exits after one pass. + let backed = true; + while (backed) { + backed = false; + channelChoice = await askChannelChoice(); + if (channelChoice !== 'skip' && channelChoice !== 'other') { + await resolveDisplayName(); + } + let result: void | typeof BACK_TO_CHANNEL_SELECTION; + if (channelChoice === 'telegram') { + await runTelegramChannel(displayName!); + } else if (channelChoice === 'discord') { + result = await runDiscordChannel(displayName!); + } else if (channelChoice === 'whatsapp') { + result = await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + await runSignalChannel(displayName!); + } else if (channelChoice === 'teams') { + await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + result = await runIMessageChannel(displayName!); + } else if (channelChoice === 'other') { + await askOtherChannelName(); + } else { + p.log.info( + brandBody( + wrapForGutter( + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', + 4, + ), ), - ), - ); + ); + } + if (result === BACK_TO_CHANNEL_SELECTION) backed = true; } } diff --git a/setup/channels/discord.ts b/setup/channels/discord.ts index 28c0254..ad9da17 100644 --- a/setup/channels/discord.ts +++ b/setup/channels/discord.ts @@ -27,6 +27,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; import { askOperatorRole } from '../lib/role-prompt.js'; @@ -48,8 +49,10 @@ interface AppInfo { owner: { id: string; username: string } | null; } -export async function runDiscordChannel(displayName: string): Promise { - const hasBot = await askHasBotToken(); +export async function runDiscordChannel(displayName: string): Promise { + const choice = await askHasBotToken(); + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + const hasBot = choice === 'yes'; if (!hasBot) { await walkThroughBotCreation(); } @@ -142,17 +145,18 @@ export async function runDiscordChannel(displayName: string): Promise { } } -async function askHasBotToken(): Promise { +async function askHasBotToken(): Promise<'yes' | 'no' | 'back'> { const answer = ensureAnswer( await brightSelect({ message: 'Do you already have a Discord bot?', options: [ { value: 'yes', label: 'Yes, I have a bot token ready' }, { value: 'no', label: "No, walk me through creating one" }, + { value: 'back', label: '← Back to channel selection' }, ], }), ); - return answer === 'yes'; + return answer as 'yes' | 'no' | 'back'; } async function walkThroughBotCreation(): Promise { diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts index 8c0b78d..5730fca 100644 --- a/setup/channels/imessage.ts +++ b/setup/channels/imessage.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; @@ -48,10 +49,11 @@ interface RemoteCreds { apiKey: string; } -export async function runIMessageChannel(displayName: string): Promise { +export async function runIMessageChannel(displayName: string): Promise { const isMac = os.platform() === 'darwin'; const mode = await askMode(isMac); + if (mode === 'back') return BACK_TO_CHANNEL_SELECTION; let remoteCreds: RemoteCreds | null = null; if (mode === 'local') { @@ -139,34 +141,38 @@ export async function runIMessageChannel(displayName: string): Promise { } } -async function askMode(isMac: boolean): Promise { +async function askMode(isMac: boolean): Promise { + const baseOptions = isMac + ? [ + { + value: 'local' as const, + label: 'Local (this Mac)', + hint: "uses this machine's iMessage account", + }, + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'the bot lives on another server', + }, + ] + : [ + { + value: 'remote' as const, + label: 'Remote (Photon API)', + hint: 'only option off macOS', + }, + ]; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'How should iMessage run?', initialValue: isMac ? 'local' : 'remote', - options: isMac - ? [ - { - value: 'local', - label: 'Local (this Mac)', - hint: "uses this machine's iMessage account", - }, - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'the bot lives on another server', - }, - ] - : [ - { - value: 'remote', - label: 'Remote (Photon API)', - hint: 'only option off macOS', - }, - ], + options: [ + ...baseOptions, + { value: 'back', label: '← Back to channel selection' }, + ], }), ); - setupLog.userInput('imessage_mode', String(choice)); + if (choice !== 'back') setupLog.userInput('imessage_mode', String(choice)); return choice; } diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..2a0de1a 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -33,6 +33,7 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; import { @@ -53,8 +54,9 @@ const AUTH_CREDS_PATH = path.join(process.cwd(), 'store', 'auth', 'creds.json'); type AuthMethod = 'qr' | 'pairing-code'; -export async function runWhatsAppChannel(displayName: string): Promise { +export async function runWhatsAppChannel(displayName: string): Promise { const method = await askAuthMethod(); + if (method === 'back') return BACK_TO_CHANNEL_SELECTION; const phone = method === 'pairing-code' ? await askPhoneNumber() : undefined; const install = await runQuietChild( @@ -148,7 +150,7 @@ export async function runWhatsAppChannel(displayName: string): Promise { } } -async function askAuthMethod(): Promise { +async function askAuthMethod(): Promise { const choice = ensureAnswer( await brightSelect({ message: 'How would you like to authenticate with WhatsApp?', @@ -163,10 +165,14 @@ async function askAuthMethod(): Promise { label: 'Enter a pairing code on your phone', hint: 'no camera needed', }, + { + value: 'back', + label: '← Back to channel selection', + }, ], }), - ) as AuthMethod; - setupLog.userInput('whatsapp_auth_method', choice); + ) as AuthMethod | 'back'; + if (choice !== 'back') setupLog.userInput('whatsapp_auth_method', choice); return choice; } diff --git a/setup/lib/back-nav.ts b/setup/lib/back-nav.ts new file mode 100644 index 0000000..586d161 --- /dev/null +++ b/setup/lib/back-nav.ts @@ -0,0 +1,17 @@ +/** + * Channel-flow back-navigation sentinel. + * + * Each `runXxxChannel(displayName)` in `setup/channels/` may return either + * `void` (sub-flow completed normally) or `BACK_TO_CHANNEL_SELECTION` to + * signal "the user picked '← Back to channel selection' on my first + * prompt; please re-run the channel chooser." `setup/auto.ts` catches + * that signal and loops back to `askChannelChoice()`. + * + * Back is only offered on the *first* interactive prompt of each channel + * sub-flow — once the user has answered something, they're committed + * (subsequent steps may have side effects like opening browsers, hitting + * APIs, or installing adapter packages, none of which are easily undone). + */ +export const BACK_TO_CHANNEL_SELECTION = Symbol('BACK_TO_CHANNEL_SELECTION'); + +export type ChannelFlowResult = void | typeof BACK_TO_CHANNEL_SELECTION; From e1ecfb9c4866c301eb2f8d601ecb3465e3928d6c Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:29:23 +0000 Subject: [PATCH 19/32] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Telegram=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from the Discord/WhatsApp/iMessage PR — depends on setup/lib/back-nav.ts and the auto.ts loop. Telegram's "no existing token" path adds one extra prompt — a brightSelect "Ready to paste your bot token?" between the BotFather instructions and the token paste. Clack's p.password prompt doesn't support menu options so we can't fold Back into the paste itself; the cleanest fix is a separate gate immediately before. The "existing token" path doesn't add noise — the Yes/No confirm becomes Yes/No/Back. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/telegram.ts | 36 ++++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 4b7ca46..bf3ce92 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -454,7 +454,7 @@ async function main(): Promise { } let result: void | typeof BACK_TO_CHANNEL_SELECTION; if (channelChoice === 'telegram') { - await runTelegramChannel(displayName!); + result = await runTelegramChannel(displayName!); } else if (channelChoice === 'discord') { result = await runDiscordChannel(displayName!); } else if (channelChoice === 'whatsapp') { diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index bf474f2..01a6675 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,7 +21,9 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -38,8 +40,10 @@ import { accentGreen, brandBold, fitToWidth, fmtDuration, note } from '../lib/th const DEFAULT_AGENT_NAME = 'Nano'; -export async function runTelegramChannel(displayName: string): Promise { - const token = await collectTelegramToken(); +export async function runTelegramChannel(displayName: string): Promise { + const tokenOrBack = await collectTelegramToken(); + if (tokenOrBack === 'back') return BACK_TO_CHANNEL_SELECTION; + const token = tokenOrBack; const botUsername = await validateTelegramToken(token); // Deep-link the user into the bot's chat so they're on the right screen @@ -131,17 +135,24 @@ export async function runTelegramChannel(displayName: string): Promise { } } -async function collectTelegramToken(): Promise { +async function collectTelegramToken(): Promise { const existing = readEnvKey('TELEGRAM_BOT_TOKEN'); if (existing && /^[0-9]+:[A-Za-z0-9_-]{35,}$/.test(existing)) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found an existing Telegram bot token (${existing.slice(0, 8)}…). Use it?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing token' }, + { value: 'no', label: 'No, paste a new one' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return 'back'; + if (choice === 'yes') { setupLog.userInput('telegram_token', 'reused-existing'); return existing; } + // 'no' falls through to the paste flow below } note( @@ -159,6 +170,19 @@ async function collectTelegramToken(): Promise { 'Set up your Telegram bot', ); + // Back-aware gate before the password prompt — `p.password` doesn't + // accept extra options, so we offer Back as a separate brightSelect + // immediately after the BotFather instructions and before the paste. + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to paste your bot token?', + options: [ + { value: 'continue', label: 'Yes, paste it on the next prompt' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return 'back'; + const answer = ensureAnswer( await p.password({ message: 'Paste your bot token', From 6a54b699120ac7da9202d0e9c8555d5e5f84c5f6 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:32:34 +0000 Subject: [PATCH 20/32] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Slack=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on the back-nav scaffolding from #2269 and the Telegram PR. Slack's first prompt was already a single-purpose "Press Enter to open Slack app settings" confirm. Replacing it with a 2-option brightSelect (Open / ← Back) folds the Back gate into the existing screen — net same number of prompts as before, just with a way out. The redundant confirmThenOpen Press-Enter step is dropped; openUrl is called inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/slack.ts | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index bf3ce92..b2d6dfc 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -464,7 +464,7 @@ async function main(): Promise { } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { - await runSlackChannel(displayName!); + result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { result = await runIMessageChannel(displayName!); } else if (channelChoice === 'other') { diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts index 0e3f052..0918075 100644 --- a/setup/channels/slack.ts +++ b/setup/channels/slack.ts @@ -25,7 +25,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { confirmThenOpen, formatNoteLink } from '../lib/browser.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { formatNoteLink, openUrl } from '../lib/browser.js'; +import { isHeadless } from '../platform.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; import { readEnvKey } from '../environment.js'; @@ -42,8 +45,9 @@ interface WorkspaceInfo { botUserId: string; } -export async function runSlackChannel(displayName: string): Promise { - await walkThroughAppCreation(); +export async function runSlackChannel(displayName: string): Promise { + const intro = await walkThroughAppCreation(); + if (intro === 'back') return BACK_TO_CHANNEL_SELECTION; const token = await collectBotToken(); const signingSecret = await collectSigningSecret(); @@ -121,7 +125,7 @@ export async function runSlackChannel(displayName: string): Promise { showPostInstallChecklist(info); } -async function walkThroughAppCreation(): Promise { +async function walkThroughAppCreation(): Promise<'continue' | 'back'> { note( [ "You'll create a Slack app that the assistant talks through.", @@ -140,7 +144,20 @@ async function walkThroughAppCreation(): Promise { ].filter((line): line is string => line !== null).join('\n'), 'Create a Slack app', ); - await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); + + // Back-aware gate replacing the old `confirmThenOpen` "Press Enter to open + // Slack app settings" so users can bail out of Slack before we open the + // browser or ask for tokens. + const choice = ensureAnswer(await brightSelect<'open' | 'back'>({ + message: 'Open Slack app settings in your browser?', + options: [ + { value: 'open', label: 'Open Slack app settings' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'open', + })); + if (choice === 'back') return 'back'; + if (!isHeadless()) openUrl(SLACK_APPS_URL); ensureAnswer( await p.confirm({ @@ -148,6 +165,7 @@ async function walkThroughAppCreation(): Promise { initialValue: true, }), ); + return 'continue'; } async function collectBotToken(): Promise { From c44c7a6669d30f1fd75f674bb7981723b108639b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:47:17 +0000 Subject: [PATCH 21/32] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Teams=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram and Slack PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Both Teams paths already had a brightSelect at the right place, so we just extend each with a Back option — no new prompts: - Existing-credentials path: Yes/No confirm becomes Yes/No/Back - Fresh-setup path: the very first stepGate ("How did that go?") gets a 4th option. Subsequent stepGates keep the original 3 options so we never lose mid-flow state. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/teams.ts | 55 ++++++++++++++++++++++++++++++++--------- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index b2d6dfc..8185b22 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -462,7 +462,7 @@ async function main(): Promise { } else if (channelChoice === 'signal') { await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { - await runTeamsChannel(displayName!); + result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { result = await runSlackChannel(displayName!); } else if (channelChoice === 'imessage') { diff --git a/setup/channels/teams.ts b/setup/channels/teams.ts index 41e2070..3691beb 100644 --- a/setup/channels/teams.ts +++ b/setup/channels/teams.ts @@ -30,6 +30,7 @@ import path from 'path'; import * as p from '@clack/prompts'; import k from 'kleur'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; import { brightSelect } from '../lib/bright-select.js'; import { confirmThenOpen } from '../lib/browser.js'; import { @@ -57,18 +58,24 @@ interface Collected { agentName?: string; } -export async function runTeamsChannel(_displayName: string): Promise { +export async function runTeamsChannel(_displayName: string): Promise { const collected: Collected = {}; const completed: string[] = []; const existingAppId = readEnvKey('TEAMS_APP_ID'); const existingPassword = readEnvKey('TEAMS_APP_PASSWORD'); if (existingAppId && existingPassword) { - const reuse = ensureAnswer(await p.confirm({ + const choice = ensureAnswer(await brightSelect<'yes' | 'no' | 'back'>({ message: `Found existing Teams credentials (App ID: ${existingAppId.slice(0, 8)}…). Use them?`, - initialValue: true, + options: [ + { value: 'yes', label: 'Yes, use the existing credentials' }, + { value: 'no', label: "No, set up new ones" }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'yes', })); - if (reuse) { + if (choice === 'back') return BACK_TO_CHANNEL_SELECTION; + if (choice === 'yes') { collected.appId = existingAppId; collected.appPassword = existingPassword; collected.appType = (readEnvKey('TEAMS_APP_TYPE') as 'SingleTenant' | 'MultiTenant') || 'MultiTenant'; @@ -85,7 +92,8 @@ export async function runTeamsChannel(_displayName: string): Promise { printIntro(); - await confirmPrereqs({ collected, completed }); + const prereqsResult = await confirmPrereqs({ collected, completed }); + if (prereqsResult === 'back') return BACK_TO_CHANNEL_SELECTION; await stepPublicUrl({ collected, completed }); await stepAppRegistration({ collected, completed }); await stepClientSecret({ collected, completed }); @@ -116,7 +124,7 @@ function printIntro(): void { ); } -async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise { +async function confirmPrereqs(args: { collected: Collected; completed: string[] }): Promise<'continue' | 'back'> { note( [ 'Before we start, confirm you have:', @@ -131,13 +139,36 @@ async function confirmPrereqs(args: { collected: Collected; completed: string[] 'Prereqs', ); - await stepGate({ - stepName: 'teams-prereqs', - stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', - reshow: () => confirmPrereqs(args), - args, - }); + // Back-aware variant of stepGate — Back is only offered on the very first + // step of the Teams flow so users can bail out before any state is taken. + while (true) { + const choice = ensureAnswer( + await brightSelect<'done' | 'help' | 'reshow' | 'back'>({ + message: 'How did that go?', + options: [ + { value: 'done', label: "Done — let's continue" }, + { value: 'help', label: 'Stuck — hand me off to Claude' }, + { value: 'reshow', label: 'Show me the steps again' }, + { value: 'back', label: '← Back to channel selection' }, + ], + }), + ); + if (choice === 'back') return 'back'; + if (choice === 'done') break; + if (choice === 'help') { + await offerHandoff({ + step: 'teams-prereqs', + stepDescription: 'confirming they have the right Microsoft 365 tenant and tunnel', + args, + }); + continue; + } + if (choice === 'reshow') { + return confirmPrereqs(args); + } + } args.completed.push('Prereqs confirmed.'); + return 'continue'; } // ─── step: public URL ────────────────────────────────────────────────── From decf18049ff06f582cbdab49dc4bc0f234df97d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 09:51:21 +0000 Subject: [PATCH 22/32] =?UTF-8?q?setup:=20add=20=E2=86=90=20Back=20option?= =?UTF-8?q?=20to=20Signal=20channel=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #2269 (back-nav scaffolding) plus the Telegram, Slack, and Teams PRs. They share the same scaffolding file from #2269 — they don't compile without it, so they have to stack. Signal had no user-facing prompt before the install kicked off, so there was nothing to attach a Back option to. This adds a brief "Set up Signal" info card (what's about to happen, no new phone number needed) followed by a Continue/Back brightSelect. The card serves double duty — context for the install plus the Back gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/auto.ts | 2 +- setup/channels/signal.ts | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/setup/auto.ts b/setup/auto.ts index 8185b22..91ad83a 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -460,7 +460,7 @@ async function main(): Promise { } else if (channelChoice === 'whatsapp') { result = await runWhatsAppChannel(displayName!); } else if (channelChoice === 'signal') { - await runSignalChannel(displayName!); + result = await runSignalChannel(displayName!); } else if (channelChoice === 'teams') { result = await runTeamsChannel(displayName!); } else if (channelChoice === 'slack') { diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..498690f 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -33,6 +33,8 @@ import k from 'kleur'; import * as setupLog from '../logs.js'; import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { brightSelect } from '../lib/bright-select.js'; import { type Block, type StepResult, @@ -48,7 +50,33 @@ import { accentGreen, fmtDuration, note } from '../lib/theme.js'; const DEFAULT_AGENT_NAME = 'Nano'; -export async function runSignalChannel(displayName: string): Promise { +export async function runSignalChannel(displayName: string): Promise { + note( + [ + "NanoClaw links to Signal as a *secondary* device on your existing", + "phone — no new number needed. Your assistant will send and receive", + "messages as the number on that phone.", + '', + "Here's what's about to happen:", + '', + ' 1. Check that signal-cli is installed (we\'ll guide you if not)', + ' 2. Install the Signal adapter', + ' 3. Show a QR code — scan it from Signal → Settings → Linked Devices', + ' 4. Wire your assistant and send a welcome message', + ].join('\n'), + 'Set up Signal', + ); + + const proceed = ensureAnswer(await brightSelect<'continue' | 'back'>({ + message: 'Ready to set up Signal?', + options: [ + { value: 'continue', label: 'Continue' }, + { value: 'back', label: '← Back to channel selection' }, + ], + initialValue: 'continue', + })); + if (proceed === 'back') return BACK_TO_CHANNEL_SELECTION; + await ensureSignalCli(); const install = await runQuietChild( From 7fdd7eaa1c6c13057b1c23dd3c09f6f3661b465a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 10:14:12 +0000 Subject: [PATCH 23/32] setup: update WhatsApp link instructions to "You / Settings" WhatsApp's mobile UI calls the menu "You" on iOS and "Settings" on Android (depending on platform/version). Both QR-scan and pairing-code captions only mentioned "Settings", so iOS users had to figure out the iOS-specific path on their own. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/whatsapp.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..e95d0dc 100644 --- a/setup/channels/whatsapp.ts +++ b/setup/channels/whatsapp.ts @@ -312,7 +312,7 @@ async function renderQr(qr: string): Promise { const QRCode = await import('qrcode'); const qrText = await QRCode.toString(qr, { type: 'terminal', small: true }); const caption = k.dim( - ' Open WhatsApp → Settings → Linked Devices → Link a Device → scan.', + ' Open WhatsApp → You / Settings → Linked Devices → Link a Device → scan.', ); return [...qrText.trimEnd().split('\n'), '', caption]; } catch { @@ -328,7 +328,7 @@ function formatPairingCard(code: string): string { '', ` ${brandBold(spaced)}`, '', - k.dim(' Open WhatsApp → Settings → Linked Devices → Link a Device'), + k.dim(' Open WhatsApp → You / Settings → Linked Devices → Link a Device'), k.dim(' → "Link with phone number instead" → enter this code.'), k.dim(' It expires in ~60 seconds.'), ].join('\n'); From a870e7ebf24f2aface4a4359d75955f9ab79917b Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 15:56:08 +0300 Subject: [PATCH 24/32] fix: keep resetStuckProcessingRows private, restore test wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test wrapper forwards the in-memory outDb as the writable handle, avoiding the filesystem reopen that fails in CI. The function stays private — the optional writableOutDb param is an internal detail, not a public API. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/host-sweep.test.ts | 11 ++++++++--- src/host-sweep.ts | 27 ++++++++++----------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/host-sweep.test.ts b/src/host-sweep.test.ts index 155b1b1..bd2e233 100644 --- a/src/host-sweep.test.ts +++ b/src/host-sweep.test.ts @@ -7,7 +7,12 @@ import Database from 'better-sqlite3'; import { describe, expect, it } from 'vitest'; import { deleteOrphanProcessingClaims, getProcessingClaims } from './db/session-db.js'; -import { ABSOLUTE_CEILING_MS, CLAIM_STUCK_MS, resetStuckProcessingRows, decideStuckAction } from './host-sweep.js'; +import { + ABSOLUTE_CEILING_MS, + CLAIM_STUCK_MS, + _resetStuckProcessingRowsForTesting, + decideStuckAction, +} from './host-sweep.js'; import type { Session } from './types.js'; const BASE = Date.parse('2026-04-20T12:00:00.000Z'); @@ -248,7 +253,7 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { // Sanity: the orphan claim is what would trip claim-stuck. expect(getProcessingClaims(outDb)).toHaveLength(1); - resetStuckProcessingRows(inDb, outDb, fakeSession(), 'absolute-ceiling', outDb); + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'absolute-ceiling'); // Regression assertion: orphan claim is gone — next sweep tick will see // an empty claims list and not kill the freshly respawned container. @@ -280,7 +285,7 @@ describe('resetStuckProcessingRows — orphan claim cleanup', () => { .run(claimedAt, future); outDb.prepare("INSERT INTO processing_ack VALUES ('m-2', 'processing', ?)").run(claimedAt); - resetStuckProcessingRows(inDb, outDb, fakeSession(), 'claim-stuck', outDb); + _resetStuckProcessingRowsForTesting(inDb, outDb, fakeSession(), 'claim-stuck'); expect(getProcessingClaims(outDb)).toEqual([]); const row = inDb.prepare('SELECT tries FROM messages_in WHERE id = ?').get('m-2') as { tries: number }; diff --git a/src/host-sweep.ts b/src/host-sweep.ts index b10ee0d..93a7e87 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -250,23 +250,16 @@ function enforceRunningContainerSla( resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck'); } -/** - * Reset retries on inbound rows the container claimed but never acked, and - * delete the orphan `processing_ack` rows so the next sweep tick doesn't - * see them. - * - * Safe to call only when the container that owned `outbound.db` is dead — - * production callers invoke this either in the `!alive` branch or right - * after `killContainer`. Without that guarantee, the orphan-claim delete - * would race the container's own writer. - * - * `writableOutDb` is the same handle outbound writes go through. When - * omitted (typical production path) the function reopens `outbound.db` - * read-write by session path for the delete and closes that handle on - * exit. Callers that already hold a writable handle — including tests - * using in-memory DBs — can pass it in to skip the reopen. - */ -export function resetStuckProcessingRows( +export function _resetStuckProcessingRowsForTesting( + inDb: Database.Database, + outDb: Database.Database, + session: Session, + reason: string, +): void { + resetStuckProcessingRows(inDb, outDb, session, reason, outDb); +} + +function resetStuckProcessingRows( inDb: Database.Database, outDb: Database.Database, session: Session, From 9ac1e6fd7bdd86366436f98aec237269a05b6252 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 12:57:49 +0000 Subject: [PATCH 25/32] chore: bump version to 2.0.31 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f92ed88..35856b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.30", + "version": "2.0.31", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 6d6584d1207e7ae55ab20c068c11be65a7a58426 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Tue, 5 May 2026 16:02:10 +0300 Subject: [PATCH 26/32] fix(test-infra): openInboundDb honors in-memory test DB openInboundDb() always opened /workspace/inbound.db which doesn't exist in CI. In test mode, return a thin wrapper over the in-memory singleton that delegates prepare/exec but no-ops close(), so callers' try/finally cleanup doesn't destroy the shared DB mid-test. One flag (_testMode), no monkey-patching, no saved-close bookkeeping. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/db/connection.ts | 45 +++++++-------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index ac563fa..871e43a 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -27,34 +27,29 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; -// True when initTestSessionDb() set _inbound to an in-memory DB. Used by -// openInboundDb() so tests don't try to open the missing /workspace path. -let _inboundIsTest = false; -// Saved real close() for the in-memory inbound singleton. We no-op the -// public .close() during tests so caller try/finally doesn't tear down -// the shared DB; closeSessionDb() invokes this to do the real teardown. -let _inboundOriginalClose: (() => void) | null = null; +let _testMode = false; /** - * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. - * + * Avoid all cached db reads; open inbound.db read-only with mmap and page cache disabled. + * * Use this (not getInboundDb) for readers that need to see host-written rows * promptly — e.g. messages_in polling. Caller must .close() the returned * connection (try/finally). * * Needed for mounts where host writes don't reliably invalidate * SQLite's caches: virtiofs (Colima, Lima, Podman Machine, Apple - * Container), NFS. - * + * Container), NFS. + * * Cost is microseconds per query, so safe for universal use. */ export function openInboundDb(): Database { - // In test mode the inbound DB is an in-memory singleton — there is no - // file at DEFAULT_INBOUND_PATH. Return the singleton directly; its - // .close() was no-op'd in initTestSessionDb so caller try/finally - // cleanup doesn't tear down the shared DB. - if (_inboundIsTest && _inbound) return _inbound; - + // In test mode return a thin wrapper over the in-memory singleton. + // Callers do try/finally { db.close() } — the wrapper no-ops close() + // so the singleton survives for the rest of the test. + if (_testMode && _inbound) { + const db = _inbound; + return { prepare: (sql: string) => db.prepare(sql), exec: (sql: string) => db.exec(sql), close: () => {} } as unknown as Database; + } const db = new Database(DEFAULT_INBOUND_PATH, { readonly: true }); db.exec('PRAGMA busy_timeout = 5000'); db.exec('PRAGMA mmap_size = 0'); @@ -183,13 +178,8 @@ export function clearStaleProcessingAcks(): void { /** For tests — creates in-memory DBs with the session schemas. */ export function initTestSessionDb(): { inbound: Database; outbound: Database } { + _testMode = true; _inbound = new Database(':memory:'); - _inboundIsTest = true; - // No-op .close() so callers using openInboundDb()'s try/finally pattern - // don't tear down our shared singleton. closeSessionDb() does the real - // teardown via the saved original. - _inboundOriginalClose = _inbound.close.bind(_inbound); - _inbound.close = () => {}; _inbound.exec('PRAGMA foreign_keys = ON'); _inbound.exec(` CREATE TABLE messages_in ( @@ -263,14 +253,9 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { } export function closeSessionDb(): void { - if (_inboundOriginalClose) { - _inboundOriginalClose(); - _inboundOriginalClose = null; - } else { - _inbound?.close(); - } + _inbound?.close(); _inbound = null; - _inboundIsTest = false; + _testMode = false; _outbound?.close(); _outbound = null; } From 9df6a91b32e277f6b8f41e4bd14bad56155c855b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 13:04:29 +0000 Subject: [PATCH 27/32] =?UTF-8?q?docs:=20update=20token=20count=20to=20141?= =?UTF-8?q?k=20tokens=20=C2=B7=2070%=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 d0bd6da..263081f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 140k tokens, 70% of context window + + 141k tokens, 70% of context window @@ -15,8 +15,8 @@ tokens - - 140k + + 141k From 395139ce635c92567f66bd20ab838dc904cda0ff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 15:04:19 +0000 Subject: [PATCH 28/32] chore: bump version to 2.0.32 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 35856b7..96f4ae9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.31", + "version": "2.0.32", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 73d45f80979ed0d8d73e4cd728095ce229b013d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 5 May 2026 15:07:05 +0000 Subject: [PATCH 29/32] =?UTF-8?q?docs:=20update=20token=20count=20to=20141?= =?UTF-8?q?k=20tokens=20=C2=B7=2071%=20of=20context=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- repo-tokens/badge.svg | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/repo-tokens/badge.svg b/repo-tokens/badge.svg index 263081f..e68caf4 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 141k tokens, 70% of context window + + 141k tokens, 71% of context window From 92a2347dc540437f06acafd64cd40a770f596715 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:04:53 +0000 Subject: [PATCH 30/32] setup: auto-install signal-cli when missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user picks Signal in setup and signal-cli isn't on PATH, today NanoClaw bails with a GitHub releases link and tells them to re-run. That's a hard wall for non-technical users — GitHub releases pages are intimidating, and the Linux native build / Java decision isn't obvious. Replace the bail-out with a real install: a new install-signal-cli.sh script that does `brew install signal-cli` on macOS or downloads the native Linux release into ~/.local/bin (no Java, no sudo). Wired into ensureSignalCli with a spinner; probe again after, fall back to the original manual-install copy if anything fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 62 ++++++++++++++++++++++------- setup/install-signal-cli.sh | 78 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 15 deletions(-) create mode 100755 setup/install-signal-cli.sh diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 8462a56..4e98ee1 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -134,42 +134,74 @@ export async function runSignalChannel(displayName: string): Promise { async function ensureSignalCli(): Promise { const cli = process.env.SIGNAL_CLI_PATH || 'signal-cli'; - const probe = spawnSync(cli, ['--version'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - if (!probe.error && probe.status === 0) return; + const probeFor = (): boolean => { + const r = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + return !r.error && r.status === 0; + }; + if (probeFor()) return; + note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We'll install it for you now — about 30 seconds, one-time only.", + '', + process.platform === 'darwin' + ? "On this Mac we'll use Homebrew (no admin password needed)." + : "On Linux we'll grab the native release binary (no Java needed) and install it to ~/.local/bin.", + ].join('\n'), + 'Setting up signal-cli', + ); + + const install = await runQuietChild( + 'install-signal-cli', + 'bash', + ['setup/install-signal-cli.sh'], + { + running: 'Installing signal-cli…', + done: 'signal-cli installed.', + }, + ); + + if (install.ok && probeFor()) return; + + const reason = install.terminal?.fields.ERROR; if (process.platform === 'darwin') { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + reason === 'homebrew_not_installed' + ? ' Reason: Homebrew is not installed.' + : ` Reason: ${reason ?? 'unknown'}.`, '', - 'The quickest way on macOS is Homebrew:', + 'You can install it manually:', '', k.cyan(' brew install signal-cli'), '', - "Install it in another terminal, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } else { note( [ - "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + "We couldn't install signal-cli automatically.", + ` Reason: ${reason ?? 'unknown'}.`, '', - 'Grab the latest release from GitHub:', + 'You can install it manually from GitHub:', '', k.cyan(' https://github.com/AsamK/signal-cli/releases'), '', - "Install it, make sure `signal-cli --version` works, then re-run setup.", + 'Then re-run setup.', ].join('\n'), - 'signal-cli not found', + "Couldn't install signal-cli", ); } await fail( - 'signal-install', - 'signal-cli is required but not installed.', - 'Install it and re-run setup.', + 'install-signal-cli', + 'signal-cli is required but the auto-install failed.', + 'Install it manually and re-run setup.', ); } diff --git a/setup/install-signal-cli.sh b/setup/install-signal-cli.sh new file mode 100755 index 0000000..870220e --- /dev/null +++ b/setup/install-signal-cli.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# install-signal-cli.sh — auto-install signal-cli on the host. +# +# NanoClaw needs `signal-cli` on PATH to talk to Signal. Picks the right +# install method per platform: +# macOS → `brew install signal-cli` (bottled, no Java needed) +# Linux → download latest native binary from GitHub releases to +# ~/.local/bin/signal-cli (no Java, no sudo) +# +# Emits the standard NanoClaw STATUS block on success or failure so the +# `runQuietChild` driver can parse the outcome. + +set -euo pipefail + +VERSION="0.14.3" +INSTALL_DIR="${HOME}/.local/bin" + +emit_status() { + local status=$1 error=${2:-} + echo "=== NANOCLAW SETUP: INSTALL_SIGNAL_CLI ===" + echo "STATUS: ${status}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[install-signal-cli] $*" >&2; } + +uname_s=$(uname) + +if [[ "${uname_s}" == "Darwin" ]]; then + if ! command -v brew >/dev/null 2>&1; then + emit_status failed "homebrew_not_installed" + exit 1 + fi + log "Installing signal-cli via Homebrew…" + brew install signal-cli >&2 || { + emit_status failed "brew_install_failed" + exit 1 + } + emit_status success + exit 0 +fi + +if [[ "${uname_s}" != "Linux" ]]; then + emit_status failed "unsupported_platform_${uname_s}" + exit 1 +fi + +# Linux native build (no Java required) → ~/.local/bin/signal-cli. +URL="https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz" +TARBALL=$(mktemp -t signal-cli.XXXXXX.tar.gz) + +log "Downloading signal-cli v${VERSION} (~96MB)…" +if ! curl -fLsS -o "${TARBALL}" "${URL}"; then + rm -f "${TARBALL}" + emit_status failed "download_failed" + exit 1 +fi + +log "Extracting…" +EXTRACT_DIR=$(mktemp -d) +if ! tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "extract_failed" + exit 1 +fi + +mkdir -p "${INSTALL_DIR}" +log "Installing to ${INSTALL_DIR}/signal-cli…" +if ! mv "${EXTRACT_DIR}/signal-cli" "${INSTALL_DIR}/signal-cli"; then + rm -rf "${TARBALL}" "${EXTRACT_DIR}" + emit_status failed "install_failed" + exit 1 +fi +chmod +x "${INSTALL_DIR}/signal-cli" +rm -rf "${TARBALL}" "${EXTRACT_DIR}" + +emit_status success From 291a1fc8a47f5c0949c37fb96361b37739e3a2ea Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Tue, 5 May 2026 17:09:39 +0000 Subject: [PATCH 31/32] update Signal intro copy to reflect auto-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today's copy says "Check that signal-cli is installed (we'll guide you if not)" but the auto-install PR (#2281) makes that misleading — we don't guide, we just install. Update the intro list to match what will actually happen, and add a "no input needed for any of it" lead so users know to expect a hands-off run. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/channels/signal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts index 498690f..5f5518d 100644 --- a/setup/channels/signal.ts +++ b/setup/channels/signal.ts @@ -57,9 +57,9 @@ export async function runSignalChannel(displayName: string): Promise Date: Tue, 5 May 2026 20:47:36 +0200 Subject: [PATCH 32/32] fix(setup): pin Baileys to 7.0.0-rc.9 in install-whatsapp scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2259 (Baileys v6→v7) was merged into the channels branch instead of main. PR #2260 was merged into main 28s later assuming v7 was already in place. The v6 pin survived in three sites while the WhatsApp adapter copied from origin/channels at install time was already on the v7 LID API, breaking every fresh migrate-v2.sh run at 2c-install-whatsapp with TS errors on remoteJidAlt/participantAlt/lid-mapping.update. Bumps the pin to 7.0.0-rc.9 (the version v1 has been running on for months) in: - setup/install-whatsapp.sh - setup/add-whatsapp.sh - .claude/skills/add-whatsapp/SKILL.md (install instruction) package.json + pnpm-lock.yaml are not touched here — install-whatsapp.sh mutates them at runtime via pnpm install with the corrected pin. Closes #2283 --- .claude/skills/add-whatsapp/SKILL.md | 2 +- setup/add-whatsapp.sh | 2 +- setup/install-whatsapp.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) mode change 100755 => 100644 setup/add-whatsapp.sh mode change 100755 => 100644 setup/install-whatsapp.sh diff --git a/.claude/skills/add-whatsapp/SKILL.md b/.claude/skills/add-whatsapp/SKILL.md index 3f10ce1..232725f 100644 --- a/.claude/skills/add-whatsapp/SKILL.md +++ b/.claude/skills/add-whatsapp/SKILL.md @@ -57,7 +57,7 @@ groups: () => import('./groups.js'), ### 5. Install the adapter packages (pinned) ```bash -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 ``` ### 6. Build diff --git a/setup/add-whatsapp.sh b/setup/add-whatsapp.sh old mode 100755 new mode 100644 index c7356af..be2dacc --- a/setup/add-whatsapp.sh +++ b/setup/add-whatsapp.sh @@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$PROJECT_ROOT" # Keep in sync with .claude/skills/add-whatsapp/SKILL.md. -BAILEYS_VERSION="@whiskeysockets/baileys@6.17.16" +BAILEYS_VERSION="@whiskeysockets/baileys@7.0.0-rc.9" QRCODE_VERSION="qrcode@1.5.4" QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" PINO_VERSION="pino@9.6.0" diff --git a/setup/install-whatsapp.sh b/setup/install-whatsapp.sh old mode 100755 new mode 100644 index 1c62d65..f18b87a --- a/setup/install-whatsapp.sh +++ b/setup/install-whatsapp.sh @@ -66,7 +66,7 @@ if ! grep -q "'whatsapp-auth':" setup/index.ts; then fi echo "STEP: pnpm-install" -pnpm install @whiskeysockets/baileys@6.17.16 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 +pnpm install @whiskeysockets/baileys@7.0.0-rc.9 qrcode@1.5.4 @types/qrcode@1.5.6 pino@9.6.0 echo "STEP: pnpm-build" pnpm run build