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 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/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts index 3ca44a8..871e43a 100644 --- a/container/agent-runner/src/db/connection.ts +++ b/container/agent-runner/src/db/connection.ts @@ -27,21 +27,29 @@ const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; let _inbound: Database | null = null; let _outbound: Database | null = null; let _heartbeatPath: string = DEFAULT_HEARTBEAT_PATH; +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 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'); @@ -170,6 +178,7 @@ 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:'); _inbound.exec('PRAGMA foreign_keys = ON'); _inbound.exec(` @@ -246,6 +255,7 @@ export function initTestSessionDb(): { inbound: Database; outbound: Database } { export function closeSessionDb(): void { _inbound?.close(); _inbound = null; + _testMode = false; _outbound?.close(); _outbound = null; } 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', 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/nanoclaw.sh b/nanoclaw.sh index 82d445a..bcf4e49 100755 --- a/nanoclaw.sh +++ b/nanoclaw.sh @@ -137,6 +137,83 @@ 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 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 + +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 +} + +MEM_MB=$(detect_mem_mb) +: "${MEM_MB:=0}" + +LOW_MEM=false +[ "$MEM_MB" -gt 0 ] && [ "$MEM_MB" -lt "$MIN_MEM_MB" ] && LOW_MEM=true + +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+ 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 /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 - 140k tokens, 70% of context window + + 141k tokens, 71% of context window @@ -15,8 +15,8 @@ tokens - - 140k + + 141k 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/auto.ts b/setup/auto.ts index b57672f..91ad83a 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') { + result = await runTelegramChannel(displayName!); + } else if (channelChoice === 'discord') { + result = await runDiscordChannel(displayName!); + } else if (channelChoice === 'whatsapp') { + result = await runWhatsAppChannel(displayName!); + } else if (channelChoice === 'signal') { + result = await runSignalChannel(displayName!); + } else if (channelChoice === 'teams') { + result = await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + result = 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/signal.ts b/setup/channels/signal.ts index 8462a56..cfdf9b2 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 — no input needed for any of it:", + '', + ' 1. Set up signal-cli (auto-installs if missing)', + ' 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( @@ -134,42 +162,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/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 { 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 ────────────────────────────────────────────────── diff --git a/setup/channels/telegram.ts b/setup/channels/telegram.ts index 7117f5d..9faf3b2 100644 --- a/setup/channels/telegram.ts +++ b/setup/channels/telegram.ts @@ -21,8 +21,10 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import * as setupLog from '../logs.js'; -import { openUrl } from '../lib/browser.js'; import { isHeadless } from '../platform.js'; +import { BACK_TO_CHANNEL_SELECTION, type ChannelFlowResult } from '../lib/back-nav.js'; +import { confirmThenOpen, formatNoteLink, openUrl } from '../lib/browser.js'; +import { brightSelect } from '../lib/bright-select.js'; import { askOperatorRole } from '../lib/role-prompt.js'; import { type Block, @@ -39,8 +41,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 @@ -155,17 +159,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( @@ -183,6 +194,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', diff --git a/setup/channels/whatsapp.ts b/setup/channels/whatsapp.ts index 922c985..b8365b4 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; } @@ -312,7 +318,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 +334,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'); 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" 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 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 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; 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; 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(); diff --git a/src/channels/chat-sdk-bridge.test.ts b/src/channels/chat-sdk-bridge.test.ts index 7e3c4ff..3049c29 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,20 @@ 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 +92,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..f403dfa 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,59 @@ 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; diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 09c82ac..93a7e87 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -256,7 +256,7 @@ export function _resetStuckProcessingRowsForTesting( session: Session, reason: string, ): void { - resetStuckProcessingRows(inDb, outDb, session, reason); + resetStuckProcessingRows(inDb, outDb, session, reason, outDb); } function resetStuckProcessingRows( @@ -264,6 +264,7 @@ function resetStuckProcessingRows( outDb: Database.Database, session: Session, reason: string, + writableOutDb?: Database.Database, ): void { const claims = getProcessingClaims(outDb); const now = Date.now(); @@ -300,19 +301,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(); } } 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}`; }