From 2383bde80fc621d4ecb52db90a3335ad713bb85a Mon Sep 17 00:00:00 2001 From: Lazer Cohen Date: Thu, 23 Apr 2026 12:12:30 +0300 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 3a9b98f1a46261e97137ca0ffaa784102dee30fa Mon Sep 17 00:00:00 2001 From: Misha Skvortsov Date: Thu, 23 Apr 2026 16:18:34 +0300 Subject: [PATCH 4/7] 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 5/7] 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 6/7] 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 7/7] 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",