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..6a6d85862 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/SKILL.md @@ -0,0 +1,243 @@ +--- +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. + +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 + +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 + +### Copy the MCP server source + +```bash +cp .claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts container/agent-runner/src/atomic-chat-mcp-stdio.ts +``` + +### 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 +# 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= +``` + +### Validate code changes + +```bash +pnpm run build +pnpm exec tsc -p container/agent-runner/tsconfig.json --noEmit +./container/build.sh +``` + +All three 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 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" + +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/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts b/.claude/skills/add-atomic-chat-tool/atomic-chat-mcp-stdio.ts new file mode 100644 index 000000000..019864420 --- /dev/null +++ b/.claude/skills/add-atomic-chat-tool/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/package.json b/package.json index 1d67485dc..77920c456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nanoclaw", - "version": "2.0.4", + "version": "2.0.7", "description": "Personal Claude assistant. Lightweight, secure, customizable.", "type": "module", "packageManager": "pnpm@10.33.0", 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': [ 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 {