diff --git a/README.md b/README.md index 08c9cd3896d..c19cff4f396 100644 --- a/README.md +++ b/README.md @@ -1,134 +1,295 @@ -# Ironclaw +

+

+ ██╗██████╗  ██████╗ ███╗   ██╗ ██████╗██╗      █████╗ ██╗    ██╗
+ ██║██╔══██╗██╔═══██╗████╗  ██║██╔════╝██║     ██╔══██╗██║    ██║
+ ██║██████╔╝██║   ██║██╔██╗ ██║██║     ██║     ███████║██║ █╗ ██║
+ ██║██╔══██╗██║   ██║██║╚██╗██║██║     ██║     ██╔══██║██║███╗██║
+ ██║██║  ██║╚██████╔╝██║ ╚████║╚██████╗███████╗██║  ██║╚███╔███╔╝
+ ╚═╝╚═╝  ╚═╝ ╚═════╝ ╚═╝  ╚═══╝ ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝
+  
+

-**AI-powered CRM platform with multi-channel agent gateway, DuckDB workspace, and knowledge management.** +

+ AI CRM, hosted locally on your Mac. +

+ +

+ Chat with your database. Automate outreach. Enrich leads. All from a single prompt. +

npm version + Discord MIT License

+

+ Website · Docs · OpenClaw Framework · Discord · Skills Store +

+ --- -Ironclaw is a personal AI assistant and CRM toolkit that runs on your own devices. It connects to your existing messaging channels (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, and more), manages structured data through a DuckDB-powered workspace, and provides a rich web interface for knowledge management and reporting. - -Built on [OpenClaw](https://github.com/openclaw/openclaw) with **Vercel AI SDK v6** as the default LLM orchestration layer. - -## Features - -- **Multi-channel inbox** -- WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, Matrix, WebChat, and more. -- **DuckDB workspace** -- Structured data objects, file management, full-text search, and bulk operations through a local DuckDB-backed store. -- **Web UI (Dench)** -- Modern chat interface with chain-of-thought reasoning, report cards, media viewer, and a database explorer. Supports light and dark themes. -- **Agent gateway** -- Local-first WebSocket control plane for sessions, channels, tools, and events. Routes agent execution through lane-based concurrency. -- **Vercel AI SDK v6** -- Default LLM engine with support for Anthropic, OpenAI, Google, Groq, Mistral, xAI, OpenRouter, and Azure. Full extended thinking/reasoning support. -- **Knowledge management** -- File tree, search index, workspace objects with custom fields, and entry-level detail views. -- **TanStack data tables** -- Sortable, filterable, bulk-selectable tables for workspace objects powered by `@tanstack/react-table`. -- **Companion apps** -- macOS menu bar app, iOS/Android nodes with voice, camera, and canvas capabilities. -- **Skills platform** -- Bundled, managed, and workspace-scoped skills with install gating. - ## Install **Runtime: Node 22+** -### From npm - ```bash -npm install -g ironclaw@latest - +npm i -g ironclaw ironclaw onboard --install-daemon ``` -### From source +Opens at `localhost:3100`. That's it. -```bash -git clone https://github.com/kumarabhirup/ironclaw.git -cd ironclaw +Three steps total: -pnpm install -pnpm build - -pnpm dev onboard --install-daemon +``` +1. npm i -g ironclaw +2. ironclaw onboard +3. ironclaw gateway start ``` -## Quick start +--- -```bash -# Start the gateway -ironclaw gateway --port 18789 --verbose +## What is Ironclaw? -# Send a message -ironclaw message send --to +1234567890 --message "Hello from Ironclaw" +Ironclaw is a personal AI agent and CRM that runs locally on your machine. It connects to every messaging channel you use, manages structured data through DuckDB, browses the web with your Chrome profile, and gives you a full web UI for pipeline management, analytics, and document management. -# Talk to the agent -ironclaw agent --message "Summarize today's tasks" --thinking high +Built on [OpenClaw](https://github.com/openclaw/openclaw) with **Vercel AI SDK v6** as the LLM orchestration layer. + +**One prompt does everything:** + +- "Find YC W26 founders building AI companies" → scrapes YC directory + LinkedIn, returns 127 matches +- "Enrich all contacts with LinkedIn and email" → fills in profiles with 98% coverage +- "Send personalized messages to qualified leads" → crafts and sends custom outreach +- "Show me pipeline stats for this quarter" → generates interactive charts from live data +- "Set up weekly follow-up sequences for all leads" → creates automation rules that run in the background + +--- + +## Use Cases + +### Find Leads + +Type a prompt, Ironclaw scrapes the web using your actual Chrome profile (all your auth sessions, cookies, history). It logs into LinkedIn, browses YC batches, pulls company data. No separate login, no API keys for browsing. + +### Enrich Data + +Point it at your contacts table. It fills in LinkedIn URLs, email addresses, education, company info. Enrichment runs in bulk with real-time progress. + +### Send Outreach + +Personalized LinkedIn messages, cold emails, follow-up sequences. Each message is customized per lead. You see status (Sent, Sending, Queued) in real time. + +### Analyze Pipeline + +Ask for analytics in plain English. Ironclaw queries your DuckDB workspace and generates interactive Recharts dashboards inline. Pipeline funnels, outreach activity charts, conversion rates, donut breakdowns. + +### Automate Everything + +Cron jobs that run on schedule. Follow-up if no reply after 3 days. Move leads to Qualified when they reply. Weekly pipeline reports every Monday. Alert on high-intent replies. + +--- + +## Core Capabilities + +### Uses Your Chrome Profile + +Unlike other AI tools, Ironclaw copies your existing Chrome profile with all your auth sessions, cookies, and history. It logs into LinkedIn, scrapes YC batches, and sends messages as you. No separate browser login needed. + +### Chat with Your Database + +Ask questions in plain English. Ironclaw translates to SQL, queries your local DuckDB, and returns structured results. Like having a data analyst on speed dial. + +``` +You: "How many founders have we contacted from YC W26?" + +→ SELECT "Status", COUNT(*) as count FROM v_founders GROUP BY "Status"; + +You've contacted 67 of 200 founders. 31 are qualified, 13 converted. +Reply rate is 34%. ``` -## Web UI +### Coding Agent with Diffs -The web application lives in `apps/web/` and is built with Next.js. It provides: +Ironclaw writes code. Review changes in a rich diff viewer before applying. Config changes, automation scripts, data transformations. All with diffs you approve. -- **Chat panel** with streaming responses, chain-of-thought display, and markdown rendering (via `react-markdown` + `remark-gfm`). -- **Workspace sidebar** with a file manager tree, knowledge tree, and database viewer. -- **Object tables** with sorting, filtering, row selection, and bulk delete. -- **Entry detail modals** with field editing and media previews. -- **Report cards** with chart panels and filter bars. -- **Media viewer** supporting images, video, audio, and PDFs. +### Your Second Brain -To run the web UI in development: +Full access to your Mac: files, apps, documents. It remembers context across sessions via persistent memory files. Learns your preferences. Proactively handles tasks during heartbeat checks. + +--- + +## Web UI (Dench) + +The web app runs at `localhost:3100` and includes: + +- **Chat panel** with streaming responses, chain-of-thought reasoning display, and markdown rendering +- **Workspace sidebar** with file manager tree, knowledge base, and database viewer +- **Object tables** powered by TanStack, with sorting, filtering, row selection, and bulk operations +- **Entry detail modals** with field editing and media previews +- **Kanban boards** with drag-and-drop that auto-update as leads reply +- **Interactive report cards** with chart panels (bar, line, area, pie, donut, funnel, scatter, radar) and filter bars +- **Document editor** with embedded live charts +- **Media viewer** supporting images, video, audio, and PDFs + +--- + +## Multi-Channel Inbox + +One agent, every channel. Connect any messaging platform. Your AI agent responds everywhere, managed from a single terminal. + +| Channel | Setup | +| ------------------- | ------------------------------------------------------------- | +| **WhatsApp** | `ironclaw channels login` + set `channels.whatsapp.allowFrom` | +| **Telegram** | Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken` | +| **Slack** | Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` | +| **Discord** | Set `DISCORD_BOT_TOKEN` or `channels.discord.token` | +| **Signal** | Requires `signal-cli` + `channels.signal` config | +| **iMessage** | Via BlueBubbles (recommended) or legacy macOS integration | +| **Microsoft Teams** | Configure Teams app + Bot Framework | +| **Google Chat** | Chat API integration | +| **Matrix** | Extension channel | +| **WebChat** | Built-in, uses Gateway WebSocket directly | + +``` + WhatsApp · Telegram · Slack · Discord + Signal · iMessage · Teams · WebChat + │ + ▼ + ┌────────────────────────────┐ + │ Ironclaw Gateway │ + │ ws://127.0.0.1:18789 │ + └─────────────┬──────────────┘ + │ + ┌───────────┼───────────┐ + │ │ │ + ▼ ▼ ▼ + AI SDK Web UI CLI + Engine (Dench) (ironclaw) +``` + +--- + +## Integrations + +Import your data from anywhere: Google Drive, Notion, Salesforce, HubSpot, Gmail, Calendar, Obsidian, Slack, LinkedIn, Asana, Monday, ClickUp, PostHog, Sheets, Apple Notes, GitHub, and 50+ more via the Skills Store. + +--- + +## Skills Platform + +Extend your agent with a single command. Browse skills from [skills.sh](https://skills.sh) and [ClawHub](https://clawhub.com). ```bash -cd apps/web -pnpm install -pnpm dev +npx skills add vercel-labs/agent-browser ``` +Popular skills: + +| Skill | Description | Installs | +| ----------------------- | ---------------------------------------------------------- | -------- | +| `crm-automation` | CRM workflow automation, lead scoring, pipeline management | 18.2K | +| `linkedin-outreach` | Automated LinkedIn prospecting and follow-up sequences | 14.8K | +| `lead-enrichment` | Enrich contacts with LinkedIn, email, and company data | 12.1K | +| `email-sequences` | Multi-step cold email campaigns with personalization | 9.7K | +| `agent-browser` | Browser automation and web scraping for agents | 35.8K | +| `web-design-guidelines` | Best practices for modern web design | 99.4K | +| `frontend-design` | Expert frontend engineering patterns | 68.9K | +| `typescript-expert` | Advanced TypeScript patterns and best practices | 15.1K | + +Or write your own. Skills are just a `SKILL.md` file with instructions + optional scripts. + +--- + +## Analytics & Reports + +Ask "show me pipeline analytics" and get interactive charts generated from your live DuckDB data. + +- **Outreach Activity** — area charts tracking LinkedIn and email volume over time +- **Pipeline Breakdown** — donut charts showing lead distribution by status +- **Conversion Funnel** — stage-by-stage conversion rates with overall percentage +- **Deal Pipeline** — bar charts, funnel views, revenue by stage +- **Custom Reports** — save as `.report.json` files that render as live dashboards + +Reports use the `report-json` format and render inline in chat as interactive Recharts components. + +--- + +## Kanban Pipeline + +Drag-and-drop kanban boards that auto-update as leads reply. Ironclaw moves cards through your pipeline automatically. + +Columns like New Lead → Contacted → Qualified → Demo Scheduled → Closed map to your sales process. Each card shows the lead name, company, and last action taken. + +--- + +## Documents, Reports & Cron Jobs + +### Documents + +Rich markdown documents with embedded live charts. SOPs, playbooks, onboarding guides. Documents nest under objects or stand alone in the file tree. + +### Cron Jobs + +Scheduled automations that run in the background: + +| Job | Schedule | Description | +| ---------------------- | -------------- | ------------------------------------ | +| Weekly pipeline report | `0 9 * * MON` | Auto-generates pipeline summary | +| Lead enrichment sync | Every 6h | Enriches new contacts | +| Email follow-up check | Every 30m | Checks for replies needing follow-up | +| Inbox digest | `0 8,18 * * *` | Morning and evening inbox summary | +| Competitor monitoring | `0 6 * * *` | Tracks competitor activity | +| CRM backup to S3 | `0 2 * * *` | Nightly workspace backup | + +```bash +ironclaw cron list +``` + +--- + +## Gateway + +The Gateway is the local-first WebSocket control plane that routes everything: + +- **Sessions** — main sessions for DMs, isolated sessions for group chats, sub-agent sessions for background tasks +- **Channels** — route inbound messages from any platform to the right session +- **Tools** — browser control, canvas, nodes, cron, messaging, file operations +- **Events** — webhooks, Gmail Pub/Sub, cron triggers, heartbeats +- **Multi-agent routing** — route channels/accounts/peers to isolated agents with separate workspaces + +### Session Model + +- `main` — direct 1:1 chats with persistent context +- `group` — isolated per-group sessions with mention gating +- `isolated` — sub-agent sessions for background tasks (cron jobs, spawned work) + +### Security + +- **DM pairing** enabled by default. Unknown senders get a pairing code. +- Approve with `ironclaw pairing approve ` +- Non-main sessions can be sandboxed in Docker +- Run `ironclaw doctor` to audit DM policies + +--- + +## Companion Apps + +- **macOS** — menu bar app with Voice Wake, Push-to-Talk, Talk Mode overlay, WebChat, and debug tools +- **iOS** — Canvas, Voice Wake, Talk Mode, camera, screen recording, Bonjour pairing +- **Android** — Canvas, Talk Mode, camera, screen recording, optional SMS + +--- + ## Configuration -Ironclaw stores its config at `~/.openclaw/openclaw.json`. Minimal example: +Config lives at `~/.openclaw/openclaw.json`: -```json5 -{ - agent: { - model: "anthropic/claude-opus-4-6", - }, -} -``` +Supports all latest and greatest mainstream LLM models. BYOK. -### Supported providers +--- -| Provider | Environment Variable | Models | -| ---------- | ------------------------------ | --------------------------------- | -| Anthropic | `ANTHROPIC_API_KEY` | Claude 4/3.x, Opus, Sonnet, Haiku | -| OpenAI | `OPENAI_API_KEY` | GPT-4o, GPT-4, o1, o3 | -| Google | `GOOGLE_GENERATIVE_AI_API_KEY` | Gemini 2.x, 1.5 Pro | -| OpenRouter | `OPENROUTER_API_KEY` | 100+ models | -| Groq | `GROQ_API_KEY` | Llama, Mixtral | -| Mistral | `MISTRAL_API_KEY` | Mistral models | -| xAI | `XAI_API_KEY` | Grok models | -| Azure | `AZURE_OPENAI_API_KEY` | Azure OpenAI models | - -### Thinking / reasoning - -```bash -# Set thinking level (maps to AI SDK budgetTokens) -ironclaw agent --message "Complex analysis" --thinking high - -# Levels: off, minimal, low, medium, high, xhigh -``` - -## Channel setup - -Each channel is configured in `~/.openclaw/openclaw.json` under `channels.*`: - -- **WhatsApp** -- Link via `ironclaw channels login`. Set `channels.whatsapp.allowFrom`. -- **Telegram** -- Set `TELEGRAM_BOT_TOKEN` or `channels.telegram.botToken`. -- **Slack** -- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`. -- **Discord** -- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`. -- **Signal** -- Requires `signal-cli` + `channels.signal` config. -- **iMessage** -- Via BlueBubbles (recommended) or legacy macOS-only integration. -- **Microsoft Teams** -- Configure a Teams app + Bot Framework. -- **WebChat** -- Uses the Gateway WebSocket directly, no extra config. - -## Chat commands +## Chat Commands Send these in any connected channel: @@ -143,26 +304,74 @@ Send these in any connected channel: | `/restart` | Restart the gateway | | `/activation mention\|always` | Group activation toggle | -## Architecture +--- -``` -WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Teams / WebChat - | - v - +----------------------------+ - | Gateway | - | (control plane) | - | ws://127.0.0.1:18789 | - +-------------+--------------+ - | - +-- Vercel AI SDK v6 engine - +-- CLI (ironclaw ...) - +-- Web UI (Dench) - +-- macOS app - +-- iOS / Android nodes +## DuckDB Workspace + +All structured data lives in a local DuckDB database. Objects, fields, entries, relations. EAV pattern with auto-generated PIVOT views so you query like normal tables: + +```sql +SELECT * FROM v_leads WHERE "Status" = 'New' ORDER BY created_at DESC LIMIT 50; +SELECT "Status", COUNT(*) FROM v_leads GROUP BY "Status"; ``` -## Project structure +Features: + +- Custom objects with typed fields (text, email, phone, number, boolean, date, enum, relation, user) +- Full-text search +- Bulk import/export (CSV, Parquet) +- Automatic view generation +- Kanban support with drag-and-drop + +--- + +## Quick Start + +```bash +# Install +npm i -g ironclaw + +# Run onboarding wizard +ironclaw onboard --install-daemon + +# Start the gateway +ironclaw gateway start + +# Open the web UI +open http://localhost:3100 + +# Talk to your agent from CLI +ironclaw agent --message "Summarize my inbox" --thinking high + +# Send a message +ironclaw message send --to +1234567890 --message "Hello from Ironclaw" +``` + +--- + +## From Source + +```bash +git clone https://github.com/kumarabhirup/ironclaw.git +cd ironclaw + +pnpm install +pnpm build + +pnpm dev onboard --install-daemon +``` + +Web UI development: + +```bash +cd apps/web +pnpm install +pnpm dev +``` + +--- + +## Project Structure ``` src/ Core CLI, commands, gateway, agent, media pipeline @@ -176,6 +385,8 @@ scripts/ Build, deploy, and utility scripts skills/ Workspace skills ``` +--- + ## Development ```bash @@ -187,16 +398,11 @@ pnpm test:coverage # Tests with coverage pnpm dev # Dev mode (auto-reload) ``` -## Security - -- DM pairing is enabled by default -- unknown senders receive a pairing code. -- Approve senders with `ironclaw pairing approve `. -- Non-main sessions can be sandboxed in Docker (`agents.defaults.sandbox.mode: "non-main"`). -- Run `ironclaw doctor` to surface risky or misconfigured DM policies. +--- ## Upstream -Ironclaw is a fork of [OpenClaw](https://github.com/openclaw/openclaw). To sync with upstream: +Ironclaw is built on [OpenClaw](https://github.com/openclaw/openclaw). To sync with upstream: ```bash git remote add upstream https://github.com/openclaw/openclaw.git @@ -204,6 +410,12 @@ git fetch upstream git merge upstream/main ``` -## License +--- -[MIT](LICENSE) +## Open Source + +MIT Licensed. Fork it, extend it, make it yours. + +

+ GitHub stars +

diff --git a/src/gateway/server-web-app.test.ts b/src/gateway/server-web-app.test.ts index 00d15a79957..eac346f61db 100644 --- a/src/gateway/server-web-app.test.ts +++ b/src/gateway/server-web-app.test.ts @@ -1,5 +1,6 @@ import { spawn, type ChildProcess } from "node:child_process"; import fs from "node:fs"; +import http from "node:http"; import path from "node:path"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; @@ -320,7 +321,8 @@ describe("server-web-app", () => { }); const log = makeLog(); - const resultPromise = startWebAppIfEnabled({ enabled: true }, log); + // Use an explicit high port to avoid the real web app on default port. + const resultPromise = startWebAppIfEnabled({ enabled: true, port: 49100 }, log); await vi.advanceTimersByTimeAsync(3_500); const result = await resultPromise; @@ -345,7 +347,8 @@ describe("server-web-app", () => { }); const log = makeLog(); - const result = await startWebAppIfEnabled({ enabled: true }, log); + // Use an explicit high port to avoid the real web app on default port. + const result = await startWebAppIfEnabled({ enabled: true, port: 49101 }, log); expect(result).toBeNull(); expect(log.error).toHaveBeenCalledWith(expect.stringContaining("standalone build not found")); @@ -391,7 +394,8 @@ describe("server-web-app", () => { return false; }); - const resultPromise = startWebAppIfEnabled({ enabled: true }, makeLog()); + // Use an explicit high port to avoid the real web app on default port. + const resultPromise = startWebAppIfEnabled({ enabled: true, port: 49102 }, makeLog()); await vi.advanceTimersByTimeAsync(3_500); const result = await resultPromise; expect(result).not.toBeNull(); @@ -423,7 +427,8 @@ describe("server-web-app", () => { }); const log = makeLog(); - const resultPromise = startWebAppIfEnabled({ enabled: true }, log); + // Use an explicit high port to avoid the real web app on default port. + const resultPromise = startWebAppIfEnabled({ enabled: true, port: 49103 }, log); // Simulate the child crashing immediately (e.g. Cannot find module 'next'). child.exitCode = 1; @@ -435,5 +440,87 @@ describe("server-web-app", () => { expect(log.error).toHaveBeenCalledWith(expect.stringContaining("web app failed to start")); vi.useRealTimers(); }); + + it("reuses existing web app when preferred port already has Next.js running", async () => { + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + + // Clear accumulated spawn call history from previous tests (the + // module-level vi.mock isn't reset by vi.restoreAllMocks). + vi.mocked(spawn).mockClear(); + + // resolveWebAppDir() needs to find a valid directory before reaching + // the port check, so mock existsSync for the package.json check. + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + return false; + }); + + // Start a real HTTP server that mimics a Next.js app on a high port. + const testPort = 13_199; + const httpServer = http.createServer((_, res) => { + res.setHeader("x-powered-by", "Next.js"); + res.writeHead(200); + res.end("ok"); + }); + await new Promise((resolve) => httpServer.listen(testPort, resolve)); + + try { + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true, port: testPort }, log); + + expect(result).not.toBeNull(); + expect(result!.port).toBe(testPort); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("already running")); + // No child process should be spawned for the reuse path. + expect(spawn).not.toHaveBeenCalled(); + + // stop() should be a no-op (we didn't spawn this process). + await expect(result!.stop()).resolves.toBeUndefined(); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + }); + + it("finds alternative port when preferred port is occupied by non-Next.js app", async () => { + // Real timers: multiple async I/O steps (isPortFree + probeForWebApp) + // run before the timer-based waitForStartupOrCrash, so fake timers + // can't advance the clock early enough. + const { startWebAppIfEnabled } = await import("./server-web-app.js"); + mockChildProcess(); + + existsSyncSpy.mockImplementation((p) => { + const s = String(p); + if (s.endsWith(path.join("apps", "web", "package.json"))) { + return true; + } + if (s.includes(path.join(".next", "standalone", "apps", "web", "server.js"))) { + return true; + } + return false; + }); + + // Start a plain HTTP server (no x-powered-by: Next.js) to simulate + // another app occupying the port. + const testPort = 13_200; + const httpServer = http.createServer((_, res) => { + res.writeHead(200); + res.end("not next"); + }); + await new Promise((resolve) => httpServer.listen(testPort, resolve)); + + try { + const log = makeLog(); + const result = await startWebAppIfEnabled({ enabled: true, port: testPort }, log); + + expect(result).not.toBeNull(); + expect(result!.port).not.toBe(testPort); + expect(log.info).toHaveBeenCalledWith(expect.stringContaining("is busy")); + } finally { + await new Promise((resolve) => httpServer.close(() => resolve())); + } + }, 15_000); }); }); diff --git a/src/gateway/server-web-app.ts b/src/gateway/server-web-app.ts index 4ed99c6206a..b37d21b62bd 100644 --- a/src/gateway/server-web-app.ts +++ b/src/gateway/server-web-app.ts @@ -1,6 +1,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn } from "node:child_process"; import fs from "node:fs"; +import http from "node:http"; import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -98,6 +99,27 @@ function isPortFree(port: number): Promise { }); } +/** + * Probe whether our Next.js web app is already serving on the given port. + * Returns true when the server responds with the `x-powered-by: Next.js` header. + * Used during gateway restarts to detect a still-running prior instance and + * reuse it instead of spawning a broken duplicate on another port. + */ +export function probeForWebApp(port: number): Promise { + return new Promise((resolve) => { + const req = http.get({ hostname: "127.0.0.1", port, path: "/", timeout: 1_000 }, (res) => { + const powered = res.headers["x-powered-by"]; + res.resume(); + resolve(powered === "Next.js"); + }); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + }); +} + /** * Find an available port, preferring `preferred`. * @@ -219,6 +241,38 @@ export async function ensureWebAppBuilt( }; } +/** + * Ensure the standalone output has access to static assets and public files. + * + * Next.js standalone builds intentionally exclude `.next/static` and `public` + * (they're meant for CDN serving). When self-hosting, we symlink them into the + * standalone directory so the built-in server can serve CSS, JS, fonts, etc. + */ +function ensureStandaloneAssets(webAppDir: string): void { + const standaloneAppDir = path.join(webAppDir, ".next", "standalone", "apps", "web"); + + // Symlink .next/static → standalone's .next/static + const staticSrc = path.join(webAppDir, ".next", "static"); + const staticDest = path.join(standaloneAppDir, ".next", "static"); + symlinkIfMissing(staticSrc, staticDest); + + // Symlink public → standalone's public + const publicSrc = path.join(webAppDir, "public"); + const publicDest = path.join(standaloneAppDir, "public"); + symlinkIfMissing(publicSrc, publicDest); +} + +function symlinkIfMissing(src: string, dest: string): void { + if (!fs.existsSync(src)) { + return; + } + if (fs.existsSync(dest)) { + return; + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.symlinkSync(src, dest, "junction"); +} + /** * Start the Ironclaw Next.js web app as a child process. * @@ -246,19 +300,36 @@ export async function startWebAppIfEnabled( return null; } - const preferredPort = cfg.port ?? DEFAULT_WEB_APP_PORT; - const port = await findAvailablePort(preferredPort); - if (port !== preferredPort) { - log.info(`port ${preferredPort} is busy; using port ${port} instead`); - } - const devMode = cfg.dev === true; - + // Check for the web app directory early (fails fast when apps/web is absent, + // before doing any network probes or port detection). const webAppDir = resolveWebAppDir(); if (!webAppDir) { log.warn("apps/web directory not found — skipping web app"); return null; } + const preferredPort = cfg.port ?? DEFAULT_WEB_APP_PORT; + let port: number; + + if (await isPortFree(preferredPort)) { + // Fast path: preferred port is available. + port = preferredPort; + } else if (await probeForWebApp(preferredPort)) { + // Our web app is already serving on the preferred port (e.g. orphaned + // child from a prior gateway run or a restart race). Reuse it instead + // of spawning a broken duplicate on another port. + log.info(`web app already running on port ${preferredPort} — reusing`); + return { port: preferredPort, stop: async () => {} }; + } else { + // Something else occupies the preferred port — find an alternative. + port = await findAvailablePort(preferredPort); + if (port !== preferredPort) { + log.info(`port ${preferredPort} is busy; using port ${port} instead`); + } + } + + const devMode = cfg.dev === true; + let child: ChildProcess; if (devMode) { @@ -282,6 +353,7 @@ export async function startWebAppIfEnabled( if (fs.existsSync(serverJs)) { // Standalone build found — just run it (npm global install or post-build). + ensureStandaloneAssets(webAppDir); log.info(`starting web app (standalone) on port ${port}…`); child = spawn("node", [serverJs], { cwd: path.dirname(serverJs), @@ -310,6 +382,7 @@ export async function startWebAppIfEnabled( // After building, prefer standalone if the config produced it. if (fs.existsSync(serverJs)) { + ensureStandaloneAssets(webAppDir); log.info(`starting web app (standalone) on port ${port}…`); child = spawn("node", [serverJs], { cwd: path.dirname(serverJs), diff --git a/src/wizard/onboarding.finalize.ts b/src/wizard/onboarding.finalize.ts index 7f9d935ba6c..12a2f0f3e8e 100644 --- a/src/wizard/onboarding.finalize.ts +++ b/src/wizard/onboarding.finalize.ts @@ -24,7 +24,11 @@ import { } from "../commands/onboard-helpers.js"; import { resolveGatewayService } from "../daemon/service.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; -import { DEFAULT_WEB_APP_PORT, ensureWebAppBuilt } from "../gateway/server-web-app.js"; +import { + DEFAULT_WEB_APP_PORT, + ensureWebAppBuilt, + probeForWebApp, +} from "../gateway/server-web-app.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { setupOnboardingShellCompletion } from "./onboarding.completion.js"; @@ -270,9 +274,9 @@ export async function finalizeOnboardingWizard( : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; await prompter.note( [ - `Web UI: ${links.httpUrl}`, + `Control UI: ${links.httpUrl}`, settings.authMode === "token" && settings.gatewayToken - ? `Web UI (with token): ${authedUrl}` + ? `Control UI (with token): ${authedUrl}` : undefined, `Gateway WS: ${links.wsUrl}`, gatewayStatusLine, @@ -303,7 +307,10 @@ export async function finalizeOnboardingWizard( // Always open the Next.js web app in the browser (TUI available via `openclaw tui`). if (webAppReady) { - const webAppPort = nextConfig.gateway?.webApp?.port ?? DEFAULT_WEB_APP_PORT; + // Probe for the actual port the web app is serving on — the daemon + // may have picked a different port if the configured one was busy. + const preferredWebAppPort = nextConfig.gateway?.webApp?.port ?? DEFAULT_WEB_APP_PORT; + const webAppPort = await detectRunningWebAppPort(preferredWebAppPort); const webAppUrl = `http://localhost:${webAppPort}`; const browserSupport = await detectBrowserOpenSupport(); if (browserSupport.ok) { @@ -433,3 +440,22 @@ export async function finalizeOnboardingWizard( return { launchedTui: false }; } + +/** + * Probe for the actual port the Next.js web app is serving on. + * The gateway may have picked a different port if the preferred one was busy + * (mirrors the fallback logic in `startWebAppIfEnabled`). + */ +async function detectRunningWebAppPort(preferred: number): Promise { + if (await probeForWebApp(preferred)) { + return preferred; + } + for (let offset = 1; offset <= 10; offset++) { + const candidate = preferred + offset; + if (candidate <= 65535 && (await probeForWebApp(candidate))) { + return candidate; + } + } + // Not detected — fall back to the preferred port for display. + return preferred; +}