From 2383bde80fc621d4ecb52db90a3335ad713bb85a Mon Sep 17 00:00:00 2001 From: Lazer Cohen Date: Thu, 23 Apr 2026 12:12:30 +0300 Subject: [PATCH 01/31] fix(container): scope orphan reaper by install label so peers don't kill each other Two installs on the same host could trash each other's containers: the reaper used `docker ps --filter name=nanoclaw-`, a substring match that picked up every install's containers. A crash-looping peer (e.g. a legacy v1 plist respawning ~6k times) would call cleanupOrphans on every boot and kill the healthy install's session containers within seconds of spawn. - Stamp `--label nanoclaw-install=` onto every spawned container. - cleanupOrphans filters by that label; healthy peers are left alone. - Setup preflight enumerates `com.nanoclaw*` launchd plists / nanoclaw user systemd units, probes state/runs, and unloads any that are crash-looping (state != running AND runs > 10) before installing this install's service. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/peer-cleanup.ts | 186 ++++++++++++++++++++++++++++++++++ setup/service.ts | 14 +++ src/config.ts | 6 +- src/container-runner.ts | 3 +- src/container-runtime.test.ts | 12 +++ src/container-runtime.ts | 20 +++- 6 files changed, 234 insertions(+), 7 deletions(-) create mode 100644 setup/peer-cleanup.ts diff --git a/setup/peer-cleanup.ts b/setup/peer-cleanup.ts new file mode 100644 index 000000000..10b22b992 --- /dev/null +++ b/setup/peer-cleanup.ts @@ -0,0 +1,186 @@ +/** + * Detect and clean up unhealthy NanoClaw peer services. + * + * Runs as a setup preflight before we install our own service. A crash-looping + * peer install (typically the legacy v1 `com.nanoclaw` plist) silently trashes + * this install's containers on every respawn because its `cleanupOrphans()` + * reaps anything matching `nanoclaw-`. We scope our reaper by label now, but + * we still need to stop the peer from killing us on its way down. + * + * A peer is "unhealthy" when: + * - launchd: `state != running` AND `runs > UNHEALTHY_RUNS_THRESHOLD` + * - systemd: unit is in `failed` state, OR `activating` with many restarts + * + * Healthy peers are left alone — multiple installs can coexist fine now that + * container-reaper is label-scoped. + */ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; +import { log } from '../src/log.js'; + +const UNHEALTHY_RUNS_THRESHOLD = 10; + +export interface PeerStatus { + label: string; + configPath: string; + state: string; + runs: number; + unhealthy: boolean; +} + +export interface PeerCleanupResult { + checked: PeerStatus[]; + unloaded: PeerStatus[]; + failures: Array<{ label: string; err: string }>; +} + +/** + * Scan for peer NanoClaw services and unload any that are crash-looping. + * Returns a summary suitable for emitStatus / setup-log reporting. + */ +export function cleanupUnhealthyPeers(projectRoot: string = process.cwd()): PeerCleanupResult { + const platform = os.platform(); + if (platform === 'darwin') { + return cleanupLaunchdPeers(projectRoot); + } + if (platform === 'linux') { + return cleanupSystemdPeers(projectRoot); + } + return { checked: [], unloaded: [], failures: [] }; +} + +// ---- launchd (macOS) -------------------------------------------------------- + +function cleanupLaunchdPeers(projectRoot: string): PeerCleanupResult { + const ownLabel = getLaunchdLabel(projectRoot); + const agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents'); + const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] }; + + let plists: string[]; + try { + plists = fs + .readdirSync(agentsDir) + .filter((f) => /^com\.nanoclaw.*\.plist$/.test(f)) + .map((f) => path.join(agentsDir, f)); + } catch { + return result; + } + + const uid = process.getuid?.() ?? 0; + + for (const plistPath of plists) { + const label = path.basename(plistPath, '.plist'); + if (label === ownLabel) continue; + + const status = probeLaunchdPeer(label, plistPath, uid); + if (!status) continue; + result.checked.push(status); + + if (!status.unhealthy) continue; + + try { + execFileSync('launchctl', ['unload', plistPath], { stdio: 'pipe' }); + log.info('Unloaded unhealthy peer launchd service', { + label, + state: status.state, + runs: status.runs, + plistPath, + }); + result.unloaded.push(status); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn('Failed to unload peer launchd service', { label, err: message }); + result.failures.push({ label, err: message }); + } + } + + return result; +} + +function probeLaunchdPeer(label: string, plistPath: string, uid: number): PeerStatus | null { + let output: string; + try { + output = execFileSync('launchctl', ['print', `gui/${uid}/${label}`], { + stdio: ['ignore', 'pipe', 'pipe'], + encoding: 'utf-8', + }); + } catch { + // Not loaded → not currently a threat. Skip silently. + return null; + } + + const state = /^\s*state\s*=\s*(.+?)\s*$/m.exec(output)?.[1] ?? 'unknown'; + const runsStr = /^\s*runs\s*=\s*(\d+)/m.exec(output)?.[1]; + const runs = runsStr ? parseInt(runsStr, 10) : 0; + + const unhealthy = state !== 'running' && runs > UNHEALTHY_RUNS_THRESHOLD; + return { label, configPath: plistPath, state, runs, unhealthy }; +} + +// ---- systemd (Linux) -------------------------------------------------------- + +function cleanupSystemdPeers(projectRoot: string): PeerCleanupResult { + const ownUnit = getSystemdUnit(projectRoot); + const unitDir = path.join(os.homedir(), '.config', 'systemd', 'user'); + const result: PeerCleanupResult = { checked: [], unloaded: [], failures: [] }; + + let units: string[]; + try { + units = fs + .readdirSync(unitDir) + .filter((f) => /^nanoclaw.*\.service$/.test(f)) + .map((f) => f.replace(/\.service$/, '')); + } catch { + return result; + } + + for (const unit of units) { + if (unit === ownUnit) continue; + + const status = probeSystemdPeer(unit); + if (!status) continue; + result.checked.push(status); + + if (!status.unhealthy) continue; + + try { + execFileSync('systemctl', ['--user', 'disable', '--now', `${unit}.service`], { stdio: 'pipe' }); + log.info('Disabled unhealthy peer systemd unit', { + unit, + state: status.state, + runs: status.runs, + }); + result.unloaded.push(status); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn('Failed to disable peer systemd unit', { unit, err: message }); + result.failures.push({ label: unit, err: message }); + } + } + + return result; +} + +function probeSystemdPeer(unit: string): PeerStatus | null { + const unitPath = path.join(os.homedir(), '.config', 'systemd', 'user', `${unit}.service`); + try { + const output = execFileSync( + 'systemctl', + ['--user', 'show', '--property=ActiveState,NRestarts', `${unit}.service`], + { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' }, + ); + const activeState = /^ActiveState=(.+)$/m.exec(output)?.[1]?.trim() ?? 'unknown'; + const restartsStr = /^NRestarts=(\d+)/m.exec(output)?.[1]; + const runs = restartsStr ? parseInt(restartsStr, 10) : 0; + + const unhealthy = + activeState === 'failed' || (activeState !== 'active' && runs > UNHEALTHY_RUNS_THRESHOLD); + return { label: unit, configPath: unitPath, state: activeState, runs, unhealthy }; + } catch { + return null; + } +} diff --git a/setup/service.ts b/setup/service.ts index 79304610f..777c0c5cb 100644 --- a/setup/service.ts +++ b/setup/service.ts @@ -11,6 +11,7 @@ import path from 'path'; import { log } from '../src/log.js'; import { getLaunchdLabel, getSystemdUnit } from '../src/install-slug.js'; +import { cleanupUnhealthyPeers } from './peer-cleanup.js'; import { commandExists, getPlatform, @@ -53,6 +54,19 @@ export async function run(_args: string[]): Promise { fs.mkdirSync(path.join(projectRoot, 'logs'), { recursive: true }); + // Peer preflight — a crash-looping peer install (most often the legacy v1 + // `com.nanoclaw` plist) will keep trashing this install's containers on + // every respawn via its own cleanupOrphans. Detect and unload any peer + // that's unhealthy before we install our service. Healthy peers are left + // alone now that container reaping is install-label-scoped. + const peerReport = cleanupUnhealthyPeers(projectRoot); + if (peerReport.unloaded.length > 0) { + log.warn('Unloaded unhealthy peer NanoClaw services', { + count: peerReport.unloaded.length, + labels: peerReport.unloaded.map((p) => p.label), + }); + } + if (platform === 'macos') { setupLaunchd(projectRoot, nodePath, homeDir); } else if (platform === 'linux') { diff --git a/src/config.ts b/src/config.ts index 79a1ce9df..a82d4f5c9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,7 +2,7 @@ import os from 'os'; import path from 'path'; import { readEnvFile } from './env.js'; -import { getContainerImageBase, getDefaultContainerImage } from './install-slug.js'; +import { getContainerImageBase, getDefaultContainerImage, getInstallSlug } from './install-slug.js'; import { isValidTimezone } from './timezone.js'; // Read config values from .env (falls back to process.env). @@ -27,6 +27,10 @@ export const DATA_DIR = path.resolve(PROJECT_ROOT, 'data'); // `nanoclaw-agent:latest` and clobber each other on rebuild. export const CONTAINER_IMAGE_BASE = process.env.CONTAINER_IMAGE_BASE || getContainerImageBase(PROJECT_ROOT); export const CONTAINER_IMAGE = process.env.CONTAINER_IMAGE || getDefaultContainerImage(PROJECT_ROOT); +// Install slug — stamped onto every spawned container via --label so +// cleanupOrphans only reaps containers from this install, not peers. +export const INSTALL_SLUG = getInstallSlug(PROJECT_ROOT); +export const CONTAINER_INSTALL_LABEL = `nanoclaw-install=${INSTALL_SLUG}`; export const CONTAINER_TIMEOUT = parseInt(process.env.CONTAINER_TIMEOUT || '1800000', 10); export const CONTAINER_MAX_OUTPUT_SIZE = parseInt(process.env.CONTAINER_MAX_OUTPUT_SIZE || '10485760', 10); // 10MB default export const ONECLI_URL = process.env.ONECLI_URL || envConfig.ONECLI_URL; diff --git a/src/container-runner.ts b/src/container-runner.ts index 646b11811..71e2064f3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -12,6 +12,7 @@ import { OneCLI } from '@onecli-sh/sdk'; import { CONTAINER_IMAGE, CONTAINER_IMAGE_BASE, + CONTAINER_INSTALL_LABEL, DATA_DIR, GROUPS_DIR, ONECLI_API_KEY, @@ -389,7 +390,7 @@ async function buildContainerArgs( providerContribution: ProviderContainerContribution, agentIdentifier?: string, ): Promise { - const args: string[] = ['run', '--rm', '--name', containerName]; + const args: string[] = ['run', '--rm', '--name', containerName, '--label', CONTAINER_INSTALL_LABEL]; // Environment — only vars read by code we don't own. // Everything NanoClaw-specific is in container.json (read by runner at startup). diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 47d97448e..f6f6e8a82 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -24,6 +24,7 @@ import { ensureContainerRuntimeRunning, cleanupOrphans, } from './container-runtime.js'; +import { CONTAINER_INSTALL_LABEL } from './config.js'; import { log } from './log.js'; beforeEach(() => { @@ -84,6 +85,17 @@ describe('ensureContainerRuntimeRunning', () => { // --- cleanupOrphans --- describe('cleanupOrphans', () => { + it('filters ps by the install label so peers are not reaped', () => { + mockExecSync.mockReturnValueOnce(''); + + cleanupOrphans(); + + expect(mockExecSync).toHaveBeenCalledWith( + `${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`, + expect.any(Object), + ); + }); + it('stops orphaned nanoclaw containers', () => { // docker ps returns container names, one per line mockExecSync.mockReturnValueOnce('nanoclaw-group1-111\nnanoclaw-group2-222\n'); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 5e684269a..82ddb5eca 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -5,6 +5,7 @@ import { execSync } from 'child_process'; import os from 'os'; +import { CONTAINER_INSTALL_LABEL } from './config.js'; import { log } from './log.js'; /** The container runtime binary name. */ @@ -56,13 +57,22 @@ export function ensureContainerRuntimeRunning(): void { } } -/** Kill orphaned NanoClaw containers from previous runs. */ +/** + * Kill orphaned NanoClaw containers from THIS install's previous runs. + * + * Scoped by label `nanoclaw-install=` so a crash-looping peer install + * cannot reap our containers, and we cannot reap theirs. The label is + * stamped onto every container at spawn time — see container-runner.ts. + */ export function cleanupOrphans(): void { try { - const output = execSync(`${CONTAINER_RUNTIME_BIN} ps --filter name=nanoclaw- --format '{{.Names}}'`, { - stdio: ['pipe', 'pipe', 'pipe'], - encoding: 'utf-8', - }); + const output = execSync( + `${CONTAINER_RUNTIME_BIN} ps --filter label=${CONTAINER_INSTALL_LABEL} --format '{{.Names}}'`, + { + stdio: ['pipe', 'pipe', 'pipe'], + encoding: 'utf-8', + }, + ); const orphans = output.trim().split('\n').filter(Boolean); for (const name of orphans) { try { From d8b1f52f2b61f7104f28d3667bab79b934a121e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 09:52:56 +0000 Subject: [PATCH 02/31] chore: bump version to 2.0.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1d67485dc..09053c429 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.4", + "version": "2.0.5", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3101f65a722325e6c54e55c2edc50adde413e9d0 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 11:09:30 +0300 Subject: [PATCH 03/31] feat(setup): add Slack and iMessage channel flows (experimental) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack: interactive driver walks through app creation, validates the bot token via auth.test, installs the adapter, and prints a post-install checklist for the webhook URL + Event Subscriptions config. No welcome DM since Slack needs a public URL before inbound events work — the driver's own "finish in Slack" note replaces the outro "check your DMs" banner. iMessage: picks local (macOS) vs remote (Photon) mode. Local mode opens the node binary's directory in Finder so the user can drag it into Full Disk Access. Remote mode prompts for Photon URL + API key. Asks for the operator's phone/email, then wires the first agent including a welcome iMessage. Both marked "(experimental)" in the askChannelChoice picker. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-imessage.sh | 160 +++++++++++++++++++ setup/add-slack.sh | 125 +++++++++++++++ setup/auto.ts | 50 ++++-- setup/channels/imessage.ts | 314 +++++++++++++++++++++++++++++++++++++ setup/channels/slack.ts | 249 +++++++++++++++++++++++++++++ setup/lib/claude-assist.ts | 4 + 6 files changed, 891 insertions(+), 11 deletions(-) create mode 100755 setup/add-imessage.sh create mode 100755 setup/add-slack.sh create mode 100644 setup/channels/imessage.ts create mode 100644 setup/channels/slack.ts diff --git a/setup/add-imessage.sh b/setup/add-imessage.sh new file mode 100755 index 000000000..ea1986203 --- /dev/null +++ b/setup/add-imessage.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# +# Install the iMessage adapter, persist mode/creds to .env + data/env/env, +# and restart the service. Non-interactive — the Full Disk Access walkthrough +# (local mode) and Photon URL/key prompts (remote mode) live in +# setup/channels/imessage.ts. Creds come in via env vars: +# IMESSAGE_LOCAL 'true' | 'false' (required) +# IMESSAGE_ENABLED 'true' (required when IMESSAGE_LOCAL=true) +# IMESSAGE_SERVER_URL (required when IMESSAGE_LOCAL=false) +# IMESSAGE_API_KEY (required when IMESSAGE_LOCAL=false) +# +# Emits exactly one status block on stdout (ADD_IMESSAGE) at the end. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-imessage/SKILL.md. +ADAPTER_VERSION="chat-adapter-imessage@0.1.1" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + local mode=${IMESSAGE_LOCAL:-} + echo "=== NANOCLAW SETUP: ADD_IMESSAGE ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$mode" ] && echo "MODE: $([ "$mode" = "true" ] && echo local || echo remote)" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-imessage] $*" >&2; } + +# Validate creds based on mode. +if [ -z "${IMESSAGE_LOCAL:-}" ]; then + emit_status failed "IMESSAGE_LOCAL env var not set (expected true|false)" + exit 1 +fi +if [ "${IMESSAGE_LOCAL}" = "true" ]; then + if [ -z "${IMESSAGE_ENABLED:-}" ]; then + emit_status failed "IMESSAGE_ENABLED env var not set for local mode" + exit 1 + fi + if [ "$(uname -s)" != "Darwin" ]; then + emit_status failed "local mode requires macOS" + exit 1 + fi +else + if [ -z "${IMESSAGE_SERVER_URL:-}" ]; then + emit_status failed "IMESSAGE_SERVER_URL env var not set for remote mode" + exit 1 + fi + if [ -z "${IMESSAGE_API_KEY:-}" ]; then + emit_status failed "IMESSAGE_API_KEY env var not set for remote mode" + exit 1 + fi +fi + +need_install() { + [ ! -f src/channels/imessage.ts ] && return 0 + ! grep -q "^import './imessage.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/imessage.ts" > src/channels/imessage.ts + + # Append self-registration import if missing. + if ! grep -q "^import './imessage.js';" src/channels/index.ts; then + echo "import './imessage.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} + +remove_env() { + local key=$1 + if grep -q "^${key}=" .env 2>/dev/null; then + grep -v "^${key}=" .env > .env.tmp && mv .env.tmp .env + fi +} + +# Write the canonical keys for the chosen mode, strip the opposite mode's +# keys so stale values can't confuse the adapter's factory. +upsert_env IMESSAGE_LOCAL "$IMESSAGE_LOCAL" +if [ "$IMESSAGE_LOCAL" = "true" ]; then + upsert_env IMESSAGE_ENABLED "$IMESSAGE_ENABLED" + remove_env IMESSAGE_SERVER_URL + remove_env IMESSAGE_API_KEY +else + upsert_env IMESSAGE_SERVER_URL "$IMESSAGE_SERVER_URL" + upsert_env IMESSAGE_API_KEY "$IMESSAGE_API_KEY" + remove_env IMESSAGE_ENABLED +fi + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the creds…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ + || true + ;; +esac + +# Give the adapter a moment to open chat.db (local) or handshake with +# Photon (remote) before emitting success. +sleep 3 + +emit_status success diff --git a/setup/add-slack.sh b/setup/add-slack.sh new file mode 100755 index 000000000..3eea3e5e6 --- /dev/null +++ b/setup/add-slack.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Install the Slack adapter, persist SLACK_BOT_TOKEN + SLACK_SIGNING_SECRET to +# .env + data/env/env, and restart the service. Non-interactive — the +# operator-facing app creation walkthrough + credential paste live in +# setup/channels/slack.ts. Credentials come in via env vars: +# SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET. +# +# Emits exactly one status block on stdout (ADD_SLACK) at the end. All chatty +# progress messages go to stderr so setup:auto's raw-log capture sees the full +# story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-slack/SKILL.md. +ADAPTER_VERSION="@chat-adapter/slack@4.26.0" + +# Resolve which remote carries the channels branch — handles forks where +# upstream lives on a different remote than `origin`. +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SLACK ===" + echo "STATUS: ${status}" + echo "ADAPTER_VERSION: ${ADAPTER_VERSION}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-slack] $*" >&2; } + +if [ -z "${SLACK_BOT_TOKEN:-}" ]; then + emit_status failed "SLACK_BOT_TOKEN env var not set" + exit 1 +fi +if [ -z "${SLACK_SIGNING_SECRET:-}" ]; then + emit_status failed "SLACK_SIGNING_SECRET env var not set" + exit 1 +fi + +need_install() { + [ ! -f src/channels/slack.ts ] && return 0 + ! grep -q "^import './slack.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter from ${CHANNELS_BRANCH}…" + git show "${CHANNELS_BRANCH}:src/channels/slack.ts" > src/channels/slack.ts + + # Append self-registration import if missing. + if ! grep -q "^import './slack.js';" src/channels/index.ts; then + echo "import './slack.js';" >> src/channels/index.ts + fi + + log "Installing ${ADAPTER_VERSION}…" + pnpm install "${ADAPTER_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${ADAPTER_VERSION} failed" + exit 1 + } + + log "Building…" + pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 + } +else + log "Adapter files already installed — skipping install phase." +fi + +# Persist credentials. auto.ts validates via auth.test before this point, so +# bad values here would be an internal bug rather than operator input. +touch .env +upsert_env() { + local key=$1 value=$2 + if grep -q "^${key}=" .env; then + awk -v k="$key" -v v="$value" \ + 'BEGIN{FS=OFS="="} $1==k {print k "=" v; next} {print}' \ + .env > .env.tmp && mv .env.tmp .env + else + echo "${key}=${value}" >> .env + fi +} +upsert_env SLACK_BOT_TOKEN "$SLACK_BOT_TOKEN" +upsert_env SLACK_SIGNING_SECRET "$SLACK_SIGNING_SECRET" + +# Container reads from data/env/env (the host mounts it). +mkdir -p data/env +cp .env data/env/env + +log "Restarting service so the new adapter picks up the credentials…" +# shellcheck source=setup/lib/install-slug.sh +source "$PROJECT_ROOT/setup/lib/install-slug.sh" +case "$(uname -s)" in + Darwin) + launchctl kickstart -k "gui/$(id -u)/$(launchd_label)" >&2 2>/dev/null || true + ;; + Linux) + systemctl --user restart "$(systemd_unit)" >&2 2>/dev/null \ + || sudo systemctl restart "$(systemd_unit)" >&2 2>/dev/null \ + || true + ;; +esac + +# Give the Slack adapter a moment to finish starting the webhook listener +# before emitting success. +sleep 3 + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4becf6ec6..4c2026227 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -27,6 +27,8 @@ import * as p from '@clack/prompts'; import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; +import { runIMessageChannel } from './channels/imessage.js'; +import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; import { runWhatsAppChannel } from './channels/whatsapp.js'; @@ -48,6 +50,15 @@ import { isValidTimezone } from '../src/timezone.js'; const CLI_AGENT_NAME = 'Terminal Agent'; const RUN_START = Date.now(); +type ChannelChoice = + | 'telegram' + | 'discord' + | 'whatsapp' + | 'teams' + | 'slack' + | 'imessage' + | 'skip'; + async function main(): Promise { printIntro(); initProgressionLog(); @@ -295,8 +306,7 @@ async function main(): Promise { await runTimezoneStep(); } - let channelChoice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' = - 'skip'; + let channelChoice: ChannelChoice = 'skip'; if (!skip.has('channel')) { channelChoice = await askChannelChoice(); if (channelChoice === 'telegram') { @@ -307,10 +317,14 @@ async function main(): Promise { await runWhatsAppChannel(displayName!); } else if (channelChoice === 'teams') { await runTeamsChannel(displayName!); + } else if (channelChoice === 'slack') { + await runSlackChannel(displayName!); + } else if (channelChoice === 'imessage') { + await runIMessageChannel(displayName!); } else { p.log.info( wrapForGutter( - 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, or Slack).', + 'No messaging app for now. You can add one later (like Telegram, Discord, WhatsApp, Teams, Slack, or iMessage).', 4, ), ); @@ -420,9 +434,7 @@ async function main(): Promise { } } -function channelDmLabel( - choice: 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip', -): string | null { +function channelDmLabel(choice: ChannelChoice): string | null { switch (choice) { case 'telegram': return 'Telegram'; @@ -432,6 +444,13 @@ function channelDmLabel( return 'WhatsApp'; case 'teams': return 'Teams'; + case 'imessage': + return 'iMessage'; + case 'slack': + // Slack install doesn't wire an agent or send a welcome DM — the + // driver prints its own "finish in your Slack app" note. Falling + // through to null avoids a misleading "check your Slack DMs" banner. + return null; default: return null; } @@ -807,16 +826,25 @@ async function askDisplayName(fallback: string): Promise { return value; } -async function askChannelChoice(): Promise< - 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip' -> { +async function askChannelChoice(): Promise { + const isMac = process.platform === 'darwin'; const choice = ensureAnswer( - await brightSelect({ + await brightSelect({ message: 'Want to chat with your assistant from your phone?', options: [ { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'imessage', + label: 'Yes, connect iMessage (experimental)', + hint: isMac ? 'local macOS mode' : 'remote Photon only', + }, + { + value: 'slack', + label: 'Yes, connect Slack (experimental)', + hint: 'needs public URL', + }, { value: 'teams', label: 'Yes, connect Microsoft Teams', hint: 'complex setup' }, { value: 'skip', label: 'Skip for now', hint: "I'll just use the terminal" }, ], @@ -824,7 +852,7 @@ async function askChannelChoice(): Promise< ); setupLog.userInput('channel_choice', String(choice)); phEmit('channel_chosen', { channel: String(choice) }); - return choice as 'telegram' | 'discord' | 'whatsapp' | 'teams' | 'skip'; + return choice; } // ─── interactive / env helpers ───────────────────────────────────────── diff --git a/setup/channels/imessage.ts b/setup/channels/imessage.ts new file mode 100644 index 000000000..d8b129fa8 --- /dev/null +++ b/setup/channels/imessage.ts @@ -0,0 +1,314 @@ +/** + * iMessage channel flow for setup:auto. + * + * `runIMessageChannel(displayName)` covers both deployment modes: + * + * Local (macOS): the bot runs on this Mac and talks via the signed-in + * iMessage account. Reading chat.db needs Full Disk Access granted to + * the Node binary — we open the directory for them so they can drag + * the `node` file into System Settings. + * + * Remote (Photon API): the bot talks to a separate server (Photon) + * that owns an iMessage account on another Mac. Used when this host + * is Linux, or when the operator wants to keep their daily-driver + * Mac's chat history out of the loop. + * + * Flow: + * 1. Pick mode (auto-defaults to local on macOS, remote elsewhere) + * 2. Local: FDA walkthrough (open node bin directory, wait for ack) + * Remote: prompt for Photon server URL + API key + * 3. Ask for the phone or email the operator messages from — this is + * the platform-id for first-agent wiring + * 4. Install the adapter (setup/add-imessage.sh, non-interactive) + * 5. Wire the agent via scripts/init-first-agent.ts — the welcome + * iMessage goes out through the normal delivery path + * + * All output obeys the three-level contract. See docs/setup-flow.md. + */ +import { execSync } from 'child_process'; +import os from 'os'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { brightSelect } from '../lib/bright-select.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { wrapForGutter } from '../lib/theme.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +type Mode = 'local' | 'remote'; + +interface RemoteCreds { + serverUrl: string; + apiKey: string; +} + +export async function runIMessageChannel(displayName: string): Promise { + const isMac = os.platform() === 'darwin'; + + const mode = await askMode(isMac); + let remoteCreds: RemoteCreds | null = null; + + if (mode === 'local') { + if (!isMac) { + await fail( + 'imessage', + "Local iMessage mode only works on macOS.", + 'Choose remote mode (Photon API) on Linux/WSL, or run setup from your Mac.', + ); + } + await walkThroughFullDiskAccess(); + } else { + remoteCreds = await collectRemoteCreds(); + } + + const handle = await askOperatorHandle(); + + const install = await runQuietChild( + 'imessage-install', + 'bash', + ['setup/add-imessage.sh'], + { + running: + mode === 'local' + ? "Connecting the iMessage adapter to this Mac…" + : `Connecting the iMessage adapter to ${remoteCreds!.serverUrl}…`, + done: 'iMessage adapter installed.', + }, + { + env: + mode === 'local' + ? { IMESSAGE_LOCAL: 'true', IMESSAGE_ENABLED: 'true' } + : { + IMESSAGE_LOCAL: 'false', + IMESSAGE_SERVER_URL: remoteCreds!.serverUrl, + IMESSAGE_API_KEY: remoteCreds!.apiKey, + }, + extraFields: { MODE: mode }, + }, + ); + if (!install.ok) { + await fail( + 'imessage-install', + "Couldn't install the iMessage adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const role = await askOperatorRole('iMessage'); + setupLog.userInput('imessage_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'imessage', + '--user-id', handle, + '--platform-id', handle, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to iMessage…`, + done: `${agentName} is ready. Check iMessage for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'imessage', + AGENT_NAME: agentName, + PLATFORM_ID: handle, + MODE: mode, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'Double-check Full Disk Access (local mode) or Photon credentials (remote), then retry.', + ); + } +} + +async function askMode(isMac: boolean): Promise { + const choice = ensureAnswer( + 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', + }, + ], + }), + ); + setupLog.userInput('imessage_mode', String(choice)); + return choice; +} + +/** + * Grant Full Disk Access to the Node binary the host runs under — without + * it, the adapter can't read chat.db and inbound messages never arrive. + * Opening the containing directory in Finder makes the drag-and-drop + * target obvious; falling back to printing the path keeps us working in + * SSH/headless contexts where `open` is a no-op. + */ +async function walkThroughFullDiskAccess(): Promise { + let nodePath = process.execPath; + try { + // `which node` picks up the user's shell-resolved node, which may differ + // from process.execPath (e.g. they launched setup under a different + // Node via `nvm`). If it succeeds and is resolvable, prefer it. + const which = execSync('which node', { encoding: 'utf-8' }).trim(); + if (which) nodePath = which; + } catch { + // fall back to process.execPath + } + const nodeDir = path.dirname(nodePath); + + p.note( + wrapForGutter( + [ + `iMessage needs Full Disk Access granted to the Node binary:`, + '', + ` ${nodePath}`, + '', + ' 1. System Settings → Privacy & Security → Full Disk Access', + ` 2. Click +, then drag the "node" file from the Finder window`, + ' we just opened for you', + ' 3. Toggle it on, then come back here', + ].join('\n'), + 6, + ), + 'Grant Full Disk Access', + ); + + try { + execSync(`open "${nodeDir}"`, { stdio: 'ignore' }); + } catch { + // No Finder (SSH/headless) — user sees the path in the note above. + } + + ensureAnswer( + await p.confirm({ + message: "Granted Full Disk Access?", + initialValue: true, + }), + ); + setupLog.userInput('imessage_fda_confirmed', 'true'); +} + +async function collectRemoteCreds(): Promise { + p.note( + [ + "Photon is a separate service that owns an iMessage account and", + "exposes it over HTTP. NanoClaw will talk to it via its API.", + '', + ' 1. Set up a Photon server: https://photon.im', + ' 2. Copy the server URL and API key from your Photon dashboard', + ].join('\n'), + 'Remote iMessage via Photon', + ); + + const urlAnswer = ensureAnswer( + await p.text({ + message: 'Photon server URL', + placeholder: 'https://photon.example.com', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'URL is required'; + if (!/^https?:\/\//i.test(t)) return 'Must start with http:// or https://'; + return undefined; + }, + }), + ); + const serverUrl = (urlAnswer as string).trim(); + + const keyAnswer = ensureAnswer( + await p.password({ + message: 'Photon API key', + validate: (v) => ((v ?? '').trim() ? undefined : 'API key is required'), + }), + ); + const apiKey = (keyAnswer as string).trim(); + + setupLog.userInput('imessage_server_url', serverUrl); + setupLog.userInput( + 'imessage_api_key', + `${apiKey.slice(0, 4)}…${apiKey.slice(-4)}`, + ); + return { serverUrl, apiKey }; +} + +async function askOperatorHandle(): Promise { + p.note( + [ + "What phone number or email do you iMessage with?", + "That's where your assistant will send its welcome message.", + '', + k.dim(' • Phone: full E.164, e.g. +15551234567'), + k.dim(' • Email: whatever iMessage recognises (Apple ID, iCloud alias, …)'), + ].join('\n'), + 'Your iMessage handle', + ); + + const answer = ensureAnswer( + await p.text({ + message: 'Phone number or email', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Required'; + const isPhone = /^\+\d{8,15}$/.test(t); + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(t); + if (!isPhone && !isEmail) { + return "Use a +E.164 phone number or an email address"; + } + return undefined; + }, + }), + ); + const handle = (answer as string).trim(); + setupLog.userInput('imessage_handle', handle); + return handle; +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/channels/slack.ts b/setup/channels/slack.ts new file mode 100644 index 000000000..f66c29afb --- /dev/null +++ b/setup/channels/slack.ts @@ -0,0 +1,249 @@ +/** + * Slack channel flow for setup:auto. + * + * `runSlackChannel(displayName)` walks the operator from a bare Slack + * workspace through a running bot, then stops before wiring an agent: + * + * 1. Walk through creating a Slack app (api.slack.com/apps) — scopes, + * event subscriptions, and signing secret + * 2. Paste the bot token + signing secret (clack password prompts) + * 3. Validate via auth.test → resolves workspace + bot identity + * 4. Install the adapter (setup/add-slack.sh, non-interactive) + * 5. Print the post-install checklist: set the public webhook URL in + * Slack's Event Subscriptions, DM the bot to bootstrap the channel, + * then `/manage-channels` to wire an agent. + * + * Why no welcome DM here: unlike Discord/Telegram (gateway / long-poll), + * Slack needs a public Event Subscriptions URL for inbound events, and + * opening an unsolicited DM would need `im:write` scope we don't force + * the SKILL.md to require. Shipping a honest "here's what's left" note + * is better than a welcome DM the user won't receive until they + * configure the webhook anyway. + * + * All output obeys the three-level contract. See docs/setup-flow.md. + */ +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { confirmThenOpen } from '../lib/browser.js'; +import { ensureAnswer, fail, runQuietChild } from '../lib/runner.js'; +import { wrapForGutter } from '../lib/theme.js'; + +const SLACK_API = 'https://slack.com/api'; +const SLACK_APPS_URL = 'https://api.slack.com/apps'; + +interface WorkspaceInfo { + teamName: string; + teamId: string; + botName: string; + botUserId: string; +} + +// displayName is reserved for when we start wiring the first agent here. +// Kept to match the `runChannel(displayName)` signature every other +// channel driver uses, so auto.ts can dispatch without a branch. +export async function runSlackChannel(_displayName: string): Promise { + await walkThroughAppCreation(); + + const token = await collectBotToken(); + const signingSecret = await collectSigningSecret(); + const info = await validateSlackToken(token); + + const install = await runQuietChild( + 'slack-install', + 'bash', + ['setup/add-slack.sh'], + { + running: `Connecting Slack to @${info.botName} (${info.teamName})…`, + done: 'Slack adapter installed.', + }, + { + env: { + SLACK_BOT_TOKEN: token, + SLACK_SIGNING_SECRET: signingSecret, + }, + extraFields: { + BOT_NAME: info.botName, + TEAM_NAME: info.teamName, + TEAM_ID: info.teamId, + }, + }, + ); + if (!install.ok) { + await fail( + 'slack-install', + "Couldn't connect Slack.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + showPostInstallChecklist(info); +} + +async function walkThroughAppCreation(): Promise { + p.note( + [ + "You'll create a Slack app that the assistant talks through.", + "Free and stays inside the workspaces you pick.", + '', + ' 1. Create a new app "From scratch", name it, pick a workspace', + ' 2. OAuth & Permissions → add Bot Token Scopes:', + ' chat:write, channels:history, groups:history, im:history,', + ' channels:read, groups:read, users:read, reactions:write', + ' 3. App Home → enable "Messages Tab" and "Allow users to send', + ' slash commands and messages from the messages tab"', + ' 4. Basic Information → copy the "Signing Secret"', + ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', + '', + k.dim(SLACK_APPS_URL), + ].join('\n'), + 'Create a Slack app', + ); + await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); + + ensureAnswer( + await p.confirm({ + message: 'Got your bot token and signing secret?', + initialValue: true, + }), + ); +} + +async function collectBotToken(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack bot token', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Token is required'; + if (!t.startsWith('xoxb-')) return 'Bot tokens start with xoxb-'; + if (t.length < 24) return "That's shorter than a real Slack bot token"; + return undefined; + }, + }), + ); + const token = (answer as string).trim(); + setupLog.userInput( + 'slack_bot_token', + `${token.slice(0, 10)}…${token.slice(-4)}`, + ); + return token; +} + +async function collectSigningSecret(): Promise { + const answer = ensureAnswer( + await p.password({ + message: 'Paste your Slack signing secret', + validate: (v) => { + const t = (v ?? '').trim(); + if (!t) return 'Signing secret is required'; + // Slack signing secrets are 32-char hex strings, but newer apps + // sometimes emit longer variants — leniently require hex only. + if (!/^[a-f0-9]{16,}$/i.test(t)) { + return 'Signing secrets are a string of hex characters'; + } + return undefined; + }, + }), + ); + const secret = (answer as string).trim(); + setupLog.userInput( + 'slack_signing_secret', + `${secret.slice(0, 4)}…${secret.slice(-4)}`, + ); + return secret; +} + +async function validateSlackToken(token: string): Promise { + const s = p.spinner(); + const start = Date.now(); + s.start('Checking your bot token…'); + try { + const res = await fetch(`${SLACK_API}/auth.test`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + const data = (await res.json()) as { + ok?: boolean; + team?: string; + team_id?: string; + user?: string; + user_id?: string; + error?: string; + }; + const elapsedS = Math.round((Date.now() - start) / 1000); + if (data.ok && data.team && data.user) { + s.stop( + `Connected to ${data.team} as @${data.user}. ${k.dim(`(${elapsedS}s)`)}`, + ); + const info: WorkspaceInfo = { + teamName: data.team, + teamId: data.team_id ?? '', + botName: data.user, + botUserId: data.user_id ?? '', + }; + setupLog.step('slack-validate', 'success', Date.now() - start, { + BOT_NAME: info.botName, + BOT_USER_ID: info.botUserId, + TEAM_NAME: info.teamName, + TEAM_ID: info.teamId, + }); + return info; + } + const reason = data.error ?? `HTTP ${res.status}`; + s.stop(`Slack didn't accept that token: ${reason}`, 1); + setupLog.step('slack-validate', 'failed', Date.now() - start, { + ERROR: reason, + }); + await fail( + 'slack-validate', + "Slack didn't accept that token.", + reason === 'invalid_auth' || reason === 'token_revoked' + ? 'Copy the token again from OAuth & Permissions and retry setup.' + : `Slack said "${reason}". Check the token scopes and workspace install, then retry.`, + ); + } catch (err) { + const elapsedS = Math.round((Date.now() - start) / 1000); + s.stop(`Couldn't reach Slack. ${k.dim(`(${elapsedS}s)`)}`, 1); + const message = err instanceof Error ? err.message : String(err); + setupLog.step('slack-validate', 'failed', Date.now() - start, { + ERROR: message, + }); + await fail( + 'slack-validate', + "Couldn't reach Slack.", + 'Check your internet connection and retry setup.', + ); + } +} + +function showPostInstallChecklist(info: WorkspaceInfo): void { + p.note( + wrapForGutter( + [ + `The Slack adapter is installed and your creds are saved. ${info.teamName} still needs two things before it can talk to you:`, + '', + ' 1. A public URL so Slack can deliver events.', + ' NanoClaw serves a webhook on port 3000 by default — expose it', + ' via ngrok, Cloudflare Tunnel, or a reverse proxy on a VPS.', + '', + ' 2. In your Slack app → Event Subscriptions:', + ' • Toggle "Enable Events" on', + ` • Request URL: https:///webhook/slack`, + ' • Subscribe to bot events: message.channels, message.groups,', + ' message.im, app_mention', + ' • Save, then reinstall the app when Slack prompts', + '', + ` 3. DM @${info.botName} from Slack once — that bootstraps the`, + ' messaging group. Then run `/manage-channels` in `claude` to', + ' wire an agent to it.', + ].join('\n'), + 6, + ), + 'Finish setting up Slack', + ); +} diff --git a/setup/lib/claude-assist.ts b/setup/lib/claude-assist.ts index c2b03677a..1651a9c12 100644 --- a/setup/lib/claude-assist.ts +++ b/setup/lib/claude-assist.ts @@ -64,6 +64,10 @@ const STEP_FILES: Record = { 'telegram-validate': ['setup/channels/telegram.ts'], 'pair-telegram': ['setup/pair-telegram.ts', 'setup/channels/telegram.ts'], 'discord-install': ['setup/add-discord.sh', 'setup/channels/discord.ts'], + 'slack-install': ['setup/add-slack.sh', 'setup/channels/slack.ts'], + 'slack-validate': ['setup/channels/slack.ts'], + 'imessage-install': ['setup/add-imessage.sh', 'setup/channels/imessage.ts'], + 'imessage': ['setup/channels/imessage.ts'], 'teams-install': ['setup/add-teams.sh', 'setup/channels/teams.ts'], 'teams-manifest': ['setup/lib/teams-manifest.ts', 'setup/channels/teams.ts'], 'init-first-agent': [ From 61ca43d19313e92631674a208b14c321bd26584b Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:12 +0000 Subject: [PATCH 04/31] fix(discord): resolve user ID from DM interactions for approval clicks Discord puts the clicking user at interaction.member.user for guild interactions but interaction.user for DM interactions. The Gateway handler only checked interaction.member, so DM button clicks resolved to an empty user ID and were silently rejected as unauthorized. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/chat-sdk-bridge.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e074..6c9f80213 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -105,7 +105,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter let setupConfig: ChannelSetup; let gatewayAbort: AbortController | null = null; - async function messageToInbound(message: ChatMessage, isMention: boolean): Promise { + async function messageToInbound(message: ChatMessage, isMention: boolean, isGroup?: boolean): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const serialized = message.toJSON() as Record; @@ -162,6 +162,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter content: serialized, timestamp: message.metadata.dateSent.toISOString(), isMention, + isGroup, }; } @@ -195,13 +196,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // wirings still fire on in-thread mentions. chat.onSubscribedMessage(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, message.isMention === true, true)); }); // @mention in an unsubscribed thread — SDK-confirmed bot mention. chat.onNewMention(async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, true)); }); // DMs — by definition addressed to the bot. Thread id flows through @@ -216,7 +217,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter sender: (message.author as any)?.fullName ?? (message.author as any)?.userId ?? 'unknown', threadId: thread.id, }); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, true, false)); }); // Plain messages in unsubscribed threads. @@ -231,7 +232,7 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter // flood gate. chat.onNewMessage(/./, async (thread, message) => { const channelId = adapter.channelIdFromThreadId(thread.id); - await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false)); + await setupConfig.onInbound(channelId, thread.id, await messageToInbound(message, false, true)); }); // Handle button clicks (ask_user_question) @@ -501,7 +502,10 @@ async function handleForwardedEvent( // type 3 = MessageComponent (button/select) if (interaction.type === 3) { const customId = (interaction.data as Record)?.custom_id as string; - const user = (interaction.member as Record)?.user as Record | undefined; + // In guilds the clicker is at interaction.member.user; in DMs it's interaction.user directly. + const user = + ((interaction.member as Record)?.user as Record | undefined) ?? + (interaction.user as Record | undefined); const interactionId = interaction.id as string; const interactionToken = interaction.token as string; From d121cd1cd6b12a764ae79be479bc8d7950fddea4 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:23 +0000 Subject: [PATCH 05/31] fix(router): pass isGroup from adapter through to messaging group creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router hardcoded is_group=0 when auto-creating messaging groups, causing channel mentions to be misclassified as DMs. The Chat SDK bridge knows which handler fired (onDirectMessage vs onNewMention) so thread the signal through InboundMessage → InboundEvent → router. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/channels/adapter.ts | 4 ++++ src/index.ts | 1 + src/router.ts | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/channels/adapter.ts b/src/channels/adapter.ts index d8d8f9d7b..82247a116 100644 --- a/src/channels/adapter.ts +++ b/src/channels/adapter.ts @@ -56,6 +56,8 @@ export interface InboundEvent { * See InboundMessage.isMention for the full explanation. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; }; replyTo?: DeliveryAddress; } @@ -81,6 +83,8 @@ export interface InboundMessage { * router falls back to text-match against agent_group_name. */ isMention?: boolean; + /** True when the source is a group/channel thread, false for DMs. */ + isGroup?: boolean; } /** A file attachment to deliver alongside a message. */ diff --git a/src/index.ts b/src/index.ts index d3de4d981..ea9fba63c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -85,6 +85,7 @@ async function main(): Promise { content: JSON.stringify(message.content), timestamp: message.timestamp, isMention: message.isMention, + isGroup: message.isGroup, }, }).catch((err) => { log.error('Failed to route inbound message', { channelType: adapter.channelType, err }); diff --git a/src/router.ts b/src/router.ts index 538c270a7..3cf0192df 100644 --- a/src/router.ts +++ b/src/router.ts @@ -170,7 +170,7 @@ export async function routeInbound(event: InboundEvent): Promise { channel_type: event.channelType, platform_id: event.platformId, name: null, - is_group: 0, + is_group: event.message.isGroup ? 1 : 0, unknown_sender_policy: 'request_approval', denied_at: null, created_at: new Date().toISOString(), From 15f30682d79a4fd2721990ee6b126178da41afc0 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:34 +0000 Subject: [PATCH 06/31] fix(approvals): show human-readable names in approval cards Channel and sender approval cards showed raw platform IDs (e.g. discord:1475578393738219540:...) instead of readable context. Extract sender name from the event content for channel approvals, and use the channel type name for sender approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/permissions/channel-approval.ts | 20 ++++++++++++++++---- src/modules/permissions/sender-approval.ts | 2 +- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index caef81563..e4b2142d7 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -101,13 +101,25 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) return; } - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; - const isGroup = originMg?.is_group === 1; + const isGroup = event.message?.isGroup ?? originMg?.is_group === 1; + + // Extract sender name from the event content for a human-readable card. + let senderName: string | undefined; + try { + const parsed = JSON.parse(event.message.content) as Record; + senderName = (parsed.senderName ?? parsed.sender) as string | undefined; + } catch { + // non-critical — fall through to generic wording + } const title = isGroup ? '📣 Bot mentioned in new chat' : '💬 New direct message'; const question = isGroup - ? `Your agent was mentioned in ${originName} on ${originChannelType}. Wire it to ${target.name} and let it engage?` - : `Someone DM'd your agent on ${originChannelType} (${originName}). Wire it to ${target.name} and let it respond?`; + ? senderName + ? `${senderName} mentioned your agent in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : `Your agent was mentioned in a ${originChannelType} channel. Wire it to ${target.name} and let it engage?` + : senderName + ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` + : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; createPendingChannelApproval({ messaging_group_id: messagingGroupId, diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index e08123ac1..a20e14f3e 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -88,7 +88,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const approvalId = generateId(); const senderDisplay = senderName && senderName.length > 0 ? senderName : senderIdentity; - const originName = originMg?.name ?? originMg?.platform_id ?? 'an unfamiliar chat'; + const originName = originMg?.name ?? `a ${originChannelType} channel`; const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; From 40f5683c3660d8f63982bfcb9f29f1157607a12e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 12:23:45 +0000 Subject: [PATCH 07/31] fix(approvals): show correct post-click labels on channel/sender cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender only checked pending_questions and pending_approvals, missing the channel and sender approval tables. Approval button clicks showed the raw value ("approve") instead of the selectedLabel ("✅ Wired"). Extend the lookup to also check pending_channel_approvals and pending_sender_approvals. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/db/sessions.ts | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a66e..e9461ca18 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -1,5 +1,5 @@ import type { PendingApproval, PendingQuestion, Session } from '../types.js'; -import { getDb } from './connection.js'; +import { getDb, hasTable } from './connection.js'; // ── Sessions ── @@ -192,6 +192,35 @@ export function getAskQuestionRender( const a = getDb().prepare('SELECT title, options_json FROM pending_approvals WHERE approval_id = ?').get(id) as | { title: string; options_json: string } | undefined; - if (!a || !a.title) return undefined; - return { title: a.title, options: JSON.parse(a.options_json) }; + if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; + + // Channel-registration approval — options are fixed constants. + if (hasTable(getDb(), 'pending_channel_approvals')) { + const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); + if (c) { + return { + title: '📣 Channel registration', + options: [ + { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, + { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, + ], + }; + } + } + + // Unknown-sender approval — options are fixed constants. + if (hasTable(getDb(), 'pending_sender_approvals')) { + const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); + if (s) { + return { + title: '👤 New sender', + options: [ + { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, + { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, + ], + }; + } + } + + return undefined; } From 3a9b98f1a46261e97137ca0ffaa784102dee30fa Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Thu, 23 Apr 2026 16:18:34 +0300 Subject: [PATCH 08/31] feat: add Atomic Chat MCP tool skill Exposes local Atomic Chat models (OpenAI-compatible API at 127.0.0.1:1337/v1) as tools to the container agent. Adds atomic_chat_list_models and atomic_chat_generate alongside the existing Ollama skill. Rebased on current main: - MCP server registered in agent-runner index.ts using bun (no tsc step in-image), sibling path to index.ts, env: {} with ATOMIC_CHAT_* forwarded when set. - allowedTools entry moved to providers/claude.ts TOOL_ALLOWLIST. - SKILL.md: drop obsolete per-group copy step (single RO mount supersedes it); use pnpm build. Made-with: Cursor Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-atomic-chat-tool/SKILL.md | 154 ++++++++++++ .env.example | 7 + .../agent-runner/src/atomic-chat-mcp-stdio.ts | 229 ++++++++++++++++++ container/agent-runner/src/index.ts | 8 + .../agent-runner/src/providers/claude.ts | 1 + src/container-runner.ts | 15 +- 6 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/add-atomic-chat-tool/SKILL.md create mode 100644 container/agent-runner/src/atomic-chat-mcp-stdio.ts diff --git a/.claude/skills/add-atomic-chat-tool/SKILL.md b/.claude/skills/add-atomic-chat-tool/SKILL.md new file mode 100644 index 000000000..d99551989 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -0,0 +1,154 @@ +--- +name: add-atomic-chat-tool +description: Add Atomic Chat MCP server so the container agent can call local models served by the Atomic Chat desktop app via its OpenAI-compatible API. +--- + +# Add Atomic Chat Integration + +This skill adds a stdio-based MCP server that exposes models running in the local [Atomic Chat](https://github.com/AtomicBot-ai/Atomic-Chat) desktop app as tools for the container agent. Claude remains the orchestrator but can offload work to local models served by Atomic Chat on `http://127.0.0.1:1337/v1` (OpenAI-compatible). + +Tools exposed: +- `atomic_chat_list_models` — list models currently available in Atomic Chat (`GET /v1/models`) +- `atomic_chat_generate` — send a prompt to a specified model and return the response (`POST /v1/chat/completions`) + +Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library. + +## Phase 1: Pre-flight + +### Check if already applied + +Check if `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists. If it does, skip to Phase 3 (Configure). + +### Check prerequisites + +Verify Atomic Chat is installed and its local API server is running. On the host: + +```bash +curl -s http://127.0.0.1:1337/v1/models | head +``` + +If the request fails: + +1. Install Atomic Chat from the [latest release](https://github.com/AtomicBot-ai/Atomic-Chat/releases) (macOS only for now — `atomic-chat.dmg`). +2. Open the app. +3. Open **Settings → Local API Server** and make sure it's enabled on port `1337`. +4. Go to the **Hub** (or **Models**) tab and download at least one model (e.g. Llama 3.2 3B, Qwen 2.5 Coder 7B). +5. Load the model once by sending any message in Atomic Chat's UI to warm it up. + +## Phase 2: Apply Code Changes + +### Ensure upstream remote + +```bash +git remote -v +``` + +If `upstream` is missing, add it: + +```bash +git remote add upstream https://github.com/qwibitai/nanoclaw.git +``` + +### Merge the skill branch + +```bash +git fetch upstream skill/atomic-chat-tool +git merge upstream/skill/atomic-chat-tool +``` + +This merges in: +- `container/agent-runner/src/atomic-chat-mcp-stdio.ts` (Atomic Chat MCP server, run directly via `bun`) +- Atomic Chat MCP registration in `container/agent-runner/src/index.ts` (`mcpServers.atomic_chat`) +- `mcp__atomic_chat__*` added to `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts` +- `[ATOMIC]` log surfacing and `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` forwarding in `src/container-runner.ts` +- `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` stubs in `.env.example` + +If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. + +### Validate code changes + +```bash +pnpm run build +./container/build.sh +``` + +Build must be clean before proceeding. + +## Phase 3: Configure + +### Set Atomic Chat host (optional) + +By default, the MCP server connects to `http://host.docker.internal:1337` (Docker Desktop) with a fallback to `localhost`. To use a custom host, add to `.env`: + +```bash +ATOMIC_CHAT_HOST=http://your-atomic-chat-host:1337 +``` + +### Set API key (optional) + +Atomic Chat does **not require authentication** when running locally — leave this unset. Only set it if you've put Atomic Chat behind a reverse proxy that enforces auth: + +```bash +ATOMIC_CHAT_API_KEY=sk-... +``` + +### Restart the service + +```bash +launchctl kickstart -k gui/$(id -u)/com.nanoclaw # macOS +# Linux: systemctl --user restart nanoclaw +``` + +## Phase 4: Verify + +### Test inference + +Tell the user: + +> Send a message like: "use atomic chat to tell me the capital of France" +> +> The agent should use `atomic_chat_list_models` to find available models, then `atomic_chat_generate` to get a response. + +### Check logs if needed + +```bash +tail -f logs/nanoclaw.log | grep -i atomic +``` + +Look for: +- `[ATOMIC] Listing models...` — list request started +- `[ATOMIC] Found N models` — models discovered +- `[ATOMIC] >>> Generating with ` — generation started +- `[ATOMIC] <<< Done: | Xs | N tokens | M chars` — generation completed + +## Troubleshooting + +### Agent says "Atomic Chat is not installed" or tries to run a CLI + +The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means: +1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` +2. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` +3. The container wasn't rebuilt — run `./container/build.sh` + +### "Failed to connect to Atomic Chat" + +1. Verify the host API is reachable: `curl http://127.0.0.1:1337/v1/models` +2. Confirm the Local API Server is enabled in Atomic Chat's settings +3. Check Docker can reach the host: `docker run --rm curlimages/curl curl -s http://host.docker.internal:1337/v1/models` +4. If using a custom host, check `ATOMIC_CHAT_HOST` in `.env` + +### `model not found` / 404 on generate + +The model ID passed to `atomic_chat_generate` must exactly match one of the IDs returned by `atomic_chat_list_models`. Ask the agent to list models first, then pick one from that list. + +### Slow first response + +Atomic Chat lazy-loads models into memory on first use. The initial call may take longer while the model warms up. Subsequent calls against the same model are fast. + +### Agent doesn't use Atomic Chat tools + +The agent may not know about the tools. Try being explicit: "use the atomic_chat_generate tool with llama3.2-3b-instruct to answer: ..." + +### Context window or output size issues + +Atomic Chat respects each model's native context length. If you hit limits, pass `max_tokens` explicitly when calling `atomic_chat_generate`, or switch to a model with a larger context window in the Atomic Chat UI. diff --git a/.env.example b/.env.example index e69de29bb..61f2074ee 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,7 @@ +# Atomic Chat MCP tool (skill/atomic-chat-tool) +# Override the host where Atomic Chat exposes its OpenAI-compatible API. +# Default: http://host.docker.internal:1337 (with fallback to localhost) +# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 + +# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. +# ATOMIC_CHAT_API_KEY= diff --git a/container/agent-runner/src/atomic-chat-mcp-stdio.ts b/container/agent-runner/src/atomic-chat-mcp-stdio.ts new file mode 100644 index 000000000..019864420 --- /dev/null +++ b/container/agent-runner/src/atomic-chat-mcp-stdio.ts @@ -0,0 +1,229 @@ +/** + * Atomic Chat MCP Server for NanoClaw + * Exposes local Atomic Chat models (OpenAI-compatible, /v1) as tools for the container agent. + * Uses host.docker.internal to reach the host's Atomic Chat desktop app from Docker. + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +import fs from 'fs'; +import path from 'path'; + +const ATOMIC_CHAT_HOST = + process.env.ATOMIC_CHAT_HOST || 'http://host.docker.internal:1337'; +const ATOMIC_CHAT_API_KEY = process.env.ATOMIC_CHAT_API_KEY || ''; +const ATOMIC_CHAT_STATUS_FILE = '/workspace/ipc/atomic_chat_status.json'; + +function log(msg: string): void { + console.error(`[ATOMIC] ${msg}`); +} + +function writeStatus(status: string, detail?: string): void { + try { + const data = { status, detail, timestamp: new Date().toISOString() }; + const tmpPath = `${ATOMIC_CHAT_STATUS_FILE}.tmp`; + fs.mkdirSync(path.dirname(ATOMIC_CHAT_STATUS_FILE), { recursive: true }); + fs.writeFileSync(tmpPath, JSON.stringify(data)); + fs.renameSync(tmpPath, ATOMIC_CHAT_STATUS_FILE); + } catch { + /* best-effort */ + } +} + +async function atomicFetch( + apiPath: string, + options?: RequestInit, +): Promise { + const url = `${ATOMIC_CHAT_HOST}${apiPath}`; + const headers: Record = { + ...((options?.headers as Record) || {}), + }; + if (ATOMIC_CHAT_API_KEY) { + headers.Authorization = `Bearer ${ATOMIC_CHAT_API_KEY}`; + } + const finalOptions: RequestInit = { ...options, headers }; + try { + return await fetch(url, finalOptions); + } catch (err) { + // Fallback to localhost if host.docker.internal fails + if (ATOMIC_CHAT_HOST.includes('host.docker.internal')) { + const fallbackUrl = url.replace('host.docker.internal', 'localhost'); + return await fetch(fallbackUrl, finalOptions); + } + throw err; + } +} + +const server = new McpServer({ + name: 'atomic_chat', + version: '1.0.0', +}); + +server.tool( + 'atomic_chat_list_models', + 'List all models available in the local Atomic Chat desktop app. Use this to see which models are loaded before calling atomic_chat_generate.', + {}, + async () => { + log('Listing models...'); + writeStatus('listing', 'Listing available models'); + try { + const res = await atomicFetch('/v1/models'); + if (!res.ok) { + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat API error: ${res.status} ${res.statusText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + data?: Array<{ id: string; owned_by?: string }>; + }; + const models = data.data || []; + + if (models.length === 0) { + return { + content: [ + { + type: 'text' as const, + text: 'No models available. Open Atomic Chat on the host and download a model from the Hub.', + }, + ], + }; + } + + const list = models + .map((m) => `- ${m.id}${m.owned_by ? ` (${m.owned_by})` : ''}`) + .join('\n'); + + log(`Found ${models.length} models`); + return { + content: [ + { type: 'text' as const, text: `Available models:\n${list}` }, + ], + }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to connect to Atomic Chat at ${ATOMIC_CHAT_HOST}: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +server.tool( + 'atomic_chat_generate', + 'Send a prompt to a local Atomic Chat model and get a response. Good for cheaper/faster tasks like summarization, translation, or general queries. Use atomic_chat_list_models first to see available models.', + { + model: z + .string() + .describe( + 'The model ID as returned by atomic_chat_list_models (e.g. "llama3.2-3b-instruct")', + ), + prompt: z.string().describe('The prompt to send to the model'), + system: z + .string() + .optional() + .describe('Optional system prompt to set model behavior'), + temperature: z + .number() + .optional() + .describe('Sampling temperature (0.0–2.0). Defaults to model default.'), + max_tokens: z + .number() + .optional() + .describe('Maximum number of tokens to generate in the response.'), + }, + async (args) => { + log(`>>> Generating with ${args.model} (${args.prompt.length} chars)...`); + writeStatus('generating', `Generating with ${args.model}`); + try { + const messages: Array<{ role: string; content: string }> = []; + if (args.system) { + messages.push({ role: 'system', content: args.system }); + } + messages.push({ role: 'user', content: args.prompt }); + + const body: Record = { + model: args.model, + messages, + stream: false, + }; + if (args.temperature !== undefined) body.temperature = args.temperature; + if (args.max_tokens !== undefined) body.max_tokens = args.max_tokens; + + const startedAt = Date.now(); + const res = await atomicFetch('/v1/chat/completions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + return { + content: [ + { + type: 'text' as const, + text: `Atomic Chat error (${res.status}): ${errorText}`, + }, + ], + isError: true, + }; + } + + const data = (await res.json()) as { + choices?: Array<{ message?: { content?: string } }>; + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; + + const response = data.choices?.[0]?.message?.content ?? ''; + const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1); + const completionTokens = data.usage?.completion_tokens; + + const meta = `\n\n[${args.model} | ${elapsedSec}s${ + completionTokens !== undefined ? ` | ${completionTokens} tokens` : '' + }]`; + + log( + `<<< Done: ${args.model} | ${elapsedSec}s | ${ + completionTokens ?? '?' + } tokens | ${response.length} chars`, + ); + writeStatus( + 'done', + `${args.model} | ${elapsedSec}s | ${completionTokens ?? '?'} tokens`, + ); + + return { content: [{ type: 'text' as const, text: response + meta }] }; + } catch (err) { + return { + content: [ + { + type: 'text' as const, + text: `Failed to call Atomic Chat: ${err instanceof Error ? err.message : String(err)}`, + }, + ], + isError: true, + }; + } + }, +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 236be4caf..2093f9af6 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -79,6 +79,14 @@ async function main(): Promise { args: ['run', mcpServerPath], env: {}, }, + atomic_chat: { + command: 'bun', + args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], + env: { + ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), + ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), + }, + }, }; for (const [name, serverConfig] of Object.entries(config.mcpServers)) { diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index fbb077c47..d633c0f30 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,6 +55,7 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', + 'mcp__atomic_chat__*', ]; interface SDKUserMessage { diff --git a/src/container-runner.ts b/src/container-runner.ts index 71e2064f3..d92f5acbb 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -139,7 +139,12 @@ async function spawnContainer(session: Session): Promise { // Log stderr container.stderr?.on('data', (data) => { for (const line of data.toString().trim().split('\n')) { - if (line) log.debug(line, { container: agentGroup.folder }); + if (!line) continue; + if (line.includes('[ATOMIC]')) { + log.info(line, { container: agentGroup.folder }); + } else { + log.debug(line, { container: agentGroup.folder }); + } } }); @@ -396,6 +401,14 @@ async function buildContainerArgs( // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); + // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). + if (process.env.ATOMIC_CHAT_HOST) { + args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); + } + if (process.env.ATOMIC_CHAT_API_KEY) { + args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); + } + // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { for (const [key, value] of Object.entries(providerContribution.env)) { From 97e356d243d7285b00bb2f5ca236349e9b98a02c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 13:21:49 +0000 Subject: [PATCH 09/31] chore: bump version to 2.0.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 09053c429..aa6375616 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.5", + "version": "2.0.6", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From dd5bc85b02656fdaf8304c91518da151b139204f Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 16:29:10 +0300 Subject: [PATCH 10/31] refactor(skill/atomic-chat-tool): ship MCP file in skill folder, revert src edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial /add-atomic-chat-tool merge added src edits directly to main. That conflicts with the utility-skill pattern used elsewhere (e.g. /claw): the skill folder should ship the file and SKILL.md should instruct copy + idempotent edits at install time, not a git merge that carries src diffs. - Move container/agent-runner/src/atomic-chat-mcp-stdio.ts → .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts - Revert the atomic_chat mcpServers entry in agent-runner index.ts - Revert mcp__atomic_chat__* from TOOL_ALLOWLIST in providers/claude.ts - Revert ATOMIC_CHAT_* env forwarding and [ATOMIC] log elevation in src/container-runner.ts - Empty .env.example back out - Rewrite SKILL.md: copy the shipped file, then apply deterministic Edits (index.ts, providers/claude.ts, container-runner.ts, .env.example) with exact before/after snippets the installer agent can match. Main is now back to its pre-PR state for the tool; /add-atomic-chat-tool re-applies everything at install time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-atomic-chat-tool/SKILL.md | 137 +++++++++++++++--- .../atomic-chat-mcp-stdio.ts | 0 .env.example | 7 - container/agent-runner/src/index.ts | 8 - .../agent-runner/src/providers/claude.ts | 1 - src/container-runner.ts | 15 +- 6 files changed, 114 insertions(+), 54 deletions(-) rename {container/agent-runner/src => .claude/skills/add-atomic-chat-tool}/atomic-chat-mcp-stdio.ts (100%) diff --git a/.claude/skills/add-atomic-chat-tool/SKILL.md b/.claude/skills/add-atomic-chat-tool/SKILL.md index d99551989..6a6d85862 100644 --- a/.claude/skills/add-atomic-chat-tool/SKILL.md +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -13,6 +13,8 @@ Tools exposed: Model management (download, delete) is done through the **Atomic Chat desktop UI** — the app is a fork of Jan and manages its own model library. +The skill ships the MCP server source in this folder and copies it into the agent-runner tree at install time, then wires it up with small edits to `index.ts`, `providers/claude.ts`, and `container-runner.ts`. No branch merge — all edits are additive and idempotent. + ## Phase 1: Pre-flight ### Check if already applied @@ -37,42 +39,128 @@ If the request fails: ## Phase 2: Apply Code Changes -### Ensure upstream remote +### Copy the MCP server source ```bash -git remote -v +cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts ``` -If `upstream` is missing, add it: +### Register the MCP server in the agent-runner + +Edit `container/agent-runner/src/index.ts`. Find the `mcpServers` object that currently looks like this: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + }; +``` + +Add an `atomic_chat` entry alongside `nanoclaw`: + +```ts + const mcpServers: Record }> = { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], + env: {}, + }, + atomic_chat: { + command: 'bun', + args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], + env: { + ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), + ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), + }, + }, + }; +``` + +### Add the tool glob to the allowlist + +Edit `container/agent-runner/src/providers/claude.ts`. Find `'mcp__nanoclaw__*',` in the `TOOL_ALLOWLIST` array and add `'mcp__atomic_chat__*',` on the following line: + +```ts + 'mcp__nanoclaw__*', + 'mcp__atomic_chat__*', +]; +``` + +### Forward host env vars into the container + +Edit `src/container-runner.ts` in `buildContainerArgs`. Find the `TZ` env line: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); +``` + +Add ATOMIC_CHAT forwarding right after it: + +```ts + args.push('-e', `TZ=${TIMEZONE}`); + + // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). + if (process.env.ATOMIC_CHAT_HOST) { + args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); + } + if (process.env.ATOMIC_CHAT_API_KEY) { + args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); + } +``` + +### Surface `[ATOMIC]` log lines at info level + +In the same file, find the stderr logger: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (line) log.debug(line, { container: agentGroup.folder }); + } + }); +``` + +Replace it with: + +```ts + container.stderr?.on('data', (data) => { + for (const line of data.toString().trim().split('\n')) { + if (!line) continue; + if (line.includes('[ATOMIC]')) { + log.info(line, { container: agentGroup.folder }); + } else { + log.debug(line, { container: agentGroup.folder }); + } + } + }); +``` + +### Add env-var stubs to `.env.example` + +Append to `.env.example`: ```bash -git remote add upstream https://github.com/qwibitai/nanoclaw.git +# Atomic Chat MCP tool (.claude/skills/add-atomic-chat-tool) +# Override the host where Atomic Chat exposes its OpenAI-compatible API. +# Default: http://host.docker.internal:1337 (with fallback to localhost) +# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 + +# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. +# ATOMIC_CHAT_API_KEY= ``` -### Merge the skill branch - -```bash -git fetch upstream skill/atomic-chat-tool -git merge upstream/skill/atomic-chat-tool -``` - -This merges in: -- `container/agent-runner/src/atomic-chat-mcp-stdio.ts` (Atomic Chat MCP server, run directly via `bun`) -- Atomic Chat MCP registration in `container/agent-runner/src/index.ts` (`mcpServers.atomic_chat`) -- `mcp__atomic_chat__*` added to `TOOL_ALLOWLIST` in `container/agent-runner/src/providers/claude.ts` -- `[ATOMIC]` log surfacing and `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` forwarding in `src/container-runner.ts` -- `ATOMIC_CHAT_HOST` / `ATOMIC_CHAT_API_KEY` stubs in `.env.example` - -If the merge reports conflicts, resolve them by reading the conflicted files and understanding the intent of both sides. - ### Validate code changes ```bash pnpm run build +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit ./container/build.sh ``` -Build must be clean before proceeding. +All three must be clean before proceeding. ## Phase 3: Configure @@ -126,9 +214,10 @@ Look for: ### Agent says "Atomic Chat is not installed" or tries to run a CLI The agent is looking for a CLI that doesn't exist instead of using the MCP tools. This means: -1. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` -2. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` -3. The container wasn't rebuilt — run `./container/build.sh` +1. The MCP server wasn't copied — check `container/agent-runner/src/atomic-chat-mcp-stdio.ts` exists +2. The MCP server wasn't registered — check `container/agent-runner/src/index.ts` has the `atomic_chat` entry in `mcpServers` +3. The allowlist wasn't updated — check `container/agent-runner/src/providers/claude.ts` includes `mcp__atomic_chat__*` in `TOOL_ALLOWLIST` +4. The container wasn't rebuilt — run `./container/build.sh` ### "Failed to connect to Atomic Chat" diff --git a/container/agent-runner/src/atomic-chat-mcp-stdio.ts b/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts similarity index 100% rename from container/agent-runner/src/atomic-chat-mcp-stdio.ts rename to .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts diff --git a/.env.example b/.env.example index 61f2074ee..e69de29bb 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +0,0 @@ -# Atomic Chat MCP tool (skill/atomic-chat-tool) -# Override the host where Atomic Chat exposes its OpenAI-compatible API. -# Default: http://host.docker.internal:1337 (with fallback to localhost) -# ATOMIC_CHAT_HOST=http://host.docker.internal:1337 - -# Optional API key. Leave unset for a local Atomic Chat install — it does not require auth. -# ATOMIC_CHAT_API_KEY= diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index 2093f9af6..236be4caf 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -79,14 +79,6 @@ async function main(): Promise { args: ['run', mcpServerPath], env: {}, }, - atomic_chat: { - command: 'bun', - args: ['run', path.join(__dirname, 'atomic-chat-mcp-stdio.ts')], - env: { - ...(process.env.ATOMIC_CHAT_HOST ? { ATOMIC_CHAT_HOST: process.env.ATOMIC_CHAT_HOST } : {}), - ...(process.env.ATOMIC_CHAT_API_KEY ? { ATOMIC_CHAT_API_KEY: process.env.ATOMIC_CHAT_API_KEY } : {}), - }, - }, }; for (const [name, serverConfig] of Object.entries(config.mcpServers)) { diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index d633c0f30..fbb077c47 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -55,7 +55,6 @@ const TOOL_ALLOWLIST = [ 'Skill', 'NotebookEdit', 'mcp__nanoclaw__*', - 'mcp__atomic_chat__*', ]; interface SDKUserMessage { diff --git a/src/container-runner.ts b/src/container-runner.ts index d92f5acbb..71e2064f3 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -139,12 +139,7 @@ async function spawnContainer(session: Session): Promise { // Log stderr container.stderr?.on('data', (data) => { for (const line of data.toString().trim().split('\n')) { - if (!line) continue; - if (line.includes('[ATOMIC]')) { - log.info(line, { container: agentGroup.folder }); - } else { - log.debug(line, { container: agentGroup.folder }); - } + if (line) log.debug(line, { container: agentGroup.folder }); } }); @@ -401,14 +396,6 @@ async function buildContainerArgs( // Everything NanoClaw-specific is in container.json (read by runner at startup). args.push('-e', `TZ=${TIMEZONE}`); - // Atomic Chat MCP tool: forward host overrides if set (default is host.docker.internal:1337). - if (process.env.ATOMIC_CHAT_HOST) { - args.push('-e', `ATOMIC_CHAT_HOST=${process.env.ATOMIC_CHAT_HOST}`); - } - if (process.env.ATOMIC_CHAT_API_KEY) { - args.push('-e', `ATOMIC_CHAT_API_KEY=${process.env.ATOMIC_CHAT_API_KEY}`); - } - // Provider-contributed env vars (e.g. XDG_DATA_HOME, OPENCODE_*, NO_PROXY). if (providerContribution.env) { for (const [key, value] of Object.entries(providerContribution.env)) { From 438dedad77c27adf648d20cf1f4a508eb806d3a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 13:30:51 +0000 Subject: [PATCH 11/31] chore: bump version to 2.0.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index aa6375616..77920c456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.6", + "version": "2.0.7", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 539af750d461a344b57ea3d80707fe781e95d873 Mon Sep 17 00:00:00 2001 From: cheats1314 <3030240693@qq.com> Date: Thu, 23 Apr 2026 22:22:18 +0800 Subject: [PATCH 12/31] fix(setup): detect registered groups from v2 central db Align the environment check with the v2 setup flow so existing wired agent groups are detected from data/v2.db instead of the retired v1 store. This prevents setup from reporting no registered groups on valid v2 installs and adds regression coverage for both v2 and pre-migration state. Co-Authored-By: Claude Opus 4.7 --- setup/environment.test.ts | 97 +++++++++++++++++++++------------------ setup/environment.ts | 47 ++++++++++--------- 2 files changed, 78 insertions(+), 66 deletions(-) diff --git a/setup/environment.test.ts b/setup/environment.test.ts index deda62f1f..7765693b6 100644 --- a/setup/environment.test.ts +++ b/setup/environment.test.ts @@ -1,5 +1,7 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'fs'; +import os from 'os'; +import path from 'path'; import Database from 'better-sqlite3'; @@ -17,58 +19,63 @@ describe('environment detection', () => { }); }); -describe('registered groups DB query', () => { - let db: Database.Database; +describe('detectRegisteredGroups', () => { + let tempDir: string; beforeEach(() => { - db = new Database(':memory:'); - db.exec(`CREATE TABLE IF NOT EXISTS registered_groups ( - jid TEXT PRIMARY KEY, - name TEXT NOT NULL, - folder TEXT NOT NULL UNIQUE, - trigger_pattern TEXT NOT NULL, - added_at TEXT NOT NULL, - container_config TEXT, - requires_trigger INTEGER DEFAULT 1 - )`); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-env-test-')); + fs.mkdirSync(path.join(tempDir, 'data'), { recursive: true }); }); - it('returns 0 for empty table', () => { - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(0); + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('returns correct count after inserts', () => { - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '123@g.us', - 'Group 1', - 'group-1', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('returns false when no registration state exists', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); - db.prepare( - `INSERT INTO registered_groups (jid, name, folder, trigger_pattern, added_at, requires_trigger) - VALUES (?, ?, ?, ?, ?, ?)`, - ).run( - '456@g.us', - 'Group 2', - 'group-2', - '@Andy', - '2024-01-01T00:00:00.000Z', - 1, - ); + it('detects pre-migration registered_groups.json', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + fs.writeFileSync(path.join(tempDir, 'data', 'registered_groups.json'), '[]'); + expect(detectRegisteredGroups(tempDir)).toBe(true); + }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - expect(row.count).toBe(2); + it('returns false for an empty v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(false); + }); + + it('detects wired agent groups in the v2 central DB', async () => { + const { detectRegisteredGroups } = await import('./environment.js'); + const db = new Database(path.join(tempDir, 'data', 'v2.db')); + db.exec(` + CREATE TABLE agent_groups (id TEXT PRIMARY KEY); + CREATE TABLE messaging_group_agents ( + id TEXT PRIMARY KEY, + messaging_group_id TEXT NOT NULL, + agent_group_id TEXT NOT NULL + ); + `); + db.prepare('INSERT INTO agent_groups (id) VALUES (?)').run('ag-1'); + db.prepare( + 'INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id) VALUES (?, ?, ?)', + ).run('mga-1', 'mg-1', 'ag-1'); + db.close(); + + expect(detectRegisteredGroups(tempDir)).toBe(true); }); }); diff --git a/setup/environment.ts b/setup/environment.ts index 4a8366503..6986396d7 100644 --- a/setup/environment.ts +++ b/setup/environment.ts @@ -7,11 +7,35 @@ import path from 'path'; import Database from 'better-sqlite3'; -import { STORE_DIR } from '../src/config.js'; import { log } from '../src/log.js'; import { commandExists, getPlatform, isHeadless, isWSL } from './platform.js'; import { emitStatus } from './status.js'; +export function detectRegisteredGroups(projectRoot: string): boolean { + if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { + return true; + } + + const dbPath = path.join(projectRoot, 'data', 'v2.db'); + if (!fs.existsSync(dbPath)) return false; + + let db: Database.Database | null = null; + try { + db = new Database(dbPath, { readonly: true }); + const row = db + .prepare( + `SELECT COUNT(DISTINCT ag.id) as count FROM agent_groups ag + JOIN messaging_group_agents mga ON mga.agent_group_id = ag.id`, + ) + .get() as { count: number }; + return row.count > 0; + } catch { + return false; + } finally { + db?.close(); + } +} + export async function run(_args: string[]): Promise { const projectRoot = process.cwd(); @@ -39,26 +63,7 @@ export async function run(_args: string[]): Promise { const authDir = path.join(projectRoot, 'store', 'auth'); const hasAuth = fs.existsSync(authDir) && fs.readdirSync(authDir).length > 0; - let hasRegisteredGroups = false; - // Check JSON file first (pre-migration) - if (fs.existsSync(path.join(projectRoot, 'data', 'registered_groups.json'))) { - hasRegisteredGroups = true; - } else { - // Check SQLite directly using better-sqlite3 (no sqlite3 CLI needed) - const dbPath = path.join(STORE_DIR, 'messages.db'); - if (fs.existsSync(dbPath)) { - try { - const db = new Database(dbPath, { readonly: true }); - const row = db - .prepare('SELECT COUNT(*) as count FROM registered_groups') - .get() as { count: number }; - if (row.count > 0) hasRegisteredGroups = true; - db.close(); - } catch { - // Table might not exist yet - } - } - } + const hasRegisteredGroups = detectRegisteredGroups(projectRoot); // Check for existing OpenClaw installation const homedir = (await import('os')).homedir(); From bee80b007200833eef4f87780a770092e95d7330 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:02 +0000 Subject: [PATCH 13/31] fix(container): clear orphan heartbeat before spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a container exits, its .heartbeat file is left behind with the mtime of its last SDK activity. When the same session spawns a new container, the host sweep's ceiling check reads that stale mtime and kills the freshly-spawned container within seconds — before the new instance has had time to touch the file itself. The sweep already has a carve-out for "no heartbeat file" (treated as a fresh spawn, given grace), so simply removing the orphan at spawn time restores the intended semantics. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 71e2064f3..8815b116e 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,7 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); @@ -131,6 +131,12 @@ async function spawnContainer(session: Session): Promise { log.info('Spawning container', { sessionId: session.id, agentGroup: agentGroup.name, containerName }); + // Clear any orphan heartbeat from a previous container instance — the + // sweep's ceiling check treats a missing file as "fresh spawn, give grace" + // (host-sweep.ts line 87). Without this, the stale mtime can trigger an + // immediate kill before the new container touches the file itself. + fs.rmSync(heartbeatPath(agentGroup.id, session.id), { force: true }); + const container = spawn(CONTAINER_RUNTIME_BIN, args, { stdio: ['ignore', 'pipe', 'pipe'] }); activeContainers.set(session.id, { process: container, containerName }); From 209061f54f6a8804ad6fd50f4ddf7d5a140b408e Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:16 +0000 Subject: [PATCH 14/31] fix(sweep): wake before reset + idempotent retry for orphan claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a container exits with an unresolved processing_ack claim, the sweep's crashed-container cleanup would reset the matching inbound message with tries++ and a future process_after. dueCount then dropped to 0, so the wake step never fired — and the next sweep tick found the same orphan claim, bumped tries again, and pushed process_after further out. The message reached MAX_TRIES and was marked failed without any container ever being spawned. Two changes: 1. Reorder sweep so the wake step runs before crashed-container cleanup. A fresh container clears orphan 'processing' rows on its own startup (container/agent-runner/src/db/connection.ts), so once we get it running the claim resolves itself. 2. Make resetStuckProcessingRows idempotent: if a message already has process_after set to a future time, skip the retry bump. The wake path will pick it up when the backoff elapses. Requires returning process_after from getMessageForRetry. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/session-db.ts | 8 ++++---- src/host-sweep.ts | 34 ++++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/db/session-db.ts b/src/db/session-db.ts index aea255d19..48e92970d 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -139,10 +139,10 @@ export function getMessageForRetry( db: Database.Database, messageId: string, status: string, -): { id: string; tries: number } | undefined { - return db.prepare('SELECT id, tries FROM messages_in WHERE id = ? AND status = ?').get(messageId, status) as - | { id: string; tries: number } - | undefined; +): { id: string; tries: number; processAfter: string | null } | undefined { + return db + .prepare('SELECT id, tries, process_after as processAfter FROM messages_in WHERE id = ? AND status = ?') + .get(messageId, status) as { id: string; tries: number; processAfter: string | null } | undefined; } export function syncProcessingAcks(inDb: Database.Database, outDb: Database.Database): void { diff --git a/src/host-sweep.ts b/src/host-sweep.ts index 1a2901ccc..4dc2fb70c 100644 --- a/src/host-sweep.ts +++ b/src/host-sweep.ts @@ -159,23 +159,31 @@ async function sweepSession(session: Session): Promise { syncProcessingAcks(inDb, outDb); } - const alive = isContainerRunning(session.id); - - // 2. Crashed-container cleanup: processing rows left behind get retried. - if (!alive && outDb) { - resetStuckProcessingRows(inDb, outDb, session, 'container not running'); + // 2. Wake a container if work is due and nothing is running. Ordered + // before the crashed-container cleanup so a fresh container gets a chance + // to clean its own orphan processing_ack rows on startup (see + // container/agent-runner/src/db/connection.ts). Otherwise the reset path + // would keep bumping process_after into the future, dueCount would stay 0, + // and the wake would never fire. + const dueCount = countDueMessages(inDb); + if (dueCount > 0 && !isContainerRunning(session.id)) { + log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); + await wakeContainer(session); } + const alive = isContainerRunning(session.id); + // 3. Running-container SLA: absolute ceiling + per-claim stuck rules. if (alive && outDb) { enforceRunningContainerSla(inDb, outDb, session, agentGroup.id); } - // 4. Wake a container if new work is due and nothing is running. - const dueCount = countDueMessages(inDb); - if (dueCount > 0 && !isContainerRunning(session.id)) { - log.info('Waking container for due messages', { sessionId: session.id, count: dueCount }); - await wakeContainer(session); + // 4. Crashed-container cleanup: processing rows left behind get retried. + // Only fires when wake in step 2 didn't pick up the work (no due messages, + // or wake failed). resetStuckProcessingRows itself is idempotent — it + // skips messages already scheduled for a future retry. + if (!alive && outDb) { + resetStuckProcessingRows(inDb, outDb, session, 'container not running'); } // 5. Recurrence fanout for completed recurring tasks. @@ -246,10 +254,16 @@ function resetStuckProcessingRows( reason: string, ): void { const claims = getProcessingClaims(outDb); + const now = Date.now(); for (const { message_id } of claims) { const msg = getMessageForRetry(inDb, message_id, 'pending'); if (!msg) continue; + // Already rescheduled for a future retry — don't bump tries again. The + // wake path (sweep step 2) will fire when process_after elapses and a + // fresh container will clean the orphan claim on startup. + if (msg.processAfter && Date.parse(msg.processAfter) > now) continue; + if (msg.tries >= MAX_TRIES) { markMessageFailed(inDb, msg.id); log.warn('Message marked as failed after max retries', { From 237876c2c6f7012fcbd6d8505b8b8e5dea33b2d3 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 15:12:56 +0000 Subject: [PATCH 15/31] chore(format): wrap session-manager import in container-runner Pre-commit prettier reformatted this in the working tree but didn't re-stage. Keeping it in a separate commit to avoid amending a prior commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/container-runner.ts b/src/container-runner.ts index 8815b116e..fca88c490 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -36,7 +36,13 @@ import { type ProviderContainerContribution, type VolumeMount, } from './providers/provider-container-registry.js'; -import { heartbeatPath, markContainerRunning, markContainerStopped, sessionDir, writeSessionRouting } from './session-manager.js'; +import { + heartbeatPath, + markContainerRunning, + markContainerStopped, + sessionDir, + writeSessionRouting, +} from './session-manager.js'; import type { AgentGroup, Session } from './types.js'; const onecli = new OneCLI({ url: ONECLI_URL, apiKey: ONECLI_API_KEY }); From ff277c0d492face410ae0b789dbe4259723fb207 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 16:56:21 +0000 Subject: [PATCH 16/31] fix(chat-sdk-bridge): encode option index in callback_data for Telegram 64-byte cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_question cards failed to deliver on Telegram whenever any option had a non-trivial value (e.g. an ISO datetime, a URL, or a long token). Telegram limits inline-keyboard callback_data to 64 bytes, and the previous encoding embedded both the questionId and the full option value in each button's actionId plus a second copy as value, producing payloads well over the cap. The adapter threw ValidationError, delivery was marked permanently failed, and the agent sat waiting on an answer that never reached the user. Fix: - Button id is now `ncq::` and button value is the stringified index. Callback payloads shrink from ~100 bytes to ~40 and fit Telegram's cap for any option list with <100 items. - Both callback-decode sites (Chat SDK `onAction` for Telegram/Slack/ etc., and the Discord Gateway interaction handler) resolve the index back to the real option value via `getAskQuestionRender(questionId)` before dispatching to the host's onAction — so response handlers (pending_questions, pending_approvals) are unchanged and still receive the canonical value. - `resolveSelectedOption` helper has a backward-compat fallback: non-numeric tails are treated as literal values so any card delivered under the old encoding still resolves if the user clicks it after deploy. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/chat-sdk-bridge.ts | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/channels/chat-sdk-bridge.ts b/src/channels/chat-sdk-bridge.ts index 5c120e074..7123c0fc1 100644 --- a/src/channels/chat-sdk-bridge.ts +++ b/src/channels/chat-sdk-bridge.ts @@ -81,6 +81,26 @@ export interface ChatSdkBridgeConfig { * chunk boundary will render as two independent blocks on the receiving * platform, which is the same behavior as manually re-opening a fence. */ +/** + * Decode the actual option value from a button callback. Buttons are encoded + * with an integer index (to keep under Telegram's 64-byte callback_data cap), + * and the real value is looked up via `getAskQuestionRender(questionId)`. + * Falls back to treating the tail as a literal value so old in-flight cards + * (encoded before this shortening landed) still resolve. + */ +function resolveSelectedOption( + render: { options: NormalizedOption[] } | undefined, + eventValue: string | undefined, + tail: string | undefined, +): string { + const candidate = eventValue ?? tail ?? ''; + if (render && /^\d+$/.test(candidate)) { + const idx = Number(candidate); + if (render.options[idx]) return render.options[idx].value; + } + return candidate; +} + export function splitForLimit(text: string, limit: number): string[] { if (text.length <= limit) return [text]; const chunks: string[] = []; @@ -240,11 +260,15 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter const parts = event.actionId.split(':'); if (parts.length < 3) return; const questionId = parts[1]; - const selectedOption = event.value || ''; + const tail = parts.slice(2).join(':'); const userId = event.user?.userId || ''; // Resolve render metadata BEFORE dispatching onAction (which deletes the row). const render = getAskQuestionRender(questionId); + // New format: button id/value is an integer index into options (kept + // short to fit Telegram's 64-byte callback_data cap). Old format: + // the full value is embedded in actionId/value directly. + const selectedOption = resolveSelectedOption(render, event.value, tail); const title = render?.title ?? '❓ Question'; const matched = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matched?.selectedLabel ?? selectedOption ?? '(clicked)'; @@ -348,8 +372,13 @@ export function createChatSdkBridge(config: ChatSdkBridgeConfig): ChannelAdapter children: [ CardText(question), Actions( - options.map((opt) => - Button({ id: `ncq:${questionId}:${opt.value}`, label: opt.label, value: opt.value }), + // Encode button id/value with the option index rather than the + // full value. Telegram caps callback_data at 64 bytes, and + // long values (e.g. ISO datetimes, URLs) push the JSON payload + // well past that. The onAction handlers resolve the index back + // to the real value via getAskQuestionRender(questionId). + options.map((opt, idx) => + Button({ id: `ncq:${questionId}:${idx}`, label: opt.label, value: String(idx) }), ), ), ], @@ -507,12 +536,12 @@ async function handleForwardedEvent( // Parse the selected option from custom_id let questionId: string | undefined; - let selectedOption: string | undefined; + let tail: string | undefined; if (customId?.startsWith('ncq:')) { const colonIdx = customId.indexOf(':', 4); // after "ncq:" if (colonIdx !== -1) { questionId = customId.slice(4, colonIdx); - selectedOption = customId.slice(colonIdx + 1); + tail = customId.slice(colonIdx + 1); } } @@ -521,6 +550,9 @@ async function handleForwardedEvent( ((interaction.message as Record)?.embeds as Array>) || []; const originalDescription = (originalEmbeds[0]?.description as string) || ''; const render = questionId ? getAskQuestionRender(questionId) : undefined; + // Discord custom_id mirrors the new index-based encoding (see Button + // construction). Decode back to the real option value for downstream. + const selectedOption = resolveSelectedOption(render, tail, tail); const cardTitle = render?.title ?? ((originalEmbeds[0]?.title as string) || '❓ Question'); const matchedOpt = render?.options.find((o) => o.value === selectedOption); const selectedLabel = matchedOpt?.selectedLabel ?? selectedOption ?? customId; From 97868af5a7529da909eb4e2bc43760f71722957a Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 17:05:41 +0000 Subject: [PATCH 17/31] fix(delivery): make pending_questions/approvals insert idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createPendingQuestion and createPendingApproval both run before the adapter delivery call. When delivery fails and the retry loop reinvokes deliverMessage with the same questionId/approvalId, the second attempt hit UNIQUE constraint on the pending_questions.question_id (or pending_approvals.approval_id) and threw — so the retry never reached the send step, and every subsequent retry failed the same way until max-attempts marked the message permanently failed. Switch both inserts to INSERT OR IGNORE. Return bool indicating whether a new row was actually inserted so delivery.ts can avoid logging "Pending question created" twice for the same card. Symptom that surfaced this: a send-layer ValidationError on one attempt followed by SqliteError on every subsequent attempt, with the user seeing neither the card nor a follow-up. Seen in conjunction with the Telegram 64-byte callback_data limit (fixed separately in #1942/chat-sdk-bridge), but the idempotency gap applies to any transient delivery failure — rate limits, network blips, adapter 5xx — and is worth fixing on its own. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/db/sessions.ts | 25 +++++++++++++++++++------ src/delivery.ts | 6 ++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/db/sessions.ts b/src/db/sessions.ts index bdca8a66e..af765f914 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -97,10 +97,16 @@ export function deleteSession(id: string): void { // ── Pending Questions ── -export function createPendingQuestion(pq: PendingQuestion): void { - getDb() +/** + * Insert a pending question row. Idempotent: when delivery fails and retries, + * the second attempt calls this with the same question_id — without `OR + * IGNORE` that would throw UNIQUE and prevent the retry from reaching the + * actual send step. Returns true if a new row was inserted. + */ +export function createPendingQuestion(pq: PendingQuestion): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) + `INSERT OR IGNORE INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, title, options_json, created_at) VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @title, @options_json, @created_at)`, ) .run({ @@ -114,6 +120,7 @@ export function createPendingQuestion(pq: PendingQuestion): void { options_json: JSON.stringify(pq.options), created_at: pq.created_at, }); + return result.changes > 0; } export function getPendingQuestion(questionId: string): PendingQuestion | undefined { @@ -131,16 +138,21 @@ export function deletePendingQuestion(questionId: string): void { // ── Pending Approvals ── +/** + * Insert a pending approval row. Idempotent for the same reason as + * createPendingQuestion: delivery retries with the same approval_id must not + * fail on UNIQUE before the send step gets a chance to succeed. + */ export function createPendingApproval( pa: Partial & Pick< PendingApproval, 'approval_id' | 'request_id' | 'action' | 'payload' | 'created_at' | 'title' | 'options_json' >, -): void { - getDb() +): boolean { + const result = getDb() .prepare( - `INSERT INTO pending_approvals + `INSERT OR IGNORE INTO pending_approvals (approval_id, session_id, request_id, action, payload, created_at, agent_group_id, channel_type, platform_id, platform_message_id, expires_at, status, title, options_json) @@ -159,6 +171,7 @@ export function createPendingApproval( status: 'pending', ...pa, }); + return result.changes > 0; } export function getPendingApproval(approvalId: string): PendingApproval | undefined { diff --git a/src/delivery.ts b/src/delivery.ts index 2e193d4c2..036153a8b 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -321,7 +321,7 @@ async function deliverMessage( questionId: content.questionId, }); } else { - createPendingQuestion({ + const inserted = createPendingQuestion({ question_id: content.questionId, session_id: session.id, message_out_id: msg.id, @@ -332,7 +332,9 @@ async function deliverMessage( options: normalizeOptions(rawOptions as never), created_at: new Date().toISOString(), }); - log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + if (inserted) { + log.info('Pending question created', { questionId: content.questionId, sessionId: session.id }); + } } } From 0ec56b732dafad275015261ac3ca574f61b3b052 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:35:00 +0300 Subject: [PATCH 18/31] docs(add-codex): add skill for installing Codex provider from providers branch Mirrors the /add-opencode and /add-ollama-provider pattern. Copies the add-codex SKILL.md from the providers branch onto trunk so the skill is discoverable without a manual branch copy. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 164 ++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 .claude/skills/add-codex/SKILL.md diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md new file mode 100644 index 000000000..a5484d535 --- /dev/null +++ b/.claude/skills/add-codex/SKILL.md @@ -0,0 +1,164 @@ +--- +name: add-codex +description: Use Codex (CLI + AppServer) as the full agent provider — planning, tool orchestration, native compaction, MCP tools, session resume — in place of the Claude Agent SDK. ChatGPT subscription or OPENAI_API_KEY. Per-group via agent_provider. Distinct from using OpenAI as an MCP tool (where Claude remains the planner). +--- + +# Codex agent provider + +NanoClaw runs agents in a long-lived **poll loop** inside the container. The backend is selected with **`AGENT_PROVIDER`** (`claude` | `opencode` | `codex` | `mock`). + +Trunk ships with only the `claude` provider baked in. This skill copies the Codex provider files in from the `providers` branch, wires them into the host and container barrels, updates the Dockerfile to install the Codex CLI, and rebuilds the image. + +The Codex provider runs `codex app-server` as a child process and speaks JSON-RPC over stdio. That gives it native session resume, streaming events, MCP tool access, and `thread/compact/start` compaction — same feature bar as the Claude Agent SDK, without the Anthropic-only lock-in. + +## Install + +### Pre-flight + +If all of the following are already present, skip to **Configuration**: + +- `src/providers/codex.ts` +- `container/agent-runner/src/providers/codex.ts` +- `container/agent-runner/src/providers/codex-app-server.ts` +- `container/agent-runner/src/providers/codex.factory.test.ts` +- `import './codex.js';` line in `src/providers/index.ts` +- `import './codex.js';` line in `container/agent-runner/src/providers/index.ts` +- `ARG CODEX_VERSION` and `"@openai/codex@${CODEX_VERSION}"` in the pnpm global-install block in `container/Dockerfile` + +Missing pieces — continue below. All steps are idempotent; re-running is safe. + +### 1. Fetch the providers branch + +```bash +git fetch origin providers +``` + +### 2. Copy the Codex source files + +Wholesale copies (owned entirely by this skill — user edits to these files won't survive a re-run, as designed): + +```bash +git show origin/providers:src/providers/codex.ts > src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex.ts > container/agent-runner/src/providers/codex.ts +git show origin/providers:container/agent-runner/src/providers/codex-app-server.ts > container/agent-runner/src/providers/codex-app-server.ts +git show origin/providers:container/agent-runner/src/providers/codex.factory.test.ts > container/agent-runner/src/providers/codex.factory.test.ts +``` + +### 3. Append the self-registration imports + +Each barrel gets one line — alphabetical placement keeps diffs small. + +`src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +`container/agent-runner/src/providers/index.ts`: + +```typescript +import './codex.js'; +``` + +### 4. Add the Codex CLI to the container Dockerfile + +Two edits to `container/Dockerfile`, both idempotent (skip if already present): + +**(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: + +```dockerfile +ARG CODEX_VERSION=0.121.0 +``` + +**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: + +```dockerfile + pnpm install -g \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ + "agent-browser@${AGENT_BROWSER_VERSION}" \ + "vercel@${VERCEL_VERSION}" +``` + +Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. + +### 5. Build + +```bash +pnpm run build # host +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit # container typecheck +./container/build.sh # agent image +``` + +## Configuration + +Codex supports two primary auth paths and one experimental BYO-endpoint path. Pick the one that matches your setup. + +### Option A — ChatGPT subscription (recommended for individuals) + +On the host (not inside the container), run Codex's OAuth login: + +```bash +codex login +``` + +This writes `~/.codex/auth.json` with a subscription token. The host-side Codex provider ([src/providers/codex.ts](../../../src/providers/codex.ts)) copies `auth.json` into a per-session `~/.codex` directory mounted into the container — your host's own Codex CLI is never touched. + +No `.env` variables required for this mode. + +### Option B — API key (recommended for CI or API billing) + +```env +OPENAI_API_KEY=sk-... +CODEX_MODEL=gpt-5.4-mini +``` + +The host forwards both variables into the container. If both subscription (`auth.json`) and `OPENAI_API_KEY` are present, Codex prefers the subscription. + +### Option C — BYO OpenAI-compatible endpoint (experimental) + +Codex's built-in `openai` provider honors the `OPENAI_BASE_URL` env var directly. Point it at any OpenAI-compatible endpoint — Groq, Together, self-hosted vLLM, an OpenAI proxy, etc. + +```env +OPENAI_API_KEY=... +OPENAI_BASE_URL=https://api.groq.com/openai/v1 +CODEX_MODEL=llama-3.3-70b-versatile +``` + +Codex also ships first-class local-runner flags — `codex --oss --local-provider ollama` or `--local-provider lmstudio` — that auto-detect a local server. To use those inside NanoClaw, set `CODEX_MODEL` to a model your local runner serves and add the corresponding base URL; see the Codex CLI docs for the full `model_provider = oss` configuration. + +**Experimental caveat:** tool-calling quality depends on the model and endpoint. Not every OpenAI-compat provider implements the full function-calling spec, and smaller models (< 30B) often struggle with multi-step tool orchestration. Test before committing. + +### Per group / per session + +Schema: **`agent_groups.agent_provider`** and **`sessions.agent_provider`**. Set to `codex` for groups or sessions that should use Codex. The container receives `AGENT_PROVIDER` from the resolved value (session overrides group). + +`CODEX_MODEL` applies process-wide via `.env`; if you need different models for different groups, set them via `container_config.env` on the group. + +Extra MCP servers still come from **`NANOCLAW_MCP_SERVERS`** / `container_config.mcpServers` on the host. The runner merges them into the same `mcpServers` object passed to all providers. + +## Operational notes + +- **Spawn-per-query:** Codex's app-server is spawned fresh per query invocation, matching the OpenCode pattern. No long-lived daemon to keep healthy across sessions. +- **Per-session `~/.codex` isolation:** each group gets its own copy of the host's `auth.json`. The container can rewrite `config.toml` freely on every wake without touching the host's Codex config. +- **Native compaction:** kicks in automatically at 40K cumulative input tokens between turns, via `thread/compact/start`. If compaction fails, the provider logs and continues uncompacted — no fatal error. +- **Approvals:** auto-accepted inside the container (the container is the sandbox; same posture as Claude/OpenCode). +- **Mid-turn input:** Codex turns don't accept mid-turn messages. Follow-up `push()` calls queue and drain between turns, matching the OpenCode pattern. The poll-loop only pushes between turns anyway, so no messages are dropped. +- **Stale thread recovery:** `isSessionInvalid` matches on stale-thread-ID errors (`thread not found`, `unknown thread`, etc.) so a cold-started app-server can recover cleanly when it sees a stored continuation it no longer has. + +## Verify + +```bash +grep -q "./codex.js" container/agent-runner/src/providers/index.ts && echo "container barrel: OK" +grep -q "./codex.js" src/providers/index.ts && echo "host barrel: OK" +grep -q "@openai/codex@" container/Dockerfile && echo "Dockerfile install: OK" +cd container/agent-runner && bun test src/providers/codex.factory.test.ts && cd - +``` + +After the image rebuild, set `agent_provider = 'codex'` on a test group and send a message. Successful round-trip looks like: + +- `init` event with a stable thread ID as continuation +- One or more `activity` / `progress` events during the turn +- `result` event with the model's reply + +If the agent hangs or errors, check `~/.codex/auth.json` exists on the host (Option A) or that `OPENAI_API_KEY` is forwarding correctly (Option B) — `docker exec` into a running container and `env | grep -i openai` to confirm. From e5a7a330843f1e5373e0849c2a78a0ff13672759 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 21:38:16 +0300 Subject: [PATCH 19/31] =?UTF-8?q?docs(add-codex):=20fix=20Dockerfile=20ins?= =?UTF-8?q?tall=20step=20=E2=80=94=20separate=20RUN=20block,=20not=20combi?= =?UTF-8?q?ned=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior instruction told users to append "@openai/codex@${CODEX_VERSION}" to a single combined `pnpm install -g` block. That block no longer exists on main — the Dockerfile splits each global CLI (vercel, agent-browser, claude-code) into its own RUN layer for cache granularity. Update the skill to add a standalone RUN block for Codex that matches the existing pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index a5484d535..17910b7e7 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -70,14 +70,11 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): ARG CODEX_VERSION=0.121.0 ``` -**(b)** In the `pnpm install -g` block (around line 80), append `"@openai/codex@${CODEX_VERSION}"` to the list: +**(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: ```dockerfile - pnpm install -g \ - "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ - "@openai/codex@${CODEX_VERSION}" \ - "agent-browser@${AGENT_BROWSER_VERSION}" \ - "vercel@${VERCEL_VERSION}" +RUN --mount=type=cache,target=/root/.cache/pnpm \ + pnpm install -g "@openai/codex@${CODEX_VERSION}" ``` Note: **no agent-runner package dependency** — Codex is a CLI binary, not a library. Unlike OpenCode, there's nothing to add to `container/agent-runner/package.json`. From c6d2f45f93d3189d0206aecb614e44e64da5afb5 Mon Sep 17 00:00:00 2001 From: Doug Daniels Date: Thu, 23 Apr 2026 14:37:10 -0400 Subject: [PATCH 20/31] feat: add Signal channel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Signal adapter using signal-cli TCP JSON-RPC daemon. No Chat SDK bridge or npm dependencies — uses only Node.js builtins. Features: - DM and group message support - Voice message detection (placeholder text; transcription via /add-voice-transcription skill) - Typing indicators (DMs only) - Mention detection via text match - Managed daemon lifecycle (auto-start/stop signal-cli) - Echo suppression for outbound messages Also fixes init-first-agent.ts to skip channel-prefixing for phone numbers (+...) and Signal group IDs (group:...), which are native platform IDs that adapters send without a channel prefix. Install via /add-signal skill. Uses /init-first-agent for channel wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/add-signal/SKILL.md | 121 +++++ scripts/init-first-agent.ts | 24 +- src/channels/index.ts | 1 + src/channels/signal.test.ts | 627 ++++++++++++++++++++++++ src/channels/signal.ts | 744 +++++++++++++++++++++++++++++ 5 files changed, 1513 insertions(+), 4 deletions(-) create mode 100644 .claude/skills/add-signal/SKILL.md create mode 100644 src/channels/signal.test.ts create mode 100644 src/channels/signal.ts diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md new file mode 100644 index 000000000..92c78004b --- /dev/null +++ b/.claude/skills/add-signal/SKILL.md @@ -0,0 +1,121 @@ +--- +name: add-signal +description: Add Signal channel integration via signal-cli TCP daemon. Native adapter — no Chat SDK bridge. +--- + +# Add Signal Channel + +Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. + +## Prerequisites + +- **signal-cli** installed and a Signal account linked + - macOS: `brew install signal-cli` + - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) + - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) + +## Install + +### Pre-flight (idempotent) + +Skip to **Credentials** if all of these are already in place: + +- `src/channels/signal.ts` exists +- `src/channels/signal.test.ts` exists +- `src/channels/index.ts` contains `import './signal.js';` + +Otherwise continue. Every step below is safe to re-run. + +### 1. Fetch the skill branch + +```bash +git fetch origin skill/signal +``` + +### 2. Copy the adapter and tests + +```bash +git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts +git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +``` + +### 3. Append the self-registration import + +Append to `src/channels/index.ts` (skip if the line is already present): + +```typescript +import './signal.js'; +``` + +### 4. Build + +```bash +pnpm run build +``` + +No npm packages to install — the adapter uses only Node.js builtins (`node:net`, `node:child_process`, `node:fs`). + +## Credentials + +Add to `.env`: + +```env +SIGNAL_ACCOUNT=+1YOURNUMBER +``` + +### Optional settings + +```env +# TCP daemon host and port (default: 127.0.0.1:7583) +SIGNAL_HTTP_HOST=127.0.0.1 +SIGNAL_HTTP_PORT=7583 + +# Whether NanoClaw manages the daemon lifecycle (default: true) +# Set to false if you run signal-cli daemon externally +SIGNAL_MANAGE_DAEMON=true + +# signal-cli data directory (default: ~/.local/share/signal-cli) +SIGNAL_DATA_DIR=~/.local/share/signal-cli +``` + +### Sync to container + +```bash +mkdir -p data/env && cp .env data/env/env +``` + +### Restart + +```bash +# macOS +launchctl kickstart -k gui/$(id -u)/com.nanoclaw + +# Linux +systemctl --user restart nanoclaw +``` + +## Next Steps + +Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: + +- **User ID**: your Signal phone number (e.g. `+15551234567`) +- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) +- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` + +`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. + +## Channel Info + +| Field | Value | +|-------|-------| +| **Type** | `signal` | +| **Thread support** | No (Signal has no thread model) | +| **Platform ID format** | DM: `+15555550123` / Group: `group:` | +| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | +| **Typing indicators** | DMs only | +| **Typical use** | Personal assistant via Signal DMs or small group chats | +| **Isolation** | Recommended: one agent per Signal account | + +### Voice Messages + +Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. diff --git a/scripts/init-first-agent.ts b/scripts/init-first-agent.ts index dcb99b511..fc61b9c75 100644 --- a/scripts/init-first-agent.ts +++ b/scripts/init-first-agent.ts @@ -137,13 +137,29 @@ function namespacedUserId(channel: string, raw: string): string { return raw.includes(':') ? raw : `${channel}:${raw}`; } +/** + * Determine whether a platform ID needs a channel-type prefix. + * + * Chat SDK adapters (Telegram, Discord, Slack, Teams, etc.) namespace their + * platform IDs with a channel prefix: "telegram:123456", "discord:guild:chan". + * The router stores `channel_type` and `platform_id` in separate columns, but + * Chat SDK adapters send the prefixed form as the platform_id, so this script + * must match that format. + * + * Native adapters (Signal, WhatsApp) use their own ID formats and send them + * as-is — no channel prefix. Signal sends raw phone numbers (+15551234567) + * for DMs and "group:" for group chats. WhatsApp sends JIDs containing + * '@' (@s.whatsapp.net, @g.us). Prefixing these would cause + * a mismatch between what the adapter sends and what the DB stores, breaking + * message routing. + */ function namespacedPlatformId(channel: string, raw: string): string { if (raw.startsWith(`${channel}:`)) return raw; - // Adapters using native JID format (WhatsApp: @s.whatsapp.net, - // @g.us) store platform_id without a channel prefix. The '@' is - // the discriminator — telegram/discord platform_ids don't contain it - // except after a channel prefix, which is already handled above. + // Native WhatsApp JIDs contain '@' — no prefix needed. if (raw.includes('@')) return raw; + // Native Signal IDs: phone numbers (+...) and group IDs (group:...). + if (raw.startsWith('+') || raw.startsWith('group:')) return raw; + // Chat SDK adapters — add the channel prefix. return `${channel}:${raw}`; } diff --git a/src/channels/index.ts b/src/channels/index.ts index e9b3bd1b7..b75016f68 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,3 +7,4 @@ // self-registration import below. import './cli.js'; +import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts new file mode 100644 index 000000000..c7ffff1d7 --- /dev/null +++ b/src/channels/signal.test.ts @@ -0,0 +1,627 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +// --- Mocks --- + +vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); +vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); +vi.mock('../log.js', () => ({ + log: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), + execFileSync: vi.fn(), +})); + +// --- TCP socket mock --- + +import { EventEmitter } from 'events'; + +const tcpRef = vi.hoisted(() => ({ + rpcResponses: new Map(), + fakeSocket: null as any, +})); + +function createFakeSocket(): EventEmitter & { + write: ReturnType; + destroy: ReturnType; + destroyed: boolean; +} { + const sock = new EventEmitter() as any; + sock.destroyed = false; + sock.destroy = vi.fn(() => { + sock.destroyed = true; + sock.emit('close'); + }); + sock.write = vi.fn((data: string) => { + try { + const req = JSON.parse(data.trim()); + const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; + const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; + setImmediate(() => sock.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + return sock; +} + +vi.mock('node:net', () => ({ + createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { + const sock = createFakeSocket(); + tcpRef.fakeSocket = sock; + if (cb) setImmediate(cb); + return sock; + }), +})); + +import type { ChannelSetup } from './adapter.js'; +import { createSignalAdapter } from './signal.js'; + +// --- Test helpers --- + +function createMockSetup() { + return { + onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, + onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, + onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, + onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, + }; +} + +function createAdapter() { + return createSignalAdapter({ + cliPath: 'signal-cli', + account: '+15551234567', + tcpHost: '127.0.0.1', + tcpPort: 7583, + manageDaemon: false, + signalDataDir: '/tmp/signal-cli-test-data', + }); +} + +function getRpcCalls(): Array<{ + method: string; + params: Record; + id: string; +}> { + if (!tcpRef.fakeSocket) return []; + return tcpRef.fakeSocket.write.mock.calls + .map((c: any[]) => { + try { + return JSON.parse(c[0].trim()); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function getRpcCallsForMethod(method: string) { + return getRpcCalls().filter((c) => c.method === method); +} + +function pushEvent(envelope: Record) { + if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); + const notification = + JSON.stringify({ + jsonrpc: '2.0', + method: 'receive', + params: { envelope }, + }) + '\n'; + tcpRef.fakeSocket.emit('data', Buffer.from(notification)); +} + +// --- Tests --- + +describe('SignalAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + tcpRef.rpcResponses.clear(); + tcpRef.fakeSocket = null; + tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); + tcpRef.rpcResponses.set('sendTyping', {}); + }); + + afterEach(() => { + try { + tcpRef.fakeSocket?.destroy(); + } catch { + // already closed + } + }); + + // --- Connection lifecycle --- + + describe('connection lifecycle', () => { + it('connects when daemon is reachable', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + expect(adapter.isConnected()).toBe(true); + expect(tcpRef.fakeSocket).not.toBeNull(); + + await adapter.teardown(); + }); + + it('isConnected() returns false before setup', () => { + const adapter = createAdapter(); + expect(adapter.isConnected()).toBe(false); + }); + + it('disconnects cleanly', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + await adapter.teardown(); + expect(adapter.isConnected()).toBe(false); + }); + + it('throws NetworkError if daemon is unreachable', async () => { + const { createConnection } = await import('node:net'); + vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { + const sock = createFakeSocket(); + setImmediate(() => sock.emit('error', new Error('Connection refused'))); + return sock as any; + }); + + const adapter = createAdapter(); + await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); + }); + }); + + // --- Inbound message handling --- + + describe('inbound message handling', () => { + it('delivers DM via onInbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'Hello from Signal', + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + id: '1700000000000', + kind: 'chat', + content: expect.objectContaining({ + text: 'Hello from Signal', + sender: '+15555550123', + senderName: 'Alice', + }), + }), + ); + + await adapter.teardown(); + }); + + it('delivers group message with group platformId', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { + timestamp: 1700000000000, + message: 'Group hello', + groupInfo: { groupId: 'abc123', groupName: 'Family' }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + + expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); + expect(cfg.onInbound).toHaveBeenCalledWith( + 'group:abc123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Group hello', + sender: '+15555550999', + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips sync messages (own outbound)', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'My own message', + destination: '+15555550123', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('processes Note to Self sync messages as inbound', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15551234567', + syncMessage: { + sentMessage: { + timestamp: 1700000000000, + message: 'Hello Bee', + destinationNumber: '+15551234567', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15551234567', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'Hello Bee', + senderName: 'Me', + isFromMe: true, + }), + }), + ); + + await adapter.teardown(); + }); + + it('skips empty messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: ' ' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips echoed outbound messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + + it('skips messages with attachments but no text', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Quote context --- + + describe('quote context', () => { + it('populates reply_to fields from quoted messages', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + pushEvent({ + sourceNumber: '+15555550123', + sourceName: 'Alice', + dataMessage: { + timestamp: 1700000000000, + message: 'I disagree', + quote: { + id: 1699999999000, + authorNumber: '+15555550888', + text: 'Pineapple belongs on pizza', + }, + }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550123', + null, + expect.objectContaining({ + content: expect.objectContaining({ + text: 'I disagree', + replyToSenderName: '+15555550888', + replyToMessageContent: 'Pineapple belongs on pizza', + replyToMessageId: '1699999999000', + }), + }), + ); + + await adapter.teardown(); + }); + }); + + // --- deliver --- + + describe('deliver', () => { + it('sends DM via TCP RPC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + recipient: ['+15555550123'], + message: 'Hello', + account: '+15551234567', + }), + ); + + await adapter.teardown(); + }); + + it('sends group message via groupId', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('group:abc123', null, { + kind: 'text', + content: { text: 'Group msg' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params).toEqual( + expect.objectContaining({ + groupId: 'abc123', + message: 'Group msg', + }), + ); + + await adapter.teardown(); + }); + + it('chunks long messages', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + const longText = 'x'.repeat(5000); + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: longText }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(1); + + await adapter.teardown(); + }); + + it('extracts text from string content', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: 'Plain string content', + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Plain string content'); + + await adapter.teardown(); + }); + }); + + // --- Text styles --- + + describe('text styles', () => { + it('sends bold text with textStyle parameter', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:BOLD']); + + await adapter.teardown(); + }); + + it('sends inline code with MONOSPACE style', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Run `npm test` now' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Run npm test now'); + expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); + + await adapter.teardown(); + }); + + it('sends plain text without textStyle', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'No formatting here' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('No formatting here'); + expect(last.params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + + it('falls back to original markup when textStyle is rejected', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + let sendCount = 0; + tcpRef.fakeSocket.write.mockImplementation((data: string) => { + try { + const req = JSON.parse(data.trim()); + if (req.method === 'send') { + sendCount++; + if (sendCount === 1) { + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + error: { message: 'Unknown parameter: textStyle' }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + return; + } + } + const response = + JSON.stringify({ + jsonrpc: '2.0', + id: req.id, + result: { ok: true }, + }) + '\n'; + setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); + } catch { + /* ignore */ + } + }); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello **world**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBe(2); + expect(sendCalls[1].params.message).toBe('Hello **world**'); + expect(sendCalls[1].params.textStyle).toBeUndefined(); + + await adapter.teardown(); + }); + }); + + // --- setTyping --- + + describe('setTyping', () => { + it('sends typing indicator for DMs', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('+15555550123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); + + await adapter.teardown(); + }); + + it('skips typing for groups', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + + await adapter.setTyping!('group:abc123', null); + + expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); + + await adapter.teardown(); + }); + }); + + // --- Adapter properties --- + + describe('adapter properties', () => { + it('has channelType "signal"', () => { + const adapter = createAdapter(); + expect(adapter.channelType).toBe('signal'); + }); + + it('does not support threads', () => { + const adapter = createAdapter(); + expect(adapter.supportsThreads).toBe(false); + }); + }); +}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts new file mode 100644 index 000000000..300b7a6d0 --- /dev/null +++ b/src/channels/signal.ts @@ -0,0 +1,744 @@ +/** + * Signal channel adapter for NanoClaw v2. + * + * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. + * Requires signal-cli (https://github.com/AsamK/signal-cli) installed + * and a linked account. + * + * Ported from v1 — see v1 source for commit history. + */ +import { execFileSync, spawn } from 'node:child_process'; +import { readFileSync, existsSync } from 'node:fs'; +import { createConnection, type Socket } from 'node:net'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; +import { registerChannelAdapter } from './channel-registry.js'; +import { readEnvFile } from '../env.js'; +import { log } from '../log.js'; + +// --------------------------------------------------------------------------- +// Signal CLI daemon management +// --------------------------------------------------------------------------- + +interface DaemonHandle { + stop: () => void; + exited: Promise; + isExited: () => boolean; +} + +function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { + const args: string[] = []; + if (account) args.push('-a', account); + args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); + args.push('--receive-mode', 'on-start'); + + const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + let exited = false; + + const exitedPromise = new Promise((resolve) => { + child.once('exit', (code, signal) => { + exited = true; + if (code !== 0 && code !== null) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + log.error('signal-cli daemon exited', { reason }); + } + resolve(); + }); + child.on('error', (err) => { + exited = true; + log.error('signal-cli spawn error', { err }); + resolve(); + }); + }); + + child.stdout?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); + } + }); + child.stderr?.on('data', (data: Buffer) => { + for (const line of data.toString().split(/\r?\n/)) { + if (!line.trim()) continue; + if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { + log.warn('signal-cli stderr', { line: line.trim() }); + } else { + log.debug('signal-cli stderr', { line: line.trim() }); + } + } + }); + + return { + stop: () => { + if (!child.killed && !exited) child.kill('SIGTERM'); + }, + exited: exitedPromise, + isExited: () => exited, + }; +} + +// --------------------------------------------------------------------------- +// TCP JSON-RPC client for signal-cli daemon (--tcp mode) +// +// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. +// Requests are sent as JSON + newline; responses and push notifications +// (inbound messages) arrive the same way. +// --------------------------------------------------------------------------- + +const RPC_TIMEOUT_MS = 15_000; + +class SignalTcpClient { + private socket: Socket | null = null; + private buffer = ''; + private pending = new Map< + string, + { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType; + } + >(); + private onNotification: ((method: string, params: unknown) => void) | null = null; + + constructor( + private host: string, + private port: number, + ) {} + + connect(onNotification?: (method: string, params: unknown) => void): Promise { + this.onNotification = onNotification ?? null; + return new Promise((resolve, reject) => { + const sock = createConnection(this.port, this.host, () => { + this.socket = sock; + resolve(); + }); + sock.on('error', (err) => { + if (!this.socket) { + reject(err); + return; + } + log.warn('Signal TCP socket error', { err }); + }); + sock.on('data', (chunk) => this.onData(chunk)); + sock.on('close', () => { + this.socket = null; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error('Signal TCP connection closed')); + } + this.pending.clear(); + }); + }); + } + + async rpc(method: string, params?: Record): Promise { + if (!this.socket) throw new Error('Signal TCP not connected'); + const id = Math.random().toString(36).slice(2); + const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Signal RPC timeout: ${method}`)); + }, RPC_TIMEOUT_MS); + + this.pending.set(id, { + resolve: resolve as (v: unknown) => void, + reject, + timer, + }); + this.socket!.write(msg); + }); + } + + close() { + this.socket?.destroy(); + this.socket = null; + } + + isConnected(): boolean { + return this.socket !== null && !this.socket.destroyed; + } + + private onData(chunk: Buffer) { + this.buffer += chunk.toString(); + let newlineIdx = this.buffer.indexOf('\n'); + while (newlineIdx !== -1) { + const line = this.buffer.slice(0, newlineIdx).trim(); + this.buffer = this.buffer.slice(newlineIdx + 1); + if (line) this.handleLine(line); + newlineIdx = this.buffer.indexOf('\n'); + } + } + + private handleLine(line: string) { + let parsed: any; + try { + parsed = JSON.parse(line); + } catch { + log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); + return; + } + + if (parsed.id && this.pending.has(parsed.id)) { + const p = this.pending.get(parsed.id)!; + this.pending.delete(parsed.id); + clearTimeout(p.timer); + if (parsed.error) { + p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); + } else { + p.resolve(parsed.result); + } + return; + } + + if (parsed.method && this.onNotification) { + this.onNotification(parsed.method, parsed.params); + } + } +} + +async function signalTcpCheck(host: string, port: number): Promise { + return new Promise((resolve) => { + const sock = createConnection(port, host, () => { + sock.destroy(); + resolve(true); + }); + sock.on('error', () => resolve(false)); + setTimeout(() => { + sock.destroy(); + resolve(false); + }, 5000); + }); +} + +// --------------------------------------------------------------------------- +// Echo cache +// --------------------------------------------------------------------------- + +const ECHO_TTL_MS = 10_000; + +class EchoCache { + private entries = new Map(); + + remember(text: string) { + const key = text.trim(); + if (!key) return; + this.entries.set(key, Date.now()); + this.cleanup(); + } + + isEcho(text: string): boolean { + const key = text.trim(); + if (!key) return false; + const ts = this.entries.get(key); + if (!ts) return false; + if (Date.now() - ts > ECHO_TTL_MS) { + this.entries.delete(key); + return false; + } + this.entries.delete(key); + return true; + } + + private cleanup() { + const now = Date.now(); + for (const [key, ts] of this.entries) { + if (now - ts > ECHO_TTL_MS) this.entries.delete(key); + } + } +} + +// --------------------------------------------------------------------------- +// Signal envelope types +// --------------------------------------------------------------------------- + +interface SignalQuote { + id?: number; + authorNumber?: string; + authorUuid?: string; + text?: string; +} + +interface SignalDataMessage { + timestamp?: number; + message?: string; + groupInfo?: { groupId?: string; groupName?: string; type?: string }; + quote?: SignalQuote; + attachments?: Array<{ + id?: string; + contentType?: string; + filename?: string; + size?: number; + }>; +} + +interface SignalEnvelope { + source?: string; + sourceName?: string; + sourceNumber?: string; + sourceUuid?: string; + dataMessage?: SignalDataMessage; + syncMessage?: { + sentMessage?: SignalDataMessage & { + destination?: string; + destinationNumber?: string; + }; + }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function chunkText(text: string, limit: number): string[] { + const chunks: string[] = []; + let remaining = text; + while (remaining.length > 0) { + if (remaining.length <= limit) { + chunks.push(remaining); + break; + } + let splitAt = remaining.lastIndexOf('\n', limit); + if (splitAt <= 0) splitAt = limit; + chunks.push(remaining.slice(0, splitAt)); + remaining = remaining.slice(splitAt).replace(/^\n/, ''); + } + return chunks; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +// --------------------------------------------------------------------------- +// Signal text styles — convert Markdown to Signal's offset-based formatting +// --------------------------------------------------------------------------- + +interface SignalTextStyle { + style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; + start: number; + length: number; +} + +interface StyledText { + text: string; + textStyles: SignalTextStyle[]; +} + +function parseSignalStyles(input: string): StyledText { + const styles: SignalTextStyle[] = []; + + const patterns: Array<{ + regex: RegExp; + style: SignalTextStyle['style']; + }> = [ + { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, + { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, + { regex: /\*(.+?)\*/g, style: 'BOLD' }, + { regex: /_(.+?)_/g, style: 'ITALIC' }, + { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, + { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + ]; + + let text = input; + + for (const { regex, style } of patterns) { + const nextText: string[] = []; + let lastIndex = 0; + let offset = 0; + + for (const match of text.matchAll(regex)) { + const fullMatch = match[0]; + const innerText = match[1]; + const matchStart = match.index!; + + nextText.push(text.slice(lastIndex, matchStart)); + const plainStart = matchStart - offset; + + nextText.push(innerText); + styles.push({ style, start: plainStart, length: innerText.length }); + + const stripped = fullMatch.length - innerText.length; + offset += stripped; + lastIndex = matchStart + fullMatch.length; + } + + nextText.push(text.slice(lastIndex)); + text = nextText.join(''); + } + + return { text, textStyles: styles }; +} + +// --------------------------------------------------------------------------- +// SignalAdapter — v2 ChannelAdapter implementation +// --------------------------------------------------------------------------- + +/** + * Platform ID format: + * DM: phone number or UUID (e.g. "+15555550123") + * Group: "group:" (e.g. "group:abc123") + * + * channelType is always "signal". The router combines channelType + platformId + * to look up or create the messaging_group. + */ +export function createSignalAdapter(config: { + cliPath: string; + account: string; + tcpHost: string; + tcpPort: number; + manageDaemon: boolean; + signalDataDir: string; +}): ChannelAdapter { + let daemon: DaemonHandle | null = null; + let tcp: SignalTcpClient | null = null; + let connected = false; + const echoCache = new EchoCache(); + let setup: ChannelSetup | null = null; + + // -- inbound handling -- + + function handleNotification(method: string, params: unknown): void { + if (method === 'receive') { + const envelope = (params as any)?.envelope; + if (envelope) { + handleEnvelope(envelope).catch((err) => { + log.error('Signal: error handling envelope', { err }); + }); + } + } + } + + async function handleEnvelope(envelope: SignalEnvelope): Promise { + if (!setup) return; + + // Sync messages (sent from another device) + const syncSent = envelope.syncMessage?.sentMessage; + if (syncSent) { + const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); + // "Note to Self" — destination is our own account + if (dest === config.account) { + const text = (syncSent.message ?? '').trim(); + if (!text) return; + if (echoCache.isEcho(text)) return; + const platformId = config.account; + const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); + + setup.onMetadata(platformId, 'Note to Self', false); + + const msg: InboundMessage = { + id: String(syncSent.timestamp ?? Date.now()), + kind: 'chat', + content: { + text, + sender: config.account, + senderId: `signal:${config.account}`, + senderName: 'Me', + isFromMe: true, + ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + return; + } + // Other sync messages are our outbound — skip + return; + } + + const dataMessage = envelope.dataMessage; + if (!dataMessage) return; + + const text = (dataMessage.message ?? '').trim(); + + // Check for voice attachments + const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); + + if (!text && !hasVoice) return; + + const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); + if (!sender) return; + + if (text && echoCache.isEcho(text)) { + log.debug('Signal: skipping echo'); + return; + } + + const senderName = (envelope.sourceName?.trim() || sender).trim(); + const groupInfo = dataMessage.groupInfo; + const isGroup = Boolean(groupInfo?.groupId); + const groupId = groupInfo?.groupId; + + const platformId = isGroup ? `group:${groupId}` : sender; + const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); + + const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); + + setup.onMetadata(platformId, chatName, isGroup); + + let content = text; + + // Voice attachment — log path, deliver placeholder text. + // v2 does not have built-in transcription; a future MCP tool could handle this. + if (hasVoice) { + const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); + if (audio?.id) { + const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); + if (existsSync(attachmentPath)) { + log.info('Signal: voice attachment received', { + platformId, + attachmentId: audio.id, + path: attachmentPath, + }); + content = '[Voice Message]'; + } else { + log.warn('Signal: voice attachment file not found', { + id: audio.id, + path: attachmentPath, + }); + content = '[Voice Message - file not found]'; + } + } else { + content = '[Voice Message]'; + } + } + + const msg: InboundMessage = { + id: String(dataMessage.timestamp ?? Date.now()), + kind: 'chat', + content: { + text: content, + sender, + senderId: `signal:${sender}`, + senderName, + ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), + }, + timestamp, + }; + await setup.onInbound(platformId, null, msg); + + log.info('Signal message received', { platformId, sender: senderName }); + } + + function quoteToContent(quote: SignalQuote): Record { + return { + replyToSenderName: quote.authorNumber ?? 'someone', + replyToMessageContent: quote.text || undefined, + replyToMessageId: quote.id ? String(quote.id) : undefined, + }; + } + + // -- send helpers -- + + async function sendText(platformId: string, text: string): Promise { + if (!connected || !tcp) return; + + echoCache.remember(text); + + const MAX_CHUNK = 4000; + const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); + + for (const chunk of chunks) { + try { + const { text: plainText, textStyles } = parseSignalStyles(chunk); + const params: Record = { message: plainText }; + if (config.account) params.account = config.account; + if (textStyles.length > 0) { + params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); + } + + if (platformId.startsWith('group:')) { + params.groupId = platformId.slice('group:'.length); + } else { + params.recipient = [platformId]; + } + + try { + await tcp.rpc('send', params); + } catch (styledErr) { + if (textStyles.length > 0) { + log.debug('Signal: textStyle rejected, retrying with markup'); + delete params.textStyle; + params.message = chunk; + await tcp.rpc('send', params); + } else { + throw styledErr; + } + } + } catch (err) { + log.error('Signal: send failed', { platformId, err }); + } + } + + log.info('Signal message sent', { platformId, length: text.length }); + } + + async function waitForDaemon(): Promise { + const maxWait = 30_000; + const pollInterval = 1000; + const start = Date.now(); + + while (Date.now() - start < maxWait) { + if (daemon?.isExited()) return false; + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (ok) return true; + await sleep(pollInterval); + } + return false; + } + + // -- adapter -- + + const adapter: ChannelAdapter = { + name: 'signal', + channelType: 'signal', + supportsThreads: false, + + async setup(cfg: ChannelSetup): Promise { + setup = cfg; + + if (config.manageDaemon) { + daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); + const ready = await waitForDaemon(); + if (!ready) { + daemon.stop(); + throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); + } + } else { + const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); + if (!ok) { + const err = new Error( + `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, + ); + (err as any).name = 'NetworkError'; + throw err; + } + } + + tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); + await tcp.connect(handleNotification); + + try { + await tcp.rpc('updateProfile', { + name: 'NanoClaw', + account: config.account, + }); + } catch { + log.debug('Signal: could not set profile name'); + } + + try { + await tcp.rpc('updateConfiguration', { + typingIndicators: true, + account: config.account, + }); + } catch { + log.debug('Signal: could not enable typing indicators'); + } + + connected = true; + log.info('Signal channel connected', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + + async teardown(): Promise { + connected = false; + tcp?.close(); + tcp = null; + if (daemon && config.manageDaemon) { + daemon.stop(); + await daemon.exited; + } + daemon = null; + log.info('Signal channel disconnected'); + }, + + isConnected(): boolean { + return connected; + }, + + async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + const content = message.content as Record | string | undefined; + let text: string | null = null; + if (typeof content === 'string') { + text = content; + } else if (content && typeof content === 'object' && typeof content.text === 'string') { + text = content.text; + } + if (!text) return undefined; + + await sendText(platformId, text); + return undefined; + }, + + async setTyping(platformId: string, _threadId: string | null): Promise { + if (!connected || !tcp) return; + if (platformId.startsWith('group:')) return; + + try { + const params: Record = { recipient: [platformId] }; + if (config.account) params.account = config.account; + await tcp.rpc('sendTyping', params); + } catch (err) { + log.debug('Signal: typing indicator failed', { platformId, err }); + } + }, + }; + + return adapter; +} + +// --------------------------------------------------------------------------- +// Self-registration +// --------------------------------------------------------------------------- + +const DEFAULT_TCP_HOST = '127.0.0.1'; +const DEFAULT_TCP_PORT = 7583; + +registerChannelAdapter('signal', { + factory: () => { + const envVars = readEnvFile([ + 'SIGNAL_ACCOUNT', + 'SIGNAL_HTTP_HOST', + 'SIGNAL_HTTP_PORT', + 'SIGNAL_MANAGE_DAEMON', + 'SIGNAL_DATA_DIR', + ]); + + const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; + if (!account) { + log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); + return null; + } + + const cliPath = 'signal-cli'; + const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; + + const signalDataDir = + process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + + if (manageDaemon && cliPath === 'signal-cli') { + try { + execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); + } catch { + log.debug('Signal: signal-cli binary not found, skipping channel'); + return null; + } + } + + return createSignalAdapter({ + cliPath, + account, + tcpHost, + tcpPort, + manageDaemon, + signalDataDir, + }); + }, +}); From bd032c2b83236e39041e4c8b9b9dae5658ff1887 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:35:59 +0000 Subject: [PATCH 21/31] chore: bump version to 2.0.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 77920c456..e358b1d20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.7", + "version": "2.0.8", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 2861009d95eaf9ffda3f587e1b1740be78a539d5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:36:03 +0000 Subject: [PATCH 22/31] =?UTF-8?q?docs:=20update=20token=20count=20to=20129?= =?UTF-8?q?k=20tokens=20=C2=B7=2064%=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 3fc904ec7..fd252673d 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 128k tokens, 64% of context window + + 129k tokens, 64% of context window @@ -15,8 +15,8 @@ tokens - - 128k + + 129k From 5d32efbce4fc49de4e827792d7cbc05ae6439a07 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 19:37:49 +0000 Subject: [PATCH 23/31] chore: bump version to 2.0.9 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e358b1d20..098e01f83 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.8", + "version": "2.0.9", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 5f3bd9c880a06881fa66896d5f182df3eb3d97d5 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:27 +0300 Subject: [PATCH 24/31] fix(signal): address review feedback from #1953 Correctness fixes: - parseSignalStyles now uses a recursive walker so nested styles (e.g. **bold with `code` inside**) produce correct offsets against the final plain text. Previous impl recorded styles against intermediate text and didn't reindex when later passes stripped prefix characters. - *single-asterisk* maps to ITALIC (was BOLD, divergent from standard Markdown). _underscore_ also maps to ITALIC. - EchoCache keys on (platformId, text) so an outbound "hi" to Alice no longer drops a real "hi" inbound from Bob. - On TCP socket close, flip adapter connected=false and log a warning so operators see lost daemon connections instead of silently failing sends. - signalTcpCheck clears its 5s timeout on success so successful checks don't leak a setTimeout handle. Config hygiene: - Rename SIGNAL_HTTP_HOST/PORT to SIGNAL_TCP_HOST/PORT (transport is TCP JSON-RPC, not HTTP) and add SIGNAL_CLI_PATH for non-PATH installs. - Remove unused readFileSync import. - Log a warning in deliver() when outbound files are dropped (native adapter doesn't forward attachments to signal-cli yet). Tests: - Nested style offset correctness - *italic* and _italic_ ITALIC mapping - Cross-recipient echo isolation - Same-recipient echo still suppressed - isConnected() flips on socket close - Outbound-files warn-and-drop path SKILL.md realigned to the add-telegram / add-whatsapp template: fetches from the `channels` branch (not a `skill/*` branch), lists pre-flight idempotency checks, adds Features / Troubleshooting sections. Added VERIFY.md and REMOVE.md siblings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-signal/REMOVE.md | 13 ++ .claude/skills/add-signal/SKILL.md | 103 ++++++++------ .claude/skills/add-signal/VERIFY.md | 5 + src/channels/signal.test.ts | 159 ++++++++++++++++++++++ src/channels/signal.ts | 199 +++++++++++++++++++--------- 5 files changed, 375 insertions(+), 104 deletions(-) create mode 100644 .claude/skills/add-signal/REMOVE.md create mode 100644 .claude/skills/add-signal/VERIFY.md diff --git a/.claude/skills/add-signal/REMOVE.md b/.claude/skills/add-signal/REMOVE.md new file mode 100644 index 000000000..db37ade8e --- /dev/null +++ b/.claude/skills/add-signal/REMOVE.md @@ -0,0 +1,13 @@ +# Remove Signal + +1. Comment out `import './signal.js'` in `src/channels/index.ts` +2. Remove `SIGNAL_ACCOUNT` (and any other `SIGNAL_*` vars) from `.env` +3. Rebuild and restart + +If you also want to unlink the Signal account from `signal-cli`: + +```bash +signal-cli -a +1YOURNUMBER removeDevice --deviceId +``` + +(Find the device id with `signal-cli -a +1YOURNUMBER listDevices`.) diff --git a/.claude/skills/add-signal/SKILL.md b/.claude/skills/add-signal/SKILL.md index 92c78004b..e6d41aa67 100644 --- a/.claude/skills/add-signal/SKILL.md +++ b/.claude/skills/add-signal/SKILL.md @@ -5,38 +5,40 @@ description: Add Signal channel integration via signal-cli TCP daemon. Native ad # Add Signal Channel -Adds Signal messaging support via a native adapter that communicates with a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon using JSON-RPC. +Adds Signal messaging support via a native adapter that speaks JSON-RPC to a [signal-cli](https://github.com/AsamK/signal-cli) TCP daemon. No Chat SDK bridge, no npm deps — only Node.js builtins. ## Prerequisites -- **signal-cli** installed and a Signal account linked - - macOS: `brew install signal-cli` - - Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) - - Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) +`signal-cli` installed and a Signal account linked: + +- macOS: `brew install signal-cli` +- Linux: download from [GitHub releases](https://github.com/AsamK/signal-cli/releases) +- Link your account: `signal-cli -a +1YOURNUMBER link` (follow the QR instructions) ## Install +NanoClaw doesn't ship channels in trunk. This skill copies the Signal adapter and its tests in from the `channels` branch. + ### Pre-flight (idempotent) Skip to **Credentials** if all of these are already in place: -- `src/channels/signal.ts` exists -- `src/channels/signal.test.ts` exists +- `src/channels/signal.ts` and `src/channels/signal.test.ts` both exist - `src/channels/index.ts` contains `import './signal.js';` Otherwise continue. Every step below is safe to re-run. -### 1. Fetch the skill branch +### 1. Fetch the channels branch ```bash -git fetch origin skill/signal +git fetch origin channels ``` ### 2. Copy the adapter and tests ```bash -git show origin/skill/signal:src/channels/signal.ts > src/channels/signal.ts -git show origin/skill/signal:src/channels/signal.test.ts > src/channels/signal.test.ts +git show origin/channels:src/channels/signal.ts > src/channels/signal.ts +git show origin/channels:src/channels/signal.test.ts > src/channels/signal.test.ts ``` ### 3. Append the self-registration import @@ -59,30 +61,31 @@ No npm packages to install — the adapter uses only Node.js builtins (`node:net Add to `.env`: -```env +```bash SIGNAL_ACCOUNT=+1YOURNUMBER ``` ### Optional settings -```env +```bash # TCP daemon host and port (default: 127.0.0.1:7583) -SIGNAL_HTTP_HOST=127.0.0.1 -SIGNAL_HTTP_PORT=7583 +SIGNAL_TCP_HOST=127.0.0.1 +SIGNAL_TCP_PORT=7583 -# Whether NanoClaw manages the daemon lifecycle (default: true) -# Set to false if you run signal-cli daemon externally +# Path to the signal-cli binary (default: resolved on PATH) +SIGNAL_CLI_PATH=/usr/local/bin/signal-cli + +# Whether NanoClaw manages the daemon lifecycle (default: true). +# Set to false if you run signal-cli daemon externally. SIGNAL_MANAGE_DAEMON=true # signal-cli data directory (default: ~/.local/share/signal-cli) SIGNAL_DATA_DIR=~/.local/share/signal-cli ``` -### Sync to container +**Security note:** keep the TCP host on `127.0.0.1`. The daemon has no auth — binding it to a public interface would expose your full Signal account to the network. -```bash -mkdir -p data/env && cp .env data/env/env -``` +Sync to container: `mkdir -p data/env && cp .env data/env/env` ### Restart @@ -96,26 +99,50 @@ systemctl --user restart nanoclaw ## Next Steps -Run `/init-first-agent` to create an agent and wire it to your Signal DM. Signal is direct-addressable — your phone number is the platform ID: +If you're in the middle of `/setup`, return to the setup flow now. -- **User ID**: your Signal phone number (e.g. `+15551234567`) -- **Platform ID**: same as user ID for DMs (e.g. `+15551234567`) -- **For group chats**: use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups` - -`/init-first-agent` handles user creation, owner role, agent group, messaging group wiring, and the welcome DM. It's idempotent — safe to run again for additional agents. +Otherwise, run `/init-first-agent` to create an agent and wire it to your Signal DM, or `/manage-channels` to wire this channel to an existing agent group. Signal is direct-addressable — your phone number is the platform ID. ## Channel Info -| Field | Value | -|-------|-------| -| **Type** | `signal` | -| **Thread support** | No (Signal has no thread model) | -| **Platform ID format** | DM: `+15555550123` / Group: `group:` | -| **Mention detection** | Text-match against agent group name (no SDK-level mentions) | -| **Typing indicators** | DMs only | -| **Typical use** | Personal assistant via Signal DMs or small group chats | -| **Isolation** | Recommended: one agent per Signal account | +- **type**: `signal` +- **terminology**: Signal has "chats" (1:1 DMs) and "groups." +- **how-to-find-id**: DMs use your phone number (e.g. `+15555550123`). Groups use `group:` — find group IDs via `signal-cli -a +1YOURNUMBER listGroups`. +- **supports-threads**: no +- **typical-use**: Personal assistant via Signal DMs or small group chats +- **default-isolation**: One agent per Signal account. Multiple chats with the same operator can share an agent group; groups with other people should typically be separate. -### Voice Messages +### Features -Voice attachments are detected but not transcribed by default. The agent receives `[Voice Message]` as the message text. Run `/add-voice-transcription` to enable automatic local transcription via parakeet-mlx. +- Markdown formatting — `**bold**`, `*italic*` / `_italic_`, `` `code` ``, ` ```code fence``` `, `~~strike~~`, `||spoiler||` (converted to Signal's offset-based text styles) +- Quoted replies — `replyTo*` fields populated from Signal quotes +- Typing indicators — DMs only (Signal doesn't support group typing) +- Echo suppression — outbound messages are matched on `(platformId, text)` within a 10 s TTL to avoid syncMessage loops +- Note to Self — messages you send to your own account from another device route to the agent as inbound with `isFromMe: true` +- Voice attachments — detected but not transcribed by default; the agent receives `[Voice Message]` placeholder text. Run `/add-voice-transcription` for local transcription via parakeet-mlx + +Not supported yet: outbound file attachments (logged and dropped), edit/delete messages, reactions. + +## Troubleshooting + +### Daemon not reachable + +```bash +grep "Signal" logs/nanoclaw.log | tail +``` + +If you see `Signal daemon failed to start. Is signal-cli installed and your account linked?`: +- Confirm `signal-cli` is on PATH (or set `SIGNAL_CLI_PATH`) +- Confirm the account is linked: `signal-cli -a +1YOURNUMBER listIdentities` should succeed without prompting + +If you see `Signal daemon not reachable at 127.0.0.1:7583` and `SIGNAL_MANAGE_DAEMON=false`, start the daemon yourself: `signal-cli -a +1YOURNUMBER daemon --tcp 127.0.0.1:7583`. + +### Bot not responding + +1. Channel initialized: `grep "Signal channel connected" logs/nanoclaw.log | tail -1` +2. Channel wired: `sqlite3 data/v2.db "SELECT mg.platform_id, mg.name FROM messaging_groups mg JOIN messaging_group_agents mga ON mg.id = mga.messaging_group_id WHERE mg.channel_type='signal'"` +3. Service running: `launchctl print gui/$(id -u)/com.nanoclaw` (macOS) / `systemctl --user status nanoclaw` (Linux) + +### Lost connection mid-session + +If you see `Signal channel lost TCP connection to signal-cli daemon` in the logs, the daemon dropped us. There's no auto-reconnect yet — restart the service to re-establish. diff --git a/.claude/skills/add-signal/VERIFY.md b/.claude/skills/add-signal/VERIFY.md new file mode 100644 index 000000000..b1ae8518c --- /dev/null +++ b/.claude/skills/add-signal/VERIFY.md @@ -0,0 +1,5 @@ +# Verify Signal + +Send a message to your own Signal number (Note to Self) from another device, or have someone send your linked number a DM. The bot should respond within a few seconds. + +If nothing happens, tail `logs/nanoclaw.log` for `Signal channel connected` and `Signal message received`. diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts index c7ffff1d7..f5dabfa52 100644 --- a/src/channels/signal.test.ts +++ b/src/channels/signal.test.ts @@ -583,6 +583,165 @@ describe('SignalAdapter', () => { await adapter.teardown(); }); + + it('tracks nested styles with correct offsets', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: '**bold with `code` inside**' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('bold with code inside'); + // BOLD covers the full inner span, MONOSPACE points at "code" in the + // final plain text (offset 10, length 4) — not the intermediate text. + const styles = (last.params.textStyle as string[]).slice().sort(); + expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); + + await adapter.teardown(); + }); + + it('maps *single-asterisk* to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello *world*' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('Hello world'); + expect(last.params.textStyle).toEqual(['6:5:ITALIC']); + + await adapter.teardown(); + }); + + it('maps _underscore_ to ITALIC', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + tcpRef.fakeSocket.write.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'hey _there_' }, + }); + + const sendCalls = getRpcCallsForMethod('send'); + const last = sendCalls[sendCalls.length - 1]; + expect(last.params.message).toBe('hey there'); + expect(last.params.textStyle).toEqual(['4:5:ITALIC']); + + await adapter.teardown(); + }); + }); + + // --- Echo cache --- + + describe('echo cache', () => { + it('does not drop same-text inbound from a different recipient', async () => { + // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from + // a different DM. Bob's message must still route — the earlier echo key + // was scoped to Alice. + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Hello' }, + }); + + pushEvent({ + sourceNumber: '+15555550999', + sourceName: 'Bob', + dataMessage: { timestamp: 1700000000000, message: 'Hello' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).toHaveBeenCalledWith( + '+15555550999', + null, + expect.objectContaining({ + content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), + }), + ); + + await adapter.teardown(); + }); + + it('still skips echo on the same recipient', async () => { + const adapter = createAdapter(); + const cfg = createMockSetup(); + await adapter.setup(cfg); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'Echo test' }, + }); + + pushEvent({ + sourceNumber: '+15555550123', + dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(cfg.onInbound).not.toHaveBeenCalled(); + + await adapter.teardown(); + }); + }); + + // --- Connection drop --- + + describe('connection drop', () => { + it('flips isConnected to false when the socket closes', async () => { + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + expect(adapter.isConnected()).toBe(true); + + // Simulate the daemon dropping the TCP connection. + tcpRef.fakeSocket.destroy(); + await new Promise((r) => setTimeout(r, 20)); + + expect(adapter.isConnected()).toBe(false); + + await adapter.teardown(); + }); + }); + + // --- Outbound files --- + + describe('outbound files', () => { + it('logs a warning and drops unsupported file attachments', async () => { + const { log } = await import('../log.js'); + const warnMock = log.warn as unknown as ReturnType; + + const adapter = createAdapter(); + await adapter.setup(createMockSetup()); + warnMock.mockClear(); + + await adapter.deliver('+15555550123', null, { + kind: 'text', + content: { text: 'with an attachment' }, + files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], + }); + + const sendCalls = getRpcCallsForMethod('send'); + expect(sendCalls.length).toBeGreaterThan(0); + expect(warnMock).toHaveBeenCalledWith( + 'Signal: outbound files not supported, dropping', + expect.objectContaining({ platformId: '+15555550123', count: 1 }), + ); + + await adapter.teardown(); + }); }); // --- setTyping --- diff --git a/src/channels/signal.ts b/src/channels/signal.ts index 300b7a6d0..20cba8162 100644 --- a/src/channels/signal.ts +++ b/src/channels/signal.ts @@ -8,7 +8,7 @@ * Ported from v1 — see v1 source for commit history. */ import { execFileSync, spawn } from 'node:child_process'; -import { readFileSync, existsSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { createConnection, type Socket } from 'node:net'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -100,14 +100,19 @@ class SignalTcpClient { } >(); private onNotification: ((method: string, params: unknown) => void) | null = null; + private onClose: (() => void) | null = null; constructor( private host: string, private port: number, ) {} - connect(onNotification?: (method: string, params: unknown) => void): Promise { - this.onNotification = onNotification ?? null; + connect(handlers?: { + onNotification?: (method: string, params: unknown) => void; + onClose?: () => void; + }): Promise { + this.onNotification = handlers?.onNotification ?? null; + this.onClose = handlers?.onClose ?? null; return new Promise((resolve, reject) => { const sock = createConnection(this.port, this.host, () => { this.socket = sock; @@ -122,12 +127,14 @@ class SignalTcpClient { }); sock.on('data', (chunk) => this.onData(chunk)); sock.on('close', () => { + const wasConnected = this.socket !== null; this.socket = null; for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('Signal TCP connection closed')); } this.pending.clear(); + if (wasConnected) this.onClose?.(); }); }); } @@ -201,15 +208,17 @@ class SignalTcpClient { async function signalTcpCheck(host: string, port: number): Promise { return new Promise((resolve) => { - const sock = createConnection(port, host, () => { + let settled = false; + const finish = (result: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); sock.destroy(); - resolve(true); - }); - sock.on('error', () => resolve(false)); - setTimeout(() => { - sock.destroy(); - resolve(false); - }, 5000); + resolve(result); + }; + const sock = createConnection(port, host, () => finish(true)); + sock.on('error', () => finish(false)); + const timer = setTimeout(() => finish(false), 5000); }); } @@ -219,19 +228,35 @@ async function signalTcpCheck(host: string, port: number): Promise { const ECHO_TTL_MS = 10_000; +/** + * Per-recipient dedup for messages we sent ourselves. + * + * signal-cli echoes our own outbound back via syncMessage (and, for Note to + * Self, via sentMessage-with-self-destination). Without dedup, the agent sees + * its own replies as new inbound and loops. We remember `(platformId, text)` + * briefly after every send, and drop the first match within TTL. + * + * Keying on text alone is not enough: if we send "hi" to Alice and Bob then + * sends "hi" from a different chat, Bob's real message gets silently dropped. + */ class EchoCache { private entries = new Map(); - remember(text: string) { - const key = text.trim(); - if (!key) return; - this.entries.set(key, Date.now()); + private keyFor(platformId: string, text: string): string { + return `${platformId}\x00${text.trim()}`; + } + + remember(platformId: string, text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.entries.set(this.keyFor(platformId, trimmed), Date.now()); this.cleanup(); } - isEcho(text: string): boolean { - const key = text.trim(); - if (!key) return false; + isEcho(platformId: string, text: string): boolean { + const trimmed = text.trim(); + if (!trimmed) return false; + const key = this.keyFor(platformId, trimmed); const ts = this.entries.get(key); if (!ts) return false; if (Date.now() - ts > ECHO_TTL_MS) { @@ -242,7 +267,7 @@ class EchoCache { return true; } - private cleanup() { + private cleanup(): void { const now = Date.now(); for (const [key, ts] of this.entries) { if (now - ts > ECHO_TTL_MS) this.entries.delete(key); @@ -325,49 +350,61 @@ interface StyledText { textStyles: SignalTextStyle[]; } +/** + * Convert Markdown-ish input to Signal's offset-based style ranges. + * + * Walks the input recursively: at each level we find the leftmost matching + * pattern, descend into its captured inner text (so `**bold with \`code\` + * inside**` stays bold-plus-monospace rather than leaking stripped markers), + * then continue past the match. Style offsets are recorded against the + * *output* text length as it's built, so nested styles always point at the + * right span of the final plain text. + */ function parseSignalStyles(input: string): StyledText { const styles: SignalTextStyle[] = []; - const patterns: Array<{ - regex: RegExp; - style: SignalTextStyle['style']; - }> = [ - { regex: /```([\s\S]*?)```/g, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/g, style: 'MONOSPACE' }, - { regex: /\*\*(.+?)\*\*/g, style: 'BOLD' }, - { regex: /\*(.+?)\*/g, style: 'BOLD' }, - { regex: /_(.+?)_/g, style: 'ITALIC' }, - { regex: /~~(.+?)~~/g, style: 'STRIKETHROUGH' }, - { regex: /\|\|(.+?)\|\|/g, style: 'SPOILER' }, + // Ordering matters: longer/greedier delimiters first so `` ``` `` beats + // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on + // whitespace so `*` isn't mistakenly opened on " * " in list-like text. + const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ + { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, + { regex: /`([^`]+)`/, style: 'MONOSPACE' }, + { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, + { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, + { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, + { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, + { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, ]; - let text = input; - - for (const { regex, style } of patterns) { - const nextText: string[] = []; - let lastIndex = 0; - let offset = 0; - - for (const match of text.matchAll(regex)) { - const fullMatch = match[0]; - const innerText = match[1]; - const matchStart = match.index!; - - nextText.push(text.slice(lastIndex, matchStart)); - const plainStart = matchStart - offset; - - nextText.push(innerText); - styles.push({ style, start: plainStart, length: innerText.length }); - - const stripped = fullMatch.length - innerText.length; - offset += stripped; - lastIndex = matchStart + fullMatch.length; + function walk(segment: string, outputBase: number): string { + let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; + for (const { regex, style } of patterns) { + const m = regex.exec(segment); + if (!m) continue; + if (earliest === null || m.index < earliest.start) { + earliest = { start: m.index, match: m, style }; + } } + if (!earliest) return segment; - nextText.push(text.slice(lastIndex)); - text = nextText.join(''); + const before = segment.slice(0, earliest.start); + const fullMatch = earliest.match[0]; + const inner = earliest.match[1]; + const afterStart = earliest.start + fullMatch.length; + const after = segment.slice(afterStart); + + const innerOut = walk(inner, outputBase + before.length); + styles.push({ + style: earliest.style, + start: outputBase + before.length, + length: innerOut.length, + }); + const afterOut = walk(after, outputBase + before.length + innerOut.length); + + return before + innerOut + afterOut; } + const text = walk(input, 0); return { text, textStyles: styles }; } @@ -421,8 +458,8 @@ export function createSignalAdapter(config: { if (dest === config.account) { const text = (syncSent.message ?? '').trim(); if (!text) return; - if (echoCache.isEcho(text)) return; const platformId = config.account; + if (echoCache.isEcho(platformId, text)) return; const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); setup.onMetadata(platformId, 'Note to Self', false); @@ -460,17 +497,17 @@ export function createSignalAdapter(config: { const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); if (!sender) return; - if (text && echoCache.isEcho(text)) { - log.debug('Signal: skipping echo'); - return; - } - const senderName = (envelope.sourceName?.trim() || sender).trim(); const groupInfo = dataMessage.groupInfo; const isGroup = Boolean(groupInfo?.groupId); const groupId = groupInfo?.groupId; const platformId = isGroup ? `group:${groupId}` : sender; + + if (text && echoCache.isEcho(platformId, text)) { + log.debug('Signal: skipping echo', { platformId }); + return; + } const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); @@ -534,7 +571,7 @@ export function createSignalAdapter(config: { async function sendText(platformId: string, text: string): Promise { if (!connected || !tcp) return; - echoCache.remember(text); + echoCache.remember(platformId, text); const MAX_CHUNK = 4000; const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); @@ -617,7 +654,22 @@ export function createSignalAdapter(config: { } tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect(handleNotification); + await tcp.connect({ + onNotification: handleNotification, + // Signal the adapter that the daemon dropped us. No auto-reconnect yet + // — subsequent deliver/setTyping calls short-circuit on `connected` + // and log rather than throw into the retry loop. Operators see this in + // logs/nanoclaw.log and can restart the service. + onClose: () => { + if (!connected) return; + connected = false; + log.warn('Signal channel lost TCP connection to signal-cli daemon', { + account: config.account, + host: config.tcpHost, + port: config.tcpPort, + }); + }, + }); try { await tcp.rpc('updateProfile', { @@ -662,6 +714,17 @@ export function createSignalAdapter(config: { }, async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { + if (message.files && message.files.length > 0) { + // Native adapter doesn't yet forward file uploads to signal-cli's + // `send --attachment`. Don't silently swallow — operators need to see + // that an attachment was requested but not sent. + log.warn('Signal: outbound files not supported, dropping', { + platformId, + count: message.files.length, + filenames: message.files.map((f) => f.filename), + }); + } + const content = message.content as Record | string | undefined; let text: string | null = null; if (typeof content === 'string') { @@ -703,8 +766,9 @@ registerChannelAdapter('signal', { factory: () => { const envVars = readEnvFile([ 'SIGNAL_ACCOUNT', - 'SIGNAL_HTTP_HOST', - 'SIGNAL_HTTP_PORT', + 'SIGNAL_TCP_HOST', + 'SIGNAL_TCP_PORT', + 'SIGNAL_CLI_PATH', 'SIGNAL_MANAGE_DAEMON', 'SIGNAL_DATA_DIR', ]); @@ -715,14 +779,17 @@ registerChannelAdapter('signal', { return null; } - const cliPath = 'signal-cli'; - const tcpHost = process.env.SIGNAL_HTTP_HOST || envVars.SIGNAL_HTTP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_HTTP_PORT || envVars.SIGNAL_HTTP_PORT || String(DEFAULT_TCP_PORT), 10); + const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; + const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; + const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; const signalDataDir = process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); + // Only check for `signal-cli` on PATH when the operator left cliPath at + // the default AND asked us to manage the daemon. A custom absolute path + // is treated as an explicit promise and spawn will surface its own ENOENT. if (manageDaemon && cliPath === 'signal-cli') { try { execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); From f351e460083b6128a9ee6d8d5108bb17706bd0fc Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:54:47 +0300 Subject: [PATCH 25/31] refactor(approvals): persist title+options on channel/sender approval tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getAskQuestionRender used to hardcode the card title and option labels for pending_channel_approvals and pending_sender_approvals in the DB-access layer, duplicating wording that already lived in the approval modules. That caused a visible drift between the initial card title — picked per event in channel-approval.ts ("📣 Bot mentioned in new chat" vs. "💬 New direct message") — and the post-click render, which always showed the constant "📣 Channel registration". Mirror the pattern already used by pending_approvals: add title / options_json columns on both pending_*_approvals tables via migration 013, have the approval modules write them at creation time, and let getAskQuestionRender just SELECT. - Migration 013 ALTERs the two tables to add title + options_json. - PendingChannelApproval / PendingSenderApproval types and their create functions grow the two fields. - channel-approval.ts / sender-approval.ts normalize options once and pass both title and options_json into the insert. - getAskQuestionRender drops the hardcoded render objects and reads the stored values. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../013-approval-render-metadata.ts | 27 ++++++++++++++++ src/db/migrations/index.ts | 2 ++ src/db/sessions.ts | 32 ++++++------------- src/modules/permissions/channel-approval.ts | 5 ++- .../db/pending-channel-approvals.ts | 8 +++-- .../db/pending-sender-approvals.ts | 10 ++++-- src/modules/permissions/sender-approval.ts | 5 ++- 7 files changed, 61 insertions(+), 28 deletions(-) create mode 100644 src/db/migrations/013-approval-render-metadata.ts diff --git a/src/db/migrations/013-approval-render-metadata.ts b/src/db/migrations/013-approval-render-metadata.ts new file mode 100644 index 000000000..3a1af2828 --- /dev/null +++ b/src/db/migrations/013-approval-render-metadata.ts @@ -0,0 +1,27 @@ +/** + * Persist ask_question render metadata (title + options_json) on + * `pending_channel_approvals` and `pending_sender_approvals`, mirroring the + * columns migration 003 / module-approvals-title-options added to + * `pending_approvals`. + * + * Before this, `getAskQuestionRender` hardcoded the title + option labels + * for these two tables in the DB-access layer — duplicating wording that + * also lived in the approval modules and causing a visible drift between + * the initial card title ("📣 Bot mentioned in new chat" / "💬 New direct + * message", chosen per event) and the post-click render ("📣 Channel + * registration", constant). Storing the render metadata alongside the row + * lets both sides read from the same source. + */ +import type Database from 'better-sqlite3'; +import type { Migration } from './index.js'; + +export const migration013: Migration = { + version: 13, + name: 'approval-render-metadata', + up(db: Database.Database) { + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_channel_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN title TEXT NOT NULL DEFAULT ''`); + db.exec(`ALTER TABLE pending_sender_approvals ADD COLUMN options_json TEXT NOT NULL DEFAULT '[]'`); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index 33e6963a9..b46e6787c 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -9,6 +9,7 @@ import { migration009 } from './009-drop-pending-credentials.js'; import { migration010 } from './010-engage-modes.js'; import { migration011 } from './011-pending-sender-approvals.js'; import { migration012 } from './012-channel-registration.js'; +import { migration013 } from './013-approval-render-metadata.js'; import { moduleApprovalsPendingApprovals } from './module-approvals-pending-approvals.js'; import { moduleApprovalsTitleOptions } from './module-approvals-title-options.js'; @@ -29,6 +30,7 @@ const migrations: Migration[] = [ migration010, migration011, migration012, + migration013, ]; export function runMigrations(db: Database.Database): void { diff --git a/src/db/sessions.ts b/src/db/sessions.ts index e9461ca18..5c53ad5d2 100644 --- a/src/db/sessions.ts +++ b/src/db/sessions.ts @@ -194,32 +194,20 @@ export function getAskQuestionRender( | undefined; if (a?.title) return { title: a.title, options: JSON.parse(a.options_json) }; - // Channel-registration approval — options are fixed constants. + // Channel-registration + unknown-sender approvals persist title/options_json + // the same way pending_approvals does — just SELECT and return. if (hasTable(getDb(), 'pending_channel_approvals')) { - const c = getDb().prepare('SELECT 1 FROM pending_channel_approvals WHERE messaging_group_id = ?').get(id); - if (c) { - return { - title: '📣 Channel registration', - options: [ - { label: 'Approve', selectedLabel: '✅ Wired', value: 'approve' }, - { label: 'Ignore', selectedLabel: '🙅 Ignored', value: 'reject' }, - ], - }; - } + const c = getDb() + .prepare('SELECT title, options_json FROM pending_channel_approvals WHERE messaging_group_id = ?') + .get(id) as { title: string; options_json: string } | undefined; + if (c?.title) return { title: c.title, options: JSON.parse(c.options_json) }; } - // Unknown-sender approval — options are fixed constants. if (hasTable(getDb(), 'pending_sender_approvals')) { - const s = getDb().prepare('SELECT 1 FROM pending_sender_approvals WHERE id = ?').get(id); - if (s) { - return { - title: '👤 New sender', - options: [ - { label: 'Allow', selectedLabel: '✅ Allowed', value: 'approve' }, - { label: 'Deny', selectedLabel: '❌ Denied', value: 'reject' }, - ], - }; - } + const s = getDb().prepare('SELECT title, options_json FROM pending_sender_approvals WHERE id = ?').get(id) as + | { title: string; options_json: string } + | undefined; + if (s?.title) return { title: s.title, options: JSON.parse(s.options_json) }; } return undefined; diff --git a/src/modules/permissions/channel-approval.ts b/src/modules/permissions/channel-approval.ts index e4b2142d7..8ab41bc62 100644 --- a/src/modules/permissions/channel-approval.ts +++ b/src/modules/permissions/channel-approval.ts @@ -120,6 +120,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) : senderName ? `${senderName} DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?` : `Someone DM'd your agent on ${originChannelType}. Wire it to ${target.name} and let it respond?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingChannelApproval({ messaging_group_id: messagingGroupId, @@ -127,6 +128,8 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) original_message: JSON.stringify(event), approver_user_id: delivery.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -151,7 +154,7 @@ export async function requestChannelApproval(input: RequestChannelApprovalInput) questionId: messagingGroupId, title, question, - options: normalizeOptions(APPROVAL_OPTIONS), + options, }), ); log.info('Channel registration card delivered', { diff --git a/src/modules/permissions/db/pending-channel-approvals.ts b/src/modules/permissions/db/pending-channel-approvals.ts index d3e665ad2..d402074b7 100644 --- a/src/modules/permissions/db/pending-channel-approvals.ts +++ b/src/modules/permissions/db/pending-channel-approvals.ts @@ -17,6 +17,10 @@ export interface PendingChannelApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingChannelApproval(row: PendingChannelApproval): void { @@ -24,11 +28,11 @@ export function createPendingChannelApproval(row: PendingChannelApproval): void .prepare( `INSERT INTO pending_channel_approvals ( messaging_group_id, agent_group_id, original_message, - approver_user_id, created_at + approver_user_id, created_at, title, options_json ) VALUES ( @messaging_group_id, @agent_group_id, @original_message, - @approver_user_id, @created_at + @approver_user_id, @created_at, @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/db/pending-sender-approvals.ts b/src/modules/permissions/db/pending-sender-approvals.ts index 77a5699af..4d32bf4fa 100644 --- a/src/modules/permissions/db/pending-sender-approvals.ts +++ b/src/modules/permissions/db/pending-sender-approvals.ts @@ -19,6 +19,10 @@ export interface PendingSenderApproval { original_message: string; approver_user_id: string; created_at: string; + /** Card title shown at creation and re-used by getAskQuestionRender on click. */ + title: string; + /** Normalized options (JSON-encoded NormalizedOption[]) — same shape persisted on pending_approvals. */ + options_json: string; } export function createPendingSenderApproval(row: PendingSenderApproval): void { @@ -26,11 +30,13 @@ export function createPendingSenderApproval(row: PendingSenderApproval): void { .prepare( `INSERT INTO pending_sender_approvals ( id, messaging_group_id, agent_group_id, sender_identity, - sender_name, original_message, approver_user_id, created_at + sender_name, original_message, approver_user_id, created_at, + title, options_json ) VALUES ( @id, @messaging_group_id, @agent_group_id, @sender_identity, - @sender_name, @original_message, @approver_user_id, @created_at + @sender_name, @original_message, @approver_user_id, @created_at, + @title, @options_json )`, ) .run(row); diff --git a/src/modules/permissions/sender-approval.ts b/src/modules/permissions/sender-approval.ts index a20e14f3e..fb3e24e01 100644 --- a/src/modules/permissions/sender-approval.ts +++ b/src/modules/permissions/sender-approval.ts @@ -92,6 +92,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): const title = '👤 New sender'; const question = `${senderDisplay} wants to talk to your agent in ${originName}. Allow?`; + const options = normalizeOptions(APPROVAL_OPTIONS); createPendingSenderApproval({ id: approvalId, @@ -102,6 +103,8 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): original_message: JSON.stringify(event), approver_user_id: target.userId, created_at: new Date().toISOString(), + title, + options_json: JSON.stringify(options), }); const adapter = getDeliveryAdapter(); @@ -126,7 +129,7 @@ export async function requestSenderApproval(input: RequestSenderApprovalInput): questionId: approvalId, title, question, - options: APPROVAL_OPTIONS, + options, }), ); log.info('Unknown-sender approval card delivered', { From 2fd2bf3bdee3405b96e4db19ed71a771d36a588c Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 22:56:31 +0300 Subject: [PATCH 26/31] chore(signal): move adapter source to channels branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal adapter source (src/channels/signal.ts + signal.test.ts) now lives on the `channels` branch alongside all other channel adapters, per the trunk/channels split documented in CLAUDE.md and CONTRIBUTING.md ("Trunk does not ship any specific channel adapter"). The /add-signal skill fetches the file from origin/channels like every other channel. This PR to main therefore carries only: - .claude/skills/add-signal/{SKILL,VERIFY,REMOVE}.md — the skill itself - scripts/init-first-agent.ts — unrelated infra fix that benefits any native-ID channel (Signal, WhatsApp) by skipping the channel-prefix on platform IDs that already have their own format The fixed adapter source + tests were pushed to the channels branch in a parallel commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/channels/index.ts | 1 - src/channels/signal.test.ts | 786 ---------------------------------- src/channels/signal.ts | 811 ------------------------------------ 3 files changed, 1598 deletions(-) delete mode 100644 src/channels/signal.test.ts delete mode 100644 src/channels/signal.ts diff --git a/src/channels/index.ts b/src/channels/index.ts index b75016f68..e9b3bd1b7 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -7,4 +7,3 @@ // self-registration import below. import './cli.js'; -import './signal.js'; diff --git a/src/channels/signal.test.ts b/src/channels/signal.test.ts deleted file mode 100644 index f5dabfa52..000000000 --- a/src/channels/signal.test.ts +++ /dev/null @@ -1,786 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; - -// --- Mocks --- - -vi.mock('./channel-registry.js', () => ({ registerChannelAdapter: vi.fn() })); -vi.mock('../env.js', () => ({ readEnvFile: vi.fn(() => ({})) })); -vi.mock('../log.js', () => ({ - log: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }, -})); - -vi.mock('node:child_process', () => ({ - spawn: vi.fn(), - execFileSync: vi.fn(), -})); - -// --- TCP socket mock --- - -import { EventEmitter } from 'events'; - -const tcpRef = vi.hoisted(() => ({ - rpcResponses: new Map(), - fakeSocket: null as any, -})); - -function createFakeSocket(): EventEmitter & { - write: ReturnType; - destroy: ReturnType; - destroyed: boolean; -} { - const sock = new EventEmitter() as any; - sock.destroyed = false; - sock.destroy = vi.fn(() => { - sock.destroyed = true; - sock.emit('close'); - }); - sock.write = vi.fn((data: string) => { - try { - const req = JSON.parse(data.trim()); - const result = tcpRef.rpcResponses.get(req.method) ?? { ok: true }; - const response = JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'; - setImmediate(() => sock.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - return sock; -} - -vi.mock('node:net', () => ({ - createConnection: vi.fn((_port: number, _host: string, cb?: () => void) => { - const sock = createFakeSocket(); - tcpRef.fakeSocket = sock; - if (cb) setImmediate(cb); - return sock; - }), -})); - -import type { ChannelSetup } from './adapter.js'; -import { createSignalAdapter } from './signal.js'; - -// --- Test helpers --- - -function createMockSetup() { - return { - onInbound: vi.fn() as unknown as ChannelSetup['onInbound'] & ReturnType, - onInboundEvent: vi.fn() as unknown as ChannelSetup['onInboundEvent'] & ReturnType, - onMetadata: vi.fn() as unknown as ChannelSetup['onMetadata'] & ReturnType, - onAction: vi.fn() as unknown as ChannelSetup['onAction'] & ReturnType, - }; -} - -function createAdapter() { - return createSignalAdapter({ - cliPath: 'signal-cli', - account: '+15551234567', - tcpHost: '127.0.0.1', - tcpPort: 7583, - manageDaemon: false, - signalDataDir: '/tmp/signal-cli-test-data', - }); -} - -function getRpcCalls(): Array<{ - method: string; - params: Record; - id: string; -}> { - if (!tcpRef.fakeSocket) return []; - return tcpRef.fakeSocket.write.mock.calls - .map((c: any[]) => { - try { - return JSON.parse(c[0].trim()); - } catch { - return null; - } - }) - .filter(Boolean); -} - -function getRpcCallsForMethod(method: string) { - return getRpcCalls().filter((c) => c.method === method); -} - -function pushEvent(envelope: Record) { - if (!tcpRef.fakeSocket) throw new Error('TCP socket not connected'); - const notification = - JSON.stringify({ - jsonrpc: '2.0', - method: 'receive', - params: { envelope }, - }) + '\n'; - tcpRef.fakeSocket.emit('data', Buffer.from(notification)); -} - -// --- Tests --- - -describe('SignalAdapter', () => { - beforeEach(() => { - vi.clearAllMocks(); - tcpRef.rpcResponses.clear(); - tcpRef.fakeSocket = null; - tcpRef.rpcResponses.set('send', { timestamp: 1234567890 }); - tcpRef.rpcResponses.set('sendTyping', {}); - }); - - afterEach(() => { - try { - tcpRef.fakeSocket?.destroy(); - } catch { - // already closed - } - }); - - // --- Connection lifecycle --- - - describe('connection lifecycle', () => { - it('connects when daemon is reachable', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - expect(adapter.isConnected()).toBe(true); - expect(tcpRef.fakeSocket).not.toBeNull(); - - await adapter.teardown(); - }); - - it('isConnected() returns false before setup', () => { - const adapter = createAdapter(); - expect(adapter.isConnected()).toBe(false); - }); - - it('disconnects cleanly', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - await adapter.teardown(); - expect(adapter.isConnected()).toBe(false); - }); - - it('throws NetworkError if daemon is unreachable', async () => { - const { createConnection } = await import('node:net'); - vi.mocked(createConnection).mockImplementationOnce((...args: any[]) => { - const sock = createFakeSocket(); - setImmediate(() => sock.emit('error', new Error('Connection refused'))); - return sock as any; - }); - - const adapter = createAdapter(); - await expect(adapter.setup(createMockSetup())).rejects.toThrow(/not reachable/); - }); - }); - - // --- Inbound message handling --- - - describe('inbound message handling', () => { - it('delivers DM via onInbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'Hello from Signal', - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('+15555550123', 'Alice', false); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - id: '1700000000000', - kind: 'chat', - content: expect.objectContaining({ - text: 'Hello from Signal', - sender: '+15555550123', - senderName: 'Alice', - }), - }), - ); - - await adapter.teardown(); - }); - - it('delivers group message with group platformId', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { - timestamp: 1700000000000, - message: 'Group hello', - groupInfo: { groupId: 'abc123', groupName: 'Family' }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - - expect(cfg.onMetadata).toHaveBeenCalledWith('group:abc123', 'Family', true); - expect(cfg.onInbound).toHaveBeenCalledWith( - 'group:abc123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Group hello', - sender: '+15555550999', - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips sync messages (own outbound)', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'My own message', - destination: '+15555550123', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('processes Note to Self sync messages as inbound', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15551234567', - syncMessage: { - sentMessage: { - timestamp: 1700000000000, - message: 'Hello Bee', - destinationNumber: '+15551234567', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15551234567', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'Hello Bee', - senderName: 'Me', - isFromMe: true, - }), - }), - ); - - await adapter.teardown(); - }); - - it('skips empty messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: ' ' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips echoed outbound messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - - it('skips messages with attachments but no text', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - attachments: [{ id: 'att123abc', contentType: 'image/jpeg', size: 50000 }], - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Quote context --- - - describe('quote context', () => { - it('populates reply_to fields from quoted messages', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - pushEvent({ - sourceNumber: '+15555550123', - sourceName: 'Alice', - dataMessage: { - timestamp: 1700000000000, - message: 'I disagree', - quote: { - id: 1699999999000, - authorNumber: '+15555550888', - text: 'Pineapple belongs on pizza', - }, - }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550123', - null, - expect.objectContaining({ - content: expect.objectContaining({ - text: 'I disagree', - replyToSenderName: '+15555550888', - replyToMessageContent: 'Pineapple belongs on pizza', - replyToMessageId: '1699999999000', - }), - }), - ); - - await adapter.teardown(); - }); - }); - - // --- deliver --- - - describe('deliver', () => { - it('sends DM via TCP RPC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - recipient: ['+15555550123'], - message: 'Hello', - account: '+15551234567', - }), - ); - - await adapter.teardown(); - }); - - it('sends group message via groupId', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('group:abc123', null, { - kind: 'text', - content: { text: 'Group msg' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params).toEqual( - expect.objectContaining({ - groupId: 'abc123', - message: 'Group msg', - }), - ); - - await adapter.teardown(); - }); - - it('chunks long messages', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - const longText = 'x'.repeat(5000); - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: longText }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(1); - - await adapter.teardown(); - }); - - it('extracts text from string content', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: 'Plain string content', - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Plain string content'); - - await adapter.teardown(); - }); - }); - - // --- Text styles --- - - describe('text styles', () => { - it('sends bold text with textStyle parameter', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:BOLD']); - - await adapter.teardown(); - }); - - it('sends inline code with MONOSPACE style', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Run `npm test` now' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Run npm test now'); - expect(last.params.textStyle).toEqual(['4:8:MONOSPACE']); - - await adapter.teardown(); - }); - - it('sends plain text without textStyle', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'No formatting here' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('No formatting here'); - expect(last.params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('falls back to original markup when textStyle is rejected', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - let sendCount = 0; - tcpRef.fakeSocket.write.mockImplementation((data: string) => { - try { - const req = JSON.parse(data.trim()); - if (req.method === 'send') { - sendCount++; - if (sendCount === 1) { - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - error: { message: 'Unknown parameter: textStyle' }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - return; - } - } - const response = - JSON.stringify({ - jsonrpc: '2.0', - id: req.id, - result: { ok: true }, - }) + '\n'; - setImmediate(() => tcpRef.fakeSocket.emit('data', Buffer.from(response))); - } catch { - /* ignore */ - } - }); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello **world**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBe(2); - expect(sendCalls[1].params.message).toBe('Hello **world**'); - expect(sendCalls[1].params.textStyle).toBeUndefined(); - - await adapter.teardown(); - }); - - it('tracks nested styles with correct offsets', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: '**bold with `code` inside**' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('bold with code inside'); - // BOLD covers the full inner span, MONOSPACE points at "code" in the - // final plain text (offset 10, length 4) — not the intermediate text. - const styles = (last.params.textStyle as string[]).slice().sort(); - expect(styles).toEqual(['0:21:BOLD', '10:4:MONOSPACE']); - - await adapter.teardown(); - }); - - it('maps *single-asterisk* to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello *world*' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('Hello world'); - expect(last.params.textStyle).toEqual(['6:5:ITALIC']); - - await adapter.teardown(); - }); - - it('maps _underscore_ to ITALIC', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - tcpRef.fakeSocket.write.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'hey _there_' }, - }); - - const sendCalls = getRpcCallsForMethod('send'); - const last = sendCalls[sendCalls.length - 1]; - expect(last.params.message).toBe('hey there'); - expect(last.params.textStyle).toEqual(['4:5:ITALIC']); - - await adapter.teardown(); - }); - }); - - // --- Echo cache --- - - describe('echo cache', () => { - it('does not drop same-text inbound from a different recipient', async () => { - // Bot sends "Hello" to Alice. Immediately after, Bob sends "Hello" from - // a different DM. Bob's message must still route — the earlier echo key - // was scoped to Alice. - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Hello' }, - }); - - pushEvent({ - sourceNumber: '+15555550999', - sourceName: 'Bob', - dataMessage: { timestamp: 1700000000000, message: 'Hello' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).toHaveBeenCalledWith( - '+15555550999', - null, - expect.objectContaining({ - content: expect.objectContaining({ text: 'Hello', sender: '+15555550999' }), - }), - ); - - await adapter.teardown(); - }); - - it('still skips echo on the same recipient', async () => { - const adapter = createAdapter(); - const cfg = createMockSetup(); - await adapter.setup(cfg); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'Echo test' }, - }); - - pushEvent({ - sourceNumber: '+15555550123', - dataMessage: { timestamp: 1700000000000, message: 'Echo test' }, - }); - - await new Promise((r) => setTimeout(r, 50)); - expect(cfg.onInbound).not.toHaveBeenCalled(); - - await adapter.teardown(); - }); - }); - - // --- Connection drop --- - - describe('connection drop', () => { - it('flips isConnected to false when the socket closes', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - expect(adapter.isConnected()).toBe(true); - - // Simulate the daemon dropping the TCP connection. - tcpRef.fakeSocket.destroy(); - await new Promise((r) => setTimeout(r, 20)); - - expect(adapter.isConnected()).toBe(false); - - await adapter.teardown(); - }); - }); - - // --- Outbound files --- - - describe('outbound files', () => { - it('logs a warning and drops unsupported file attachments', async () => { - const { log } = await import('../log.js'); - const warnMock = log.warn as unknown as ReturnType; - - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - warnMock.mockClear(); - - await adapter.deliver('+15555550123', null, { - kind: 'text', - content: { text: 'with an attachment' }, - files: [{ filename: 'hi.txt', data: Buffer.from('hi') }], - }); - - const sendCalls = getRpcCallsForMethod('send'); - expect(sendCalls.length).toBeGreaterThan(0); - expect(warnMock).toHaveBeenCalledWith( - 'Signal: outbound files not supported, dropping', - expect.objectContaining({ platformId: '+15555550123', count: 1 }), - ); - - await adapter.teardown(); - }); - }); - - // --- setTyping --- - - describe('setTyping', () => { - it('sends typing indicator for DMs', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('+15555550123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(1); - - await adapter.teardown(); - }); - - it('skips typing for groups', async () => { - const adapter = createAdapter(); - await adapter.setup(createMockSetup()); - - await adapter.setTyping!('group:abc123', null); - - expect(getRpcCallsForMethod('sendTyping')).toHaveLength(0); - - await adapter.teardown(); - }); - }); - - // --- Adapter properties --- - - describe('adapter properties', () => { - it('has channelType "signal"', () => { - const adapter = createAdapter(); - expect(adapter.channelType).toBe('signal'); - }); - - it('does not support threads', () => { - const adapter = createAdapter(); - expect(adapter.supportsThreads).toBe(false); - }); - }); -}); diff --git a/src/channels/signal.ts b/src/channels/signal.ts deleted file mode 100644 index 20cba8162..000000000 --- a/src/channels/signal.ts +++ /dev/null @@ -1,811 +0,0 @@ -/** - * Signal channel adapter for NanoClaw v2. - * - * Uses signal-cli's TCP JSON-RPC daemon for bidirectional messaging. - * Requires signal-cli (https://github.com/AsamK/signal-cli) installed - * and a linked account. - * - * Ported from v1 — see v1 source for commit history. - */ -import { execFileSync, spawn } from 'node:child_process'; -import { existsSync } from 'node:fs'; -import { createConnection, type Socket } from 'node:net'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import type { ChannelAdapter, ChannelSetup, InboundMessage, OutboundMessage } from './adapter.js'; -import { registerChannelAdapter } from './channel-registry.js'; -import { readEnvFile } from '../env.js'; -import { log } from '../log.js'; - -// --------------------------------------------------------------------------- -// Signal CLI daemon management -// --------------------------------------------------------------------------- - -interface DaemonHandle { - stop: () => void; - exited: Promise; - isExited: () => boolean; -} - -function spawnSignalDaemon(cliPath: string, account: string, host: string, port: number): DaemonHandle { - const args: string[] = []; - if (account) args.push('-a', account); - args.push('daemon', '--tcp', `${host}:${port}`, '--no-receive-stdout'); - args.push('--receive-mode', 'on-start'); - - const child = spawn(cliPath, args, { stdio: ['ignore', 'pipe', 'pipe'] }); - let exited = false; - - const exitedPromise = new Promise((resolve) => { - child.once('exit', (code, signal) => { - exited = true; - if (code !== 0 && code !== null) { - const reason = signal ? `signal ${signal}` : `code ${code}`; - log.error('signal-cli daemon exited', { reason }); - } - resolve(); - }); - child.on('error', (err) => { - exited = true; - log.error('signal-cli spawn error', { err }); - resolve(); - }); - }); - - child.stdout?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (line.trim()) log.debug('signal-cli stdout', { line: line.trim() }); - } - }); - child.stderr?.on('data', (data: Buffer) => { - for (const line of data.toString().split(/\r?\n/)) { - if (!line.trim()) continue; - if (/\b(ERROR|WARN|FAILED|SEVERE)\b/i.test(line)) { - log.warn('signal-cli stderr', { line: line.trim() }); - } else { - log.debug('signal-cli stderr', { line: line.trim() }); - } - } - }); - - return { - stop: () => { - if (!child.killed && !exited) child.kill('SIGTERM'); - }, - exited: exitedPromise, - isExited: () => exited, - }; -} - -// --------------------------------------------------------------------------- -// TCP JSON-RPC client for signal-cli daemon (--tcp mode) -// -// signal-cli 0.14.x --tcp exposes a newline-delimited JSON-RPC socket. -// Requests are sent as JSON + newline; responses and push notifications -// (inbound messages) arrive the same way. -// --------------------------------------------------------------------------- - -const RPC_TIMEOUT_MS = 15_000; - -class SignalTcpClient { - private socket: Socket | null = null; - private buffer = ''; - private pending = new Map< - string, - { - resolve: (value: unknown) => void; - reject: (err: Error) => void; - timer: ReturnType; - } - >(); - private onNotification: ((method: string, params: unknown) => void) | null = null; - private onClose: (() => void) | null = null; - - constructor( - private host: string, - private port: number, - ) {} - - connect(handlers?: { - onNotification?: (method: string, params: unknown) => void; - onClose?: () => void; - }): Promise { - this.onNotification = handlers?.onNotification ?? null; - this.onClose = handlers?.onClose ?? null; - return new Promise((resolve, reject) => { - const sock = createConnection(this.port, this.host, () => { - this.socket = sock; - resolve(); - }); - sock.on('error', (err) => { - if (!this.socket) { - reject(err); - return; - } - log.warn('Signal TCP socket error', { err }); - }); - sock.on('data', (chunk) => this.onData(chunk)); - sock.on('close', () => { - const wasConnected = this.socket !== null; - this.socket = null; - for (const [, p] of this.pending) { - clearTimeout(p.timer); - p.reject(new Error('Signal TCP connection closed')); - } - this.pending.clear(); - if (wasConnected) this.onClose?.(); - }); - }); - } - - async rpc(method: string, params?: Record): Promise { - if (!this.socket) throw new Error('Signal TCP not connected'); - const id = Math.random().toString(36).slice(2); - const msg = JSON.stringify({ jsonrpc: '2.0', method, params, id }) + '\n'; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`Signal RPC timeout: ${method}`)); - }, RPC_TIMEOUT_MS); - - this.pending.set(id, { - resolve: resolve as (v: unknown) => void, - reject, - timer, - }); - this.socket!.write(msg); - }); - } - - close() { - this.socket?.destroy(); - this.socket = null; - } - - isConnected(): boolean { - return this.socket !== null && !this.socket.destroyed; - } - - private onData(chunk: Buffer) { - this.buffer += chunk.toString(); - let newlineIdx = this.buffer.indexOf('\n'); - while (newlineIdx !== -1) { - const line = this.buffer.slice(0, newlineIdx).trim(); - this.buffer = this.buffer.slice(newlineIdx + 1); - if (line) this.handleLine(line); - newlineIdx = this.buffer.indexOf('\n'); - } - } - - private handleLine(line: string) { - let parsed: any; - try { - parsed = JSON.parse(line); - } catch { - log.debug('Signal TCP: unparseable line', { line: line.slice(0, 200) }); - return; - } - - if (parsed.id && this.pending.has(parsed.id)) { - const p = this.pending.get(parsed.id)!; - this.pending.delete(parsed.id); - clearTimeout(p.timer); - if (parsed.error) { - p.reject(new Error(parsed.error.message ?? 'Signal RPC error')); - } else { - p.resolve(parsed.result); - } - return; - } - - if (parsed.method && this.onNotification) { - this.onNotification(parsed.method, parsed.params); - } - } -} - -async function signalTcpCheck(host: string, port: number): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (result: boolean) => { - if (settled) return; - settled = true; - clearTimeout(timer); - sock.destroy(); - resolve(result); - }; - const sock = createConnection(port, host, () => finish(true)); - sock.on('error', () => finish(false)); - const timer = setTimeout(() => finish(false), 5000); - }); -} - -// --------------------------------------------------------------------------- -// Echo cache -// --------------------------------------------------------------------------- - -const ECHO_TTL_MS = 10_000; - -/** - * Per-recipient dedup for messages we sent ourselves. - * - * signal-cli echoes our own outbound back via syncMessage (and, for Note to - * Self, via sentMessage-with-self-destination). Without dedup, the agent sees - * its own replies as new inbound and loops. We remember `(platformId, text)` - * briefly after every send, and drop the first match within TTL. - * - * Keying on text alone is not enough: if we send "hi" to Alice and Bob then - * sends "hi" from a different chat, Bob's real message gets silently dropped. - */ -class EchoCache { - private entries = new Map(); - - private keyFor(platformId: string, text: string): string { - return `${platformId}\x00${text.trim()}`; - } - - remember(platformId: string, text: string): void { - const trimmed = text.trim(); - if (!trimmed) return; - this.entries.set(this.keyFor(platformId, trimmed), Date.now()); - this.cleanup(); - } - - isEcho(platformId: string, text: string): boolean { - const trimmed = text.trim(); - if (!trimmed) return false; - const key = this.keyFor(platformId, trimmed); - const ts = this.entries.get(key); - if (!ts) return false; - if (Date.now() - ts > ECHO_TTL_MS) { - this.entries.delete(key); - return false; - } - this.entries.delete(key); - return true; - } - - private cleanup(): void { - const now = Date.now(); - for (const [key, ts] of this.entries) { - if (now - ts > ECHO_TTL_MS) this.entries.delete(key); - } - } -} - -// --------------------------------------------------------------------------- -// Signal envelope types -// --------------------------------------------------------------------------- - -interface SignalQuote { - id?: number; - authorNumber?: string; - authorUuid?: string; - text?: string; -} - -interface SignalDataMessage { - timestamp?: number; - message?: string; - groupInfo?: { groupId?: string; groupName?: string; type?: string }; - quote?: SignalQuote; - attachments?: Array<{ - id?: string; - contentType?: string; - filename?: string; - size?: number; - }>; -} - -interface SignalEnvelope { - source?: string; - sourceName?: string; - sourceNumber?: string; - sourceUuid?: string; - dataMessage?: SignalDataMessage; - syncMessage?: { - sentMessage?: SignalDataMessage & { - destination?: string; - destinationNumber?: string; - }; - }; -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function chunkText(text: string, limit: number): string[] { - const chunks: string[] = []; - let remaining = text; - while (remaining.length > 0) { - if (remaining.length <= limit) { - chunks.push(remaining); - break; - } - let splitAt = remaining.lastIndexOf('\n', limit); - if (splitAt <= 0) splitAt = limit; - chunks.push(remaining.slice(0, splitAt)); - remaining = remaining.slice(splitAt).replace(/^\n/, ''); - } - return chunks; -} - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -// --------------------------------------------------------------------------- -// Signal text styles — convert Markdown to Signal's offset-based formatting -// --------------------------------------------------------------------------- - -interface SignalTextStyle { - style: 'BOLD' | 'ITALIC' | 'STRIKETHROUGH' | 'MONOSPACE' | 'SPOILER'; - start: number; - length: number; -} - -interface StyledText { - text: string; - textStyles: SignalTextStyle[]; -} - -/** - * Convert Markdown-ish input to Signal's offset-based style ranges. - * - * Walks the input recursively: at each level we find the leftmost matching - * pattern, descend into its captured inner text (so `**bold with \`code\` - * inside**` stays bold-plus-monospace rather than leaking stripped markers), - * then continue past the match. Style offsets are recorded against the - * *output* text length as it's built, so nested styles always point at the - * right span of the final plain text. - */ -function parseSignalStyles(input: string): StyledText { - const styles: SignalTextStyle[] = []; - - // Ordering matters: longer/greedier delimiters first so `` ``` `` beats - // `` ` ``, `**` beats `*`. The italic-`*` pattern refuses to start on - // whitespace so `*` isn't mistakenly opened on " * " in list-like text. - const patterns: Array<{ regex: RegExp; style: SignalTextStyle['style'] }> = [ - { regex: /```([\s\S]+?)```/, style: 'MONOSPACE' }, - { regex: /`([^`]+)`/, style: 'MONOSPACE' }, - { regex: /\*\*([^]+?)\*\*/, style: 'BOLD' }, - { regex: /~~([^]+?)~~/, style: 'STRIKETHROUGH' }, - { regex: /\|\|([^]+?)\|\|/, style: 'SPOILER' }, - { regex: /\*([^*\s][^*]*?)\*/, style: 'ITALIC' }, - { regex: /_([^_\s][^_]*?)_/, style: 'ITALIC' }, - ]; - - function walk(segment: string, outputBase: number): string { - let earliest: { start: number; match: RegExpExecArray; style: SignalTextStyle['style'] } | null = null; - for (const { regex, style } of patterns) { - const m = regex.exec(segment); - if (!m) continue; - if (earliest === null || m.index < earliest.start) { - earliest = { start: m.index, match: m, style }; - } - } - if (!earliest) return segment; - - const before = segment.slice(0, earliest.start); - const fullMatch = earliest.match[0]; - const inner = earliest.match[1]; - const afterStart = earliest.start + fullMatch.length; - const after = segment.slice(afterStart); - - const innerOut = walk(inner, outputBase + before.length); - styles.push({ - style: earliest.style, - start: outputBase + before.length, - length: innerOut.length, - }); - const afterOut = walk(after, outputBase + before.length + innerOut.length); - - return before + innerOut + afterOut; - } - - const text = walk(input, 0); - return { text, textStyles: styles }; -} - -// --------------------------------------------------------------------------- -// SignalAdapter — v2 ChannelAdapter implementation -// --------------------------------------------------------------------------- - -/** - * Platform ID format: - * DM: phone number or UUID (e.g. "+15555550123") - * Group: "group:" (e.g. "group:abc123") - * - * channelType is always "signal". The router combines channelType + platformId - * to look up or create the messaging_group. - */ -export function createSignalAdapter(config: { - cliPath: string; - account: string; - tcpHost: string; - tcpPort: number; - manageDaemon: boolean; - signalDataDir: string; -}): ChannelAdapter { - let daemon: DaemonHandle | null = null; - let tcp: SignalTcpClient | null = null; - let connected = false; - const echoCache = new EchoCache(); - let setup: ChannelSetup | null = null; - - // -- inbound handling -- - - function handleNotification(method: string, params: unknown): void { - if (method === 'receive') { - const envelope = (params as any)?.envelope; - if (envelope) { - handleEnvelope(envelope).catch((err) => { - log.error('Signal: error handling envelope', { err }); - }); - } - } - } - - async function handleEnvelope(envelope: SignalEnvelope): Promise { - if (!setup) return; - - // Sync messages (sent from another device) - const syncSent = envelope.syncMessage?.sentMessage; - if (syncSent) { - const dest = (syncSent.destinationNumber ?? syncSent.destination ?? '').trim(); - // "Note to Self" — destination is our own account - if (dest === config.account) { - const text = (syncSent.message ?? '').trim(); - if (!text) return; - const platformId = config.account; - if (echoCache.isEcho(platformId, text)) return; - const timestamp = syncSent.timestamp ? new Date(syncSent.timestamp).toISOString() : new Date().toISOString(); - - setup.onMetadata(platformId, 'Note to Self', false); - - const msg: InboundMessage = { - id: String(syncSent.timestamp ?? Date.now()), - kind: 'chat', - content: { - text, - sender: config.account, - senderId: `signal:${config.account}`, - senderName: 'Me', - isFromMe: true, - ...(syncSent.quote ? quoteToContent(syncSent.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - return; - } - // Other sync messages are our outbound — skip - return; - } - - const dataMessage = envelope.dataMessage; - if (!dataMessage) return; - - const text = (dataMessage.message ?? '').trim(); - - // Check for voice attachments - const hasVoice = !text && dataMessage.attachments?.some((a) => a.contentType?.startsWith('audio/')); - - if (!text && !hasVoice) return; - - const sender = (envelope.sourceNumber ?? envelope.sourceUuid ?? envelope.source ?? '').trim(); - if (!sender) return; - - const senderName = (envelope.sourceName?.trim() || sender).trim(); - const groupInfo = dataMessage.groupInfo; - const isGroup = Boolean(groupInfo?.groupId); - const groupId = groupInfo?.groupId; - - const platformId = isGroup ? `group:${groupId}` : sender; - - if (text && echoCache.isEcho(platformId, text)) { - log.debug('Signal: skipping echo', { platformId }); - return; - } - const timestamp = dataMessage.timestamp ? new Date(dataMessage.timestamp).toISOString() : new Date().toISOString(); - - const chatName = groupInfo?.groupName ?? (isGroup ? `Group ${groupId?.slice(0, 8)}` : senderName); - - setup.onMetadata(platformId, chatName, isGroup); - - let content = text; - - // Voice attachment — log path, deliver placeholder text. - // v2 does not have built-in transcription; a future MCP tool could handle this. - if (hasVoice) { - const audio = dataMessage.attachments?.find((a) => a.contentType?.startsWith('audio/')); - if (audio?.id) { - const attachmentPath = join(config.signalDataDir, 'attachments', audio.id); - if (existsSync(attachmentPath)) { - log.info('Signal: voice attachment received', { - platformId, - attachmentId: audio.id, - path: attachmentPath, - }); - content = '[Voice Message]'; - } else { - log.warn('Signal: voice attachment file not found', { - id: audio.id, - path: attachmentPath, - }); - content = '[Voice Message - file not found]'; - } - } else { - content = '[Voice Message]'; - } - } - - const msg: InboundMessage = { - id: String(dataMessage.timestamp ?? Date.now()), - kind: 'chat', - content: { - text: content, - sender, - senderId: `signal:${sender}`, - senderName, - ...(dataMessage.quote ? quoteToContent(dataMessage.quote) : {}), - }, - timestamp, - }; - await setup.onInbound(platformId, null, msg); - - log.info('Signal message received', { platformId, sender: senderName }); - } - - function quoteToContent(quote: SignalQuote): Record { - return { - replyToSenderName: quote.authorNumber ?? 'someone', - replyToMessageContent: quote.text || undefined, - replyToMessageId: quote.id ? String(quote.id) : undefined, - }; - } - - // -- send helpers -- - - async function sendText(platformId: string, text: string): Promise { - if (!connected || !tcp) return; - - echoCache.remember(platformId, text); - - const MAX_CHUNK = 4000; - const chunks = text.length <= MAX_CHUNK ? [text] : chunkText(text, MAX_CHUNK); - - for (const chunk of chunks) { - try { - const { text: plainText, textStyles } = parseSignalStyles(chunk); - const params: Record = { message: plainText }; - if (config.account) params.account = config.account; - if (textStyles.length > 0) { - params.textStyle = textStyles.map((s) => `${s.start}:${s.length}:${s.style}`); - } - - if (platformId.startsWith('group:')) { - params.groupId = platformId.slice('group:'.length); - } else { - params.recipient = [platformId]; - } - - try { - await tcp.rpc('send', params); - } catch (styledErr) { - if (textStyles.length > 0) { - log.debug('Signal: textStyle rejected, retrying with markup'); - delete params.textStyle; - params.message = chunk; - await tcp.rpc('send', params); - } else { - throw styledErr; - } - } - } catch (err) { - log.error('Signal: send failed', { platformId, err }); - } - } - - log.info('Signal message sent', { platformId, length: text.length }); - } - - async function waitForDaemon(): Promise { - const maxWait = 30_000; - const pollInterval = 1000; - const start = Date.now(); - - while (Date.now() - start < maxWait) { - if (daemon?.isExited()) return false; - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (ok) return true; - await sleep(pollInterval); - } - return false; - } - - // -- adapter -- - - const adapter: ChannelAdapter = { - name: 'signal', - channelType: 'signal', - supportsThreads: false, - - async setup(cfg: ChannelSetup): Promise { - setup = cfg; - - if (config.manageDaemon) { - daemon = spawnSignalDaemon(config.cliPath, config.account, config.tcpHost, config.tcpPort); - const ready = await waitForDaemon(); - if (!ready) { - daemon.stop(); - throw new Error('Signal daemon failed to start. Is signal-cli installed and your account linked?'); - } - } else { - const ok = await signalTcpCheck(config.tcpHost, config.tcpPort); - if (!ok) { - const err = new Error( - `Signal daemon not reachable at ${config.tcpHost}:${config.tcpPort}. Start it manually or set SIGNAL_MANAGE_DAEMON=true`, - ); - (err as any).name = 'NetworkError'; - throw err; - } - } - - tcp = new SignalTcpClient(config.tcpHost, config.tcpPort); - await tcp.connect({ - onNotification: handleNotification, - // Signal the adapter that the daemon dropped us. No auto-reconnect yet - // — subsequent deliver/setTyping calls short-circuit on `connected` - // and log rather than throw into the retry loop. Operators see this in - // logs/nanoclaw.log and can restart the service. - onClose: () => { - if (!connected) return; - connected = false; - log.warn('Signal channel lost TCP connection to signal-cli daemon', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - }); - - try { - await tcp.rpc('updateProfile', { - name: 'NanoClaw', - account: config.account, - }); - } catch { - log.debug('Signal: could not set profile name'); - } - - try { - await tcp.rpc('updateConfiguration', { - typingIndicators: true, - account: config.account, - }); - } catch { - log.debug('Signal: could not enable typing indicators'); - } - - connected = true; - log.info('Signal channel connected', { - account: config.account, - host: config.tcpHost, - port: config.tcpPort, - }); - }, - - async teardown(): Promise { - connected = false; - tcp?.close(); - tcp = null; - if (daemon && config.manageDaemon) { - daemon.stop(); - await daemon.exited; - } - daemon = null; - log.info('Signal channel disconnected'); - }, - - isConnected(): boolean { - return connected; - }, - - async deliver(platformId: string, _threadId: string | null, message: OutboundMessage): Promise { - if (message.files && message.files.length > 0) { - // Native adapter doesn't yet forward file uploads to signal-cli's - // `send --attachment`. Don't silently swallow — operators need to see - // that an attachment was requested but not sent. - log.warn('Signal: outbound files not supported, dropping', { - platformId, - count: message.files.length, - filenames: message.files.map((f) => f.filename), - }); - } - - const content = message.content as Record | string | undefined; - let text: string | null = null; - if (typeof content === 'string') { - text = content; - } else if (content && typeof content === 'object' && typeof content.text === 'string') { - text = content.text; - } - if (!text) return undefined; - - await sendText(platformId, text); - return undefined; - }, - - async setTyping(platformId: string, _threadId: string | null): Promise { - if (!connected || !tcp) return; - if (platformId.startsWith('group:')) return; - - try { - const params: Record = { recipient: [platformId] }; - if (config.account) params.account = config.account; - await tcp.rpc('sendTyping', params); - } catch (err) { - log.debug('Signal: typing indicator failed', { platformId, err }); - } - }, - }; - - return adapter; -} - -// --------------------------------------------------------------------------- -// Self-registration -// --------------------------------------------------------------------------- - -const DEFAULT_TCP_HOST = '127.0.0.1'; -const DEFAULT_TCP_PORT = 7583; - -registerChannelAdapter('signal', { - factory: () => { - const envVars = readEnvFile([ - 'SIGNAL_ACCOUNT', - 'SIGNAL_TCP_HOST', - 'SIGNAL_TCP_PORT', - 'SIGNAL_CLI_PATH', - 'SIGNAL_MANAGE_DAEMON', - 'SIGNAL_DATA_DIR', - ]); - - const account = process.env.SIGNAL_ACCOUNT || envVars.SIGNAL_ACCOUNT || ''; - if (!account) { - log.debug('Signal: SIGNAL_ACCOUNT not set, skipping channel'); - return null; - } - - const cliPath = process.env.SIGNAL_CLI_PATH || envVars.SIGNAL_CLI_PATH || 'signal-cli'; - const tcpHost = process.env.SIGNAL_TCP_HOST || envVars.SIGNAL_TCP_HOST || DEFAULT_TCP_HOST; - const tcpPort = parseInt(process.env.SIGNAL_TCP_PORT || envVars.SIGNAL_TCP_PORT || String(DEFAULT_TCP_PORT), 10); - const manageDaemon = (process.env.SIGNAL_MANAGE_DAEMON || envVars.SIGNAL_MANAGE_DAEMON || 'true') === 'true'; - - const signalDataDir = - process.env.SIGNAL_DATA_DIR || envVars.SIGNAL_DATA_DIR || join(homedir(), '.local', 'share', 'signal-cli'); - - // Only check for `signal-cli` on PATH when the operator left cliPath at - // the default AND asked us to manage the daemon. A custom absolute path - // is treated as an explicit promise and spawn will surface its own ENOENT. - if (manageDaemon && cliPath === 'signal-cli') { - try { - execFileSync('which', ['signal-cli'], { stdio: 'ignore' }); - } catch { - log.debug('Signal: signal-cli binary not found, skipping channel'); - return null; - } - } - - return createSignalAdapter({ - cliPath, - account, - tcpHost, - tcpPort, - manageDaemon, - signalDataDir, - }); - }, -}); From 78b0ad68f6dfd8fea5f7ed1a4cc41052c51085dd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 20:05:01 +0000 Subject: [PATCH 27/31] chore: bump version to 2.0.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 098e01f83..20afddb92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.9", + "version": "2.0.10", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", From 3fa001409edc7b4aac1a7abf6fd6021475c58185 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Thu, 23 Apr 2026 23:19:30 +0300 Subject: [PATCH 28/31] feat(setup): wire Signal into the auto setup flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bash nanoclaw.sh` can now offer Signal as a channel choice, scan the signal-cli link QR in the terminal, and wire up the first agent end to end — mirroring the WhatsApp and Telegram flows. Pieces: - setup/add-signal.sh — non-interactive installer. Fetches src/channels/signal.ts + signal.test.ts from the channels branch, appends the self-registration import, installs qrcode (for the setup-flow QR render), and builds. Idempotent and standalone-runnable. - setup/signal-auth.ts — step runner. Spawns `signal-cli link --name NanoClaw`, watches stdout for the `sgnl://linkdevice?…` (or legacy `tsdevice://`) URL, emits SIGNAL_AUTH_QR with it. On exit 0, runs `signal-cli -o json listAccounts` and reports the new account via SIGNAL_AUTH STATUS=success. Pre-check via listAccounts returns STATUS=skipped if an account is already linked. - setup/channels/signal.ts — interactive driver. Probes for signal-cli (offering `brew install signal-cli` on macOS or linking GitHub releases on Linux if missing), runs add-signal.sh, renders each SIGNAL_AUTH_QR block as a terminal QR inside a clack spinner, persists SIGNAL_ACCOUNT to .env + data/env/env, restarts the service, then wires the first agent via init-first-agent. - setup/index.ts: register `signal-auth` in the STEPS map. - setup/auto.ts: add 'signal' to ChannelChoice, import the driver, add it to the channel picker (after WhatsApp, hint "needs signal-cli installed"), branch the dispatch, and map channelDmLabel. Co-Authored-By: Claude Opus 4.7 (1M context) --- setup/add-signal.sh | 95 +++++++++++ setup/auto.ts | 11 ++ setup/channels/signal.ts | 357 +++++++++++++++++++++++++++++++++++++++ setup/index.ts | 1 + setup/signal-auth.ts | 182 ++++++++++++++++++++ 5 files changed, 646 insertions(+) create mode 100755 setup/add-signal.sh create mode 100644 setup/channels/signal.ts create mode 100644 setup/signal-auth.ts diff --git a/setup/add-signal.sh b/setup/add-signal.sh new file mode 100755 index 000000000..8ebf2b9aa --- /dev/null +++ b/setup/add-signal.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Install the Signal adapter in an already-running NanoClaw checkout. +# Non-interactive — the operator-facing "install signal-cli" + QR scan +# live in setup/channels/signal.ts. This script only: +# +# 1. Fetches src/channels/signal.ts + signal.test.ts from the channels +# branch. +# 2. Appends the self-registration import to src/channels/index.ts. +# 3. Installs qrcode (for setup-flow QR rendering — adapter itself has +# no npm deps). +# 4. Builds. +# +# SIGNAL_ACCOUNT is persisted separately by the driver once signal-cli +# link has produced a number; that keeps this script idempotent and +# re-runnable without re-auth. +# +# Emits exactly one status block on stdout (ADD_SIGNAL) at the end. All +# chatty progress goes to stderr so setup:auto's raw-log capture sees +# the full story without cluttering the final block for the parser. +set -euo pipefail + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$PROJECT_ROOT" + +# Keep in sync with .claude/skills/add-signal/SKILL.md. +QRCODE_VERSION="qrcode@1.5.4" +QRCODE_TYPES_VERSION="@types/qrcode@1.5.6" + +# shellcheck source=setup/lib/channels-remote.sh +source "$PROJECT_ROOT/setup/lib/channels-remote.sh" +CHANNELS_REMOTE=$(resolve_channels_remote) +CHANNELS_BRANCH="${CHANNELS_REMOTE}/channels" + +emit_status() { + local status=$1 error=${2:-} + local already=${ADAPTER_ALREADY_INSTALLED:-false} + echo "=== NANOCLAW SETUP: ADD_SIGNAL ===" + echo "STATUS: ${status}" + echo "ADAPTER_ALREADY_INSTALLED: ${already}" + [ -n "$error" ] && echo "ERROR: ${error}" + echo "=== END ===" +} + +log() { echo "[add-signal] $*" >&2; } + +need_install() { + [ ! -f src/channels/signal.ts ] && return 0 + ! grep -q "^import './signal.js';" src/channels/index.ts 2>/dev/null && return 0 + return 1 +} + +ADAPTER_ALREADY_INSTALLED=true +if need_install; then + ADAPTER_ALREADY_INSTALLED=false + log "Fetching channels branch…" + git fetch "$CHANNELS_REMOTE" channels >&2 2>/dev/null || { + emit_status failed "git fetch ${CHANNELS_REMOTE} channels failed" + exit 1 + } + + log "Copying adapter files from ${CHANNELS_BRANCH}…" + for f in \ + src/channels/signal.ts \ + src/channels/signal.test.ts + do + git show "${CHANNELS_BRANCH}:$f" > "$f" || { + emit_status failed "git show ${CHANNELS_BRANCH}:$f failed" + exit 1 + } + done + + if ! grep -q "^import './signal.js';" src/channels/index.ts; then + echo "import './signal.js';" >> src/channels/index.ts + fi +fi + +# qrcode is needed by setup/signal-auth.ts to render the linking URL as a +# terminal QR. Install idempotently — if it's already present (e.g. from a +# prior WhatsApp install) pnpm is a no-op. +if ! node -e "require.resolve('qrcode')" >/dev/null 2>&1; then + log "Installing ${QRCODE_VERSION}…" + pnpm install "${QRCODE_VERSION}" "${QRCODE_TYPES_VERSION}" >&2 2>/dev/null || { + emit_status failed "pnpm install ${QRCODE_VERSION} failed" + exit 1 + } +fi + +log "Building…" +pnpm run build >&2 2>/dev/null || { + emit_status failed "pnpm run build failed" + exit 1 +} + +emit_status success diff --git a/setup/auto.ts b/setup/auto.ts index 4c2026227..cff2f63a8 100644 --- a/setup/auto.ts +++ b/setup/auto.ts @@ -28,6 +28,7 @@ import k from 'kleur'; import { runDiscordChannel } from './channels/discord.js'; import { runIMessageChannel } from './channels/imessage.js'; +import { runSignalChannel } from './channels/signal.js'; import { runSlackChannel } from './channels/slack.js'; import { runTeamsChannel } from './channels/teams.js'; import { runTelegramChannel } from './channels/telegram.js'; @@ -54,6 +55,7 @@ type ChannelChoice = | 'telegram' | 'discord' | 'whatsapp' + | 'signal' | 'teams' | 'slack' | 'imessage' @@ -315,6 +317,8 @@ async function main(): Promise { 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') { @@ -442,6 +446,8 @@ function channelDmLabel(choice: ChannelChoice): string | null { return 'Discord DMs'; case 'whatsapp': return 'WhatsApp'; + case 'signal': + return 'Signal'; case 'teams': return 'Teams'; case 'imessage': @@ -835,6 +841,11 @@ async function askChannelChoice(): Promise { { value: 'telegram', label: 'Yes, connect Telegram', hint: 'recommended' }, { value: 'discord', label: 'Yes, connect Discord' }, { value: 'whatsapp', label: 'Yes, connect WhatsApp' }, + { + value: 'signal', + label: 'Yes, connect Signal', + hint: 'needs signal-cli installed', + }, { value: 'imessage', label: 'Yes, connect iMessage (experimental)', diff --git a/setup/channels/signal.ts b/setup/channels/signal.ts new file mode 100644 index 000000000..9e54cb971 --- /dev/null +++ b/setup/channels/signal.ts @@ -0,0 +1,357 @@ +/** + * Signal channel flow for setup:auto. + * + * `runSignalChannel(displayName)` owns the full branch from signal-cli + * presence check through the welcome DM: + * + * 1. Probe signal-cli on PATH (or SIGNAL_CLI_PATH). On macOS without it, + * offer `brew install signal-cli` inline. On Linux, surface the + * GitHub releases URL and bail with an actionable error. + * 2. Install the adapter + qrcode via setup/add-signal.sh (idempotent). + * 3. Run the signal-auth step, rendering each SIGNAL_AUTH_QR block as + * a terminal QR the operator scans from Signal → Linked Devices. + * 4. Persist SIGNAL_ACCOUNT to .env (+ data/env/env). + * 5. Kick the service so the adapter picks up the new credentials. + * 6. Ask operator role + agent name. + * 7. Wire the agent via scripts/init-first-agent.ts; the existing welcome + * DM path delivers the greeting through the adapter. + * + * Signal's `link` flow creates a *secondary* device. The phone number + * comes from the primary (the phone that scanned the QR); this host then + * sends/receives as that primary number. No registration of new numbers. + * + * Output obeys the three-level contract: clack UI for the user, structured + * entries in logs/setup.log, full raw output in per-step files under + * logs/setup-steps/. See docs/setup-flow.md. + */ +import { spawnSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import * as p from '@clack/prompts'; +import k from 'kleur'; + +import * as setupLog from '../logs.js'; +import { getLaunchdLabel, getSystemdUnit } from '../../src/install-slug.js'; +import { + type Block, + type StepResult, + dumpTranscriptOnFailure, + ensureAnswer, + fail, + runQuietChild, + spawnStep, + writeStepEntry, +} from '../lib/runner.js'; +import { askOperatorRole } from '../lib/role-prompt.js'; + +const DEFAULT_AGENT_NAME = 'Nano'; + +export async function runSignalChannel(displayName: string): Promise { + await ensureSignalCli(); + + const install = await runQuietChild( + 'signal-install', + 'bash', + ['setup/add-signal.sh'], + { + running: 'Installing the Signal adapter…', + done: 'Signal adapter installed.', + skipped: 'Signal adapter already installed.', + }, + ); + if (!install.ok) { + await fail( + 'signal-install', + "Couldn't install the Signal adapter.", + 'See logs/setup-steps/ for details, then retry setup.', + ); + } + + const auth = await runSignalAuth(); + if (!auth.ok) { + const reason = auth.terminal?.fields.ERROR ?? 'unknown'; + await fail( + 'signal-auth', + `Signal link failed (${reason}).`, + reason === 'qr_timeout' + ? 'The code expired. Re-run setup to get a fresh one.' + : 'Re-run setup to try again.', + ); + } + + const account = auth.terminal?.fields.ACCOUNT; + if (!account) { + await fail( + 'signal-auth', + 'Linked with Signal but couldn\'t read the phone number back.', + 'Run `signal-cli listAccounts` to confirm, then re-run setup.', + ); + } + + writeSignalAccount(account!); + await restartService(); + + const role = await askOperatorRole('Signal'); + setupLog.userInput('signal_role', role); + + const agentName = await resolveAgentName(); + + const init = await runQuietChild( + 'init-first-agent', + 'pnpm', + [ + 'exec', 'tsx', 'scripts/init-first-agent.ts', + '--channel', 'signal', + '--user-id', account!, + '--platform-id', account!, + '--display-name', displayName, + '--agent-name', agentName, + '--role', role, + ], + { + running: `Connecting ${agentName} to Signal…`, + done: `${agentName} is ready. Check Signal for a welcome message.`, + }, + { + extraFields: { + CHANNEL: 'signal', + AGENT_NAME: agentName, + PLATFORM_ID: account!, + ROLE: role, + }, + }, + ); + if (!init.ok) { + await fail( + 'init-first-agent', + `Couldn't finish connecting ${agentName}.`, + 'You can retry later with `/manage-channels`.', + ); + } +} + +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; + + if (process.platform === 'darwin') { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'The quickest way on macOS is Homebrew:', + '', + k.cyan(' brew install signal-cli'), + '', + "Install it in another terminal, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } else { + p.note( + [ + "NanoClaw talks to Signal through signal-cli, which isn't installed yet.", + '', + 'Grab the latest release from GitHub:', + '', + k.cyan(' https://github.com/AsamK/signal-cli/releases'), + '', + "Install it, make sure `signal-cli --version` works, then re-run setup.", + ].join('\n'), + 'signal-cli not found', + ); + } + await fail( + 'signal-install', + 'signal-cli is required but not installed.', + 'Install it and re-run setup.', + ); +} + +async function runSignalAuth(): Promise< + StepResult & { rawLog: string; durationMs: number } +> { + const rawLog = setupLog.stepRawLog('signal-auth'); + const start = Date.now(); + const s = p.spinner(); + s.start('Starting Signal link…'); + let spinnerActive = true; + + const stopSpinner = (msg: string, code?: number): void => { + if (spinnerActive) { + s.stop(msg, code); + spinnerActive = false; + } + }; + + // Tracks how many lines the QR block occupies so we can wipe it in-place + // once linking succeeds (Signal's link URL doesn't rotate like WhatsApp's, + // but we still want to erase the QR from screen once it's served). + let qrLinesPrinted = 0; + + const result = await spawnStep( + 'signal-auth', + [], + (block: Block) => { + if (block.type === 'SIGNAL_AUTH_QR') { + const qr = block.fields.QR ?? ''; + if (!qr) return; + void renderQr(qr).then((lines) => { + stopSpinner('Scan this QR from Signal → Settings → Linked Devices.'); + process.stdout.write(lines.join('\n') + '\n'); + qrLinesPrinted = lines.length; + s.start('Waiting for you to scan…'); + spinnerActive = true; + }); + } else if (block.type === 'SIGNAL_AUTH') { + const status = block.fields.STATUS; + // Wipe the QR block regardless of outcome — it's either scanned + // and useless, or expired and misleading. + if (qrLinesPrinted > 0) { + process.stdout.write(`\x1b[${qrLinesPrinted}A\x1b[0J`); + qrLinesPrinted = 0; + } + const account = block.fields.ACCOUNT; + if (status === 'skipped') { + stopSpinner( + account + ? `Signal already linked as ${k.cyan(account)}.` + : 'Signal already linked.', + ); + } else if (status === 'success') { + stopSpinner(`Signal linked as ${k.cyan(String(account ?? ''))}.`); + } else if (status === 'failed') { + const err = block.fields.ERROR ?? 'unknown'; + stopSpinner(`Signal link failed: ${err}`, 1); + } + } + }, + rawLog, + ); + const durationMs = Date.now() - start; + + if (spinnerActive) { + stopSpinner( + result.ok ? 'Done.' : 'Signal link ended unexpectedly.', + result.ok ? 0 : 1, + ); + if (!result.ok) dumpTranscriptOnFailure(result.transcript); + } + + writeStepEntry('signal-auth', result, durationMs, rawLog); + return { ...result, rawLog, durationMs }; +} + +/** + * Render the raw linking URL as a block-art QR, returned line-by-line so + * the caller can count lines for in-place cleanup. Uses small-mode so the + * code stays scannable on 24-row terminals. If qrcode isn't installed + * (add-signal.sh should have handled it, but we're defensive), fall back + * to the raw URL and ask the user to paste it into an external renderer. + */ +async function renderQr(url: string): Promise { + try { + const QRCode = await import('qrcode'); + const qrText = await QRCode.toString(url, { type: 'terminal', small: true }); + const caption = k.dim( + ' Signal → Settings → Linked Devices → Link New Device → scan.', + ); + return [...qrText.trimEnd().split('\n'), '', caption]; + } catch { + return [ + 'Linking URL (render at https://qr.io or similar):', + '', + url, + '', + k.dim('Signal → Settings → Linked Devices → Link New Device → scan.'), + ]; + } +} + +/** Persist SIGNAL_ACCOUNT to .env and mirror to data/env/env for the container. */ +function writeSignalAccount(account: string): void { + const envPath = path.join(process.cwd(), '.env'); + let contents = ''; + try { + contents = fs.readFileSync(envPath, 'utf-8'); + } catch { + contents = ''; + } + if (/^SIGNAL_ACCOUNT=/m.test(contents)) { + contents = contents.replace( + /^SIGNAL_ACCOUNT=.*$/m, + `SIGNAL_ACCOUNT=${account}`, + ); + } else { + if (contents.length > 0 && !contents.endsWith('\n')) contents += '\n'; + contents += `SIGNAL_ACCOUNT=${account}\n`; + } + fs.writeFileSync(envPath, contents); + + const containerEnvDir = path.join(process.cwd(), 'data', 'env'); + fs.mkdirSync(containerEnvDir, { recursive: true }); + fs.copyFileSync(envPath, path.join(containerEnvDir, 'env')); + + setupLog.userInput('signal_account', account); +} + +async function restartService(): Promise { + const s = p.spinner(); + s.start('Restarting NanoClaw so it sees your Signal account…'); + const start = Date.now(); + const platform = process.platform; + try { + if (platform === 'darwin') { + spawnSync( + 'launchctl', + ['kickstart', '-k', `gui/${process.getuid?.() ?? 501}/${getLaunchdLabel()}`], + { stdio: 'ignore' }, + ); + } else if (platform === 'linux') { + const unit = getSystemdUnit(); + const user = spawnSync('systemctl', ['--user', 'restart', unit], { + stdio: 'ignore', + }); + if (user.status !== 0) { + spawnSync('sudo', ['systemctl', 'restart', unit], { stdio: 'ignore' }); + } + } + // Give the adapter a moment to connect to signal-cli before + // init-first-agent's welcome DM hits the delivery path. + await new Promise((r) => setTimeout(r, 5000)); + const elapsed = Math.round((Date.now() - start) / 1000); + s.stop(`NanoClaw restarted. ${k.dim(`(${elapsed}s)`)}`); + setupLog.step('signal-restart', 'success', Date.now() - start, { + PLATFORM: platform, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + s.stop(`Restart may have failed: ${message}`, 1); + setupLog.step('signal-restart', 'failed', Date.now() - start, { + ERROR: message, + }); + // Non-fatal — the user can restart manually if init-first-agent fails. + } +} + +async function resolveAgentName(): Promise { + const preset = process.env.NANOCLAW_AGENT_NAME?.trim(); + if (preset) { + setupLog.userInput('agent_name', preset); + return preset; + } + const answer = ensureAnswer( + await p.text({ + message: 'What should your assistant be called?', + placeholder: DEFAULT_AGENT_NAME, + defaultValue: DEFAULT_AGENT_NAME, + }), + ); + const value = (answer as string).trim() || DEFAULT_AGENT_NAME; + setupLog.userInput('agent_name', value); + return value; +} diff --git a/setup/index.ts b/setup/index.ts index 25d1934c3..200b9e221 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -16,6 +16,7 @@ const STEPS: Record< register: () => import('./register.js'), groups: () => import('./groups.js'), 'whatsapp-auth': () => import('./whatsapp-auth.js'), + 'signal-auth': () => import('./signal-auth.js'), mounts: () => import('./mounts.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), diff --git a/setup/signal-auth.ts b/setup/signal-auth.ts new file mode 100644 index 000000000..ce289db28 --- /dev/null +++ b/setup/signal-auth.ts @@ -0,0 +1,182 @@ +/** + * Step: signal-auth — link this host to an existing Signal account via + * signal-cli's QR-code flow. + * + * signal-cli `link` opens a bi-directional handshake with the Signal + * servers: it prints one line containing a linking URL (`sgnl://linkdevice?…` + * or older `tsdevice://linkdevice?…`), then blocks until either the user + * scans it from an existing Signal install, or the code expires. On + * success, a secondary account is created under the user's signal-cli + * data directory, associated with the phone number of the scanner. + * + * Methods: + * (no args) Spawn signal-cli link, emit SIGNAL_AUTH_QR + * with the URL, wait for completion. + * + * Block schema (parent parses these): + * SIGNAL_AUTH_QR { QR: "" } — one-shot + * SIGNAL_AUTH { STATUS: success, ACCOUNT: + } — terminal + * { STATUS: skipped, ACCOUNT, REASON: already-authenticated } + * { STATUS: failed, ERROR: } + * + * STATUS values match the runner's vocabulary (success/skipped/failed) so + * spawnStep recognises them and sets `ok` correctly; Signal-specific UI + * lives in setup/channels/signal.ts. + * + * If one or more accounts are already linked (discovered via + * `signal-cli -o json listAccounts`), the step emits SIGNAL_AUTH + * STATUS=skipped with the first account so the driver can reuse it. + * Selecting a different existing account is a driver concern. + */ +import { spawn, spawnSync } from 'child_process'; + +import { emitStatus } from './status.js'; + +const LINK_TIMEOUT_MS = 180_000; +const DEFAULT_DEVICE_NAME = 'NanoClaw'; + +interface SignalAccount { + account?: string; + registered?: boolean; +} + +function cliPath(): string { + return process.env.SIGNAL_CLI_PATH || 'signal-cli'; +} + +/** + * Query signal-cli for currently linked accounts. Empty array if none + * configured, no binary, or the call fails for any other reason. + */ +function listAccounts(): string[] { + const cli = cliPath(); + try { + const res = spawnSync(cli, ['-o', 'json', 'listAccounts'], { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (res.status !== 0) return []; + const parsed = JSON.parse(res.stdout || '[]') as SignalAccount[]; + return parsed + .filter((a) => a.registered !== false) + .map((a) => a.account ?? '') + .filter(Boolean); + } catch { + return []; + } +} + +export async function run(_args: string[]): Promise { + const cli = cliPath(); + + // Verify signal-cli exists before we commit to the long-running link. + // The driver checks too, but this keeps the step honest when run alone. + const probe = spawnSync(cli, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (probe.error || probe.status !== 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'failed', + ERROR: 'signal-cli not found. Install signal-cli first.', + }); + return; + } + + const existing = listAccounts(); + if (existing.length > 0) { + emitStatus('SIGNAL_AUTH', { + STATUS: 'skipped', + ACCOUNT: existing[0], + REASON: 'already-authenticated', + }); + return; + } + + await new Promise((resolve) => { + let settled = false; + let qrEmitted = false; + + const finish = (block: Record, code: number): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + emitStatus('SIGNAL_AUTH', block); + resolve(); + setTimeout(() => process.exit(code), 500); + }; + + const timer = setTimeout(() => { + try { + child.kill('SIGTERM'); + } catch { + /* ignore */ + } + finish({ STATUS: 'failed', ERROR: 'qr_timeout' }, 1); + }, LINK_TIMEOUT_MS); + + const child = spawn(cli, ['link', '--name', DEFAULT_DEVICE_NAME], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // stdout carries the URL on the first line; subsequent lines may print + // status like "Associated with: +1555…". We don't strictly need to parse + // the number — listAccounts after exit is the source of truth — but the + // URL match drives the QR emit, which is the whole point. + let stdoutBuf = ''; + const handleStdout = (chunk: Buffer): void => { + stdoutBuf += chunk.toString('utf-8'); + let idx: number; + while ((idx = stdoutBuf.indexOf('\n')) !== -1) { + const line = stdoutBuf.slice(0, idx).trim(); + stdoutBuf = stdoutBuf.slice(idx + 1); + if (!line) continue; + // Match both modern (sgnl://) and legacy (tsdevice://) schemes. + if (/^(sgnl|tsdevice):\/\/linkdevice\?/.test(line) && !qrEmitted) { + qrEmitted = true; + emitStatus('SIGNAL_AUTH_QR', { QR: line }); + } + } + }; + child.stdout.on('data', handleStdout); + + // Capture stderr for the transcript / log — signal-cli writes warnings + // and errors there. We don't emit on partial stderr lines since a + // successful link can still produce noise. + let stderrBuf = ''; + child.stderr.on('data', (chunk: Buffer) => { + stderrBuf += chunk.toString('utf-8'); + }); + + child.on('error', (err) => { + finish({ STATUS: 'failed', ERROR: `spawn error: ${err.message}` }, 1); + }); + + child.on('close', (code) => { + // After a successful link, signal-cli exits 0 and the newly linked + // account shows up in listAccounts. Use that as the source of truth + // rather than scraping stdout — more robust across signal-cli versions. + if (code === 0) { + const post = listAccounts(); + if (post.length === 0) { + finish( + { STATUS: 'failed', ERROR: 'link exited 0 but no account registered' }, + 1, + ); + return; + } + finish({ STATUS: 'success', ACCOUNT: post[0] }, 0); + return; + } + + // Non-zero exit. Surface the last non-empty stderr line for context; + // signal-cli's own error messages are usually informative. + const lastErr = + stderrBuf + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .slice(-1)[0] ?? `signal-cli link exited with code ${code}`; + finish({ STATUS: 'failed', ERROR: lastErr }, 1); + }); + }); +} From ce28e7f5583959a8b827ee361af743b8266d0766 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 24 Apr 2026 01:27:20 +0300 Subject: [PATCH 29/31] docs(add-codex): bump CODEX_VERSION to 0.124.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/add-codex/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/add-codex/SKILL.md b/.claude/skills/add-codex/SKILL.md index 17910b7e7..3411bae78 100644 --- a/.claude/skills/add-codex/SKILL.md +++ b/.claude/skills/add-codex/SKILL.md @@ -67,7 +67,7 @@ Two edits to `container/Dockerfile`, both idempotent (skip if already present): **(a)** In the "Pin CLI versions" ARG block (around line 18), add after `ARG CLAUDE_CODE_VERSION=...`: ```dockerfile -ARG CODEX_VERSION=0.121.0 +ARG CODEX_VERSION=0.124.0 ``` **(b)** Add a new standalone `RUN` block for the Codex CLI, after the existing per-CLI install blocks (around line 106, right after the `@anthropic-ai/claude-code` block). The Dockerfile splits each global CLI into its own layer for cache granularity — keep that pattern; do not collapse them into a single combined `pnpm install -g` call: From 5845a5a98029c0a2d284e8607ead213a07eec499 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Thu, 23 Apr 2026 22:47:10 +0000 Subject: [PATCH 30/31] fix(container-runner): honor agent_provider DB columns with session override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveProviderContribution read only containerConfig.provider (from each group's container.json) and ignored both agent_groups.agent_provider and sessions.agent_provider. The provider-install skills (opencode, codex) and CLAUDE.md document those DB columns as the source of truth with session-overrides-group precedence, but the code never consulted them — so setting `agent_provider = 'codex'` on a group had no effect, and the only way to route to a non-default provider was to edit the per-group JSON directly. Discovered while wiring up Codex: DB update landed but the spawned container kept running Claude. Extract a pure `resolveProviderName(session, group, containerConfig)` with the documented precedence: sessions.agent_provider → agent_groups.agent_provider → container.json `provider` → 'claude' `resolveProviderContribution` now calls it. The container.json fallback stays so existing installs that only set provider in JSON keep working. Empty strings treated as unset to avoid footguns when a DB-backed form writes '' for "no override." Added unit tests covering precedence, null-fallthrough, empty-string fallthrough, and case normalization. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/container-runner.test.ts | 32 ++++++++++++++++++++++++++++++++ src/container-runner.ts | 21 ++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 src/container-runner.test.ts diff --git a/src/container-runner.test.ts b/src/container-runner.test.ts new file mode 100644 index 000000000..cd18a7289 --- /dev/null +++ b/src/container-runner.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveProviderName } from './container-runner.js'; + +describe('resolveProviderName', () => { + it('prefers session over group and container.json', () => { + expect(resolveProviderName('codex', 'opencode', 'claude')).toBe('codex'); + }); + + it('falls back to group when session is null', () => { + expect(resolveProviderName(null, 'codex', 'claude')).toBe('codex'); + }); + + it('falls back to container.json when session and group are null', () => { + expect(resolveProviderName(null, null, 'opencode')).toBe('opencode'); + }); + + it('defaults to claude when nothing is set', () => { + expect(resolveProviderName(null, null, undefined)).toBe('claude'); + }); + + it('lowercases the resolved name', () => { + expect(resolveProviderName('CODEX', null, null)).toBe('codex'); + expect(resolveProviderName(null, 'OpenCode', null)).toBe('opencode'); + expect(resolveProviderName(null, null, 'Claude')).toBe('claude'); + }); + + it('treats empty string as unset (falls through)', () => { + expect(resolveProviderName('', 'codex', null)).toBe('codex'); + expect(resolveProviderName(null, '', 'opencode')).toBe('opencode'); + }); +}); diff --git a/src/container-runner.ts b/src/container-runner.ts index fca88c490..029b5fe3d 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -191,12 +191,31 @@ export function killContainer(sessionId: string, reason: string): void { } } +/** + * Resolve the provider name for a session using the precedence documented in + * the provider-install skills: + * + * sessions.agent_provider + * → agent_groups.agent_provider + * → container.json `provider` + * → 'claude' + * + * Pure so the precedence can be unit-tested without a DB or filesystem. + */ +export function resolveProviderName( + sessionProvider: string | null | undefined, + agentGroupProvider: string | null | undefined, + containerConfigProvider: string | null | undefined, +): string { + return (sessionProvider || agentGroupProvider || containerConfigProvider || 'claude').toLowerCase(); +} + function resolveProviderContribution( session: Session, agentGroup: AgentGroup, containerConfig: import('./container-config.js').ContainerConfig, ): { provider: string; contribution: ProviderContainerContribution } { - const provider = (containerConfig.provider || 'claude').toLowerCase(); + const provider = resolveProviderName(session.agent_provider, agentGroup.agent_provider, containerConfig.provider); const fn = getProviderContainerConfig(provider); const contribution = fn ? fn({ From a4346f566c87a25418aa5e783fc2a54089e11e6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 23 Apr 2026 22:54:40 +0000 Subject: [PATCH 31/31] =?UTF-8?q?docs:=20update=20token=20count=20to=20130?= =?UTF-8?q?k=20tokens=20=C2=B7=2065%=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 fd252673d..fd8a4363f 100644 --- a/repo-tokens/badge.svg +++ b/repo-tokens/badge.svg @@ -1,5 +1,5 @@ - - 129k tokens, 64% of context window + + 130k tokens, 65% of context window @@ -15,8 +15,8 @@ tokens - - 129k + + 130k