From b4061c4ec7ba4ce68a1f52478bfe747db3bcbfee Mon Sep 17 00:00:00 2001
From: machieke <110525763+machieke@users.noreply.github.com>
Date: Thu, 6 Aug 2026 15:00:47 +0000
Subject: [PATCH] feat: add OmegaClaw container control UI
---
.dockerignore | 2 +
.gitignore | 7 +-
README.md | 40 ++
compose.yaml | 81 +++
control-ui/Dockerfile | 31 ++
control-ui/Dockerfile.dockerignore | 11 +
control-ui/index.html | 17 +
control-ui/package-lock.json | 838 +++++++++++++++++++++++++++++
control-ui/package.json | 21 +
control-ui/server.mjs | 472 ++++++++++++++++
control-ui/server.test.mjs | 49 ++
control-ui/src/App.jsx | 494 +++++++++++++++++
control-ui/src/main.jsx | 10 +
control-ui/src/styles.css | 246 +++++++++
control-ui/vite.config.js | 12 +
scripts/start-control-ui | 36 ++
scripts/start-control-ui.ps1 | 30 ++
17 files changed, 2396 insertions(+), 1 deletion(-)
create mode 100644 compose.yaml
create mode 100644 control-ui/Dockerfile
create mode 100644 control-ui/Dockerfile.dockerignore
create mode 100644 control-ui/index.html
create mode 100644 control-ui/package-lock.json
create mode 100644 control-ui/package.json
create mode 100644 control-ui/server.mjs
create mode 100644 control-ui/server.test.mjs
create mode 100644 control-ui/src/App.jsx
create mode 100644 control-ui/src/main.jsx
create mode 100644 control-ui/src/styles.css
create mode 100644 control-ui/vite.config.js
create mode 100755 scripts/start-control-ui
create mode 100644 scripts/start-control-ui.ps1
diff --git a/.dockerignore b/.dockerignore
index 61f630c9..9549e7fc 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -27,6 +27,8 @@ __pycache__/
# Container metadata
Dockerfile
docker-compose.yml
+compose.yaml
+control-ui/
# Docs and license (not required at runtime)
README.md
diff --git a/.gitignore b/.gitignore
index e8aaef53..d7c86d02 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,11 @@ memory/.channel/
# venv
.venv
+# Node build artifacts
+node_modules/
+control-ui/dist/
+proofshot-artifacts/
+
# editor backup files
*~
Autotests/venv/
@@ -26,4 +31,4 @@ Autotests/logs/
# logs
logs/*.log
-logs/*.log.*
\ No newline at end of file
+logs/*.log.*
diff --git a/README.md b/README.md
index 44ba26f0..95cc3b68 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,46 @@ To reset OmegaClaw's memory:
docker volume rm omegaclaw-memory
```
+### Run with the visual control panel
+
+The included Compose application starts a local React control panel first and
+keeps the OmegaClaw agent stopped until it has a channel and LLM configuration.
+
+In Docker Desktop, open [`compose.yaml`](./compose.yaml), start the `control`
+service, and use the published `3210:3210` port link. The page is also available
+at [http://localhost:3210](http://localhost:3210).
+
+For a one-command start that also opens the default browser, use the launcher
+for your operating system:
+
+```sh
+# macOS, Linux, or WSL
+./scripts/start-control-ui
+```
+
+```powershell
+# Windows PowerShell
+.\scripts\start-control-ui.ps1
+```
+
+Select a communication channel and LLM provider in the page, enter their
+credentials, accept the safety notice, and press **Start OmegaClaw**. Pressing
+**Stop** stops the agent container without deleting its memory volume. Starting
+again recreates only the agent container with the new settings.
+
+The control page is bound to `127.0.0.1` by default. It mounts the Docker socket
+so it can manage the `omegaclaw` service declared in the same Compose file; do
+not expose the control port to other machines. API credentials are not stored
+in browser storage, but Docker necessarily places them in the agent container's
+environment while it runs.
+
+Optional environment overrides:
+
+| Variable | Default | Purpose |
+|---|---|---|
+| `OMEGACLAW_CONTROL_PORT` | `3210` | Host port for the control page. |
+| `OMEGACLAW_IMAGE` | `singularitynet/omegaclaw:latest` | OmegaClaw image started by the panel. |
+
---
## Usage
diff --git a/compose.yaml b/compose.yaml
new file mode 100644
index 00000000..108911a7
--- /dev/null
+++ b/compose.yaml
@@ -0,0 +1,81 @@
+name: omegaclaw
+
+services:
+ control:
+ build:
+ context: .
+ dockerfile: control-ui/Dockerfile
+ image: omegaclaw-control-ui:local
+ container_name: omegaclaw-control
+ restart: unless-stopped
+ environment:
+ COMPOSE_PROJECT_NAME: omegaclaw
+ OMEGACLAW_CONTAINER_NAME: omegaclaw
+ OMEGACLAW_IMAGE: ${OMEGACLAW_IMAGE:-singularitynet/omegaclaw:latest}
+ ports:
+ - "127.0.0.1:${OMEGACLAW_CONTROL_PORT:-3210}:3210"
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ labels:
+ org.opencontainers.image.title: OmegaClaw Control
+ org.opencontainers.image.description: Local configuration and lifecycle control for OmegaClaw
+
+ omegaclaw:
+ profiles: [agent]
+ image: ${OMEGACLAW_IMAGE:-singularitynet/omegaclaw:latest}
+ pull_policy: missing
+ container_name: omegaclaw
+ restart: "no"
+ init: true
+ security_opt:
+ - no-new-privileges:true
+ extra_hosts:
+ - host.docker.internal:host-gateway
+ tmpfs:
+ - /tmp:size=64m,mode=1777
+ - /var/tmp:size=64m,mode=1777
+ - /run:size=16m,mode=755
+ volumes:
+ - omegaclaw-memory:/PeTTa/repos/OmegaClaw-Core/memory
+ environment:
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
+ ASI_API_KEY: ${ASI_API_KEY:-}
+ ASIONE_API_KEY: ${ASIONE_API_KEY:-}
+ OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
+ OPENAIAPI_API_KEY: ${OPENAIAPI_API_KEY:-}
+ TG_BOT_TOKEN: ${TG_BOT_TOKEN:-}
+ SL_BOT_TOKEN: ${SL_BOT_TOKEN:-}
+ MM_BOT_TOKEN: ${MM_BOT_TOKEN:-}
+ MM_UPSTREAM_URL: ${OMEGACLAW_MM_URL:-https://chat.singularitynet.io}
+ OMEGACLAW_AUTH_SECRET: ${OMEGACLAW_AUTH_SECRET:-}
+ IMPORT_KB_ON_START: ${IMPORT_KB_ON_START:-0}
+ command:
+ - commchannel=${OMEGACLAW_CHANNEL:-irc}
+ - provider=${OMEGACLAW_PROVIDER:-Anthropic}
+ - embeddingprovider=${OMEGACLAW_EMBEDDING_PROVIDER:-Local}
+ - model=${OMEGACLAW_MODEL:-claude-opus-4-8}
+ - maxOutputToken=${OMEGACLAW_MAX_OUTPUT_TOKEN:-6000}
+ - reasoningMode=${OMEGACLAW_REASONING_MODE:-medium}
+ - openaiapi_url=${OMEGACLAW_OPENAIAPI_URL:-http://host.docker.internal:11434/v1}
+ - IRC_channel=${OMEGACLAW_IRC_CHANNEL:-##omegaclaw}
+ - IRC_server=${OMEGACLAW_IRC_SERVER:-irc.quakenet.org}
+ - IRC_port=${OMEGACLAW_IRC_PORT:-6667}
+ - IRC_user=${OMEGACLAW_IRC_USER:-omegaclaw}
+ - TG_CHAT_ID=${OMEGACLAW_TG_CHAT_ID:-}
+ - SL_CHANNEL_ID=${OMEGACLAW_SL_CHANNEL_ID:-}
+ - WS_URL=${OMEGACLAW_WS_URL:-}
+ - WS_TOKEN=${OMEGACLAW_WS_TOKEN:-}
+ - MM_URL=${OMEGACLAW_MM_URL:-https://chat.singularitynet.io}
+ - MM_CHANNEL_ID=${OMEGACLAW_MM_CHANNEL_ID:-}
+ - securityPolicyPath=/PeTTa/repos/OmegaClaw-Core/profile/policy.yaml
+ - memoryDirectory=$$MEMORY_DIR
+ labels:
+ io.omegaclaw.control.managed: "true"
+ io.omegaclaw.control.channel: ${OMEGACLAW_CHANNEL:-irc}
+ io.omegaclaw.control.provider: ${OMEGACLAW_PROVIDER:-Anthropic}
+ io.omegaclaw.control.model: ${OMEGACLAW_MODEL:-claude-opus-4-8}
+
+volumes:
+ omegaclaw-memory:
+ name: omegaclaw-memory
diff --git a/control-ui/Dockerfile b/control-ui/Dockerfile
new file mode 100644
index 00000000..25ea1927
--- /dev/null
+++ b/control-ui/Dockerfile
@@ -0,0 +1,31 @@
+# syntax=docker/dockerfile:1.7
+
+FROM node:22-alpine AS frontend
+
+WORKDIR /build
+COPY control-ui/package.json control-ui/package-lock.json ./
+RUN npm ci
+COPY control-ui/index.html control-ui/vite.config.js ./
+COPY control-ui/src ./src
+RUN npm run build
+
+FROM docker:28-cli AS runtime
+
+RUN apk add --no-cache nodejs
+
+WORKDIR /opt/omegaclaw-control
+COPY control-ui/server.mjs ./server.mjs
+COPY --from=frontend /build/dist ./dist
+COPY compose.yaml /opt/omegaclaw/compose.yaml
+
+ENV NODE_ENV=production \
+ PORT=3210 \
+ COMPOSE_FILE=/opt/omegaclaw/compose.yaml \
+ COMPOSE_PROJECT_NAME=omegaclaw
+
+EXPOSE 3210
+
+HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -qO- http://127.0.0.1:3210/api/health >/dev/null || exit 1
+
+ENTRYPOINT ["node", "server.mjs"]
diff --git a/control-ui/Dockerfile.dockerignore b/control-ui/Dockerfile.dockerignore
new file mode 100644
index 00000000..fb196f81
--- /dev/null
+++ b/control-ui/Dockerfile.dockerignore
@@ -0,0 +1,11 @@
+*
+!compose.yaml
+!control-ui/
+!control-ui/Dockerfile
+!control-ui/package.json
+!control-ui/package-lock.json
+!control-ui/index.html
+!control-ui/vite.config.js
+!control-ui/server.mjs
+!control-ui/src/
+!control-ui/src/**
diff --git a/control-ui/index.html b/control-ui/index.html
new file mode 100644
index 00000000..a254f9de
--- /dev/null
+++ b/control-ui/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Omega Control
+
+
+
+
+
+
diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json
new file mode 100644
index 00000000..e181109b
--- /dev/null
+++ b/control-ui/package-lock.json
@@ -0,0 +1,838 @@
+{
+ "name": "omegaclaw-control-ui",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "omegaclaw-control-ui",
+ "version": "0.1.0",
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "6.0.5",
+ "vite": "8.2.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.143.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
+ "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
+ "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
+ "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
+ "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
+ "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
+ "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
+ "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
+ "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
+ "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
+ "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
+ "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
+ "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
+ "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
+ "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.5",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
+ "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.26",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
+ "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.17",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
+ "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.8",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
+ "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.8"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
+ "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.143.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.2.3",
+ "@rolldown/binding-darwin-arm64": "1.2.3",
+ "@rolldown/binding-darwin-x64": "1.2.3",
+ "@rolldown/binding-freebsd-x64": "1.2.3",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
+ "@rolldown/binding-linux-arm64-gnu": "1.2.3",
+ "@rolldown/binding-linux-arm64-musl": "1.2.3",
+ "@rolldown/binding-linux-ppc64-gnu": "1.2.3",
+ "@rolldown/binding-linux-s390x-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-gnu": "1.2.3",
+ "@rolldown/binding-linux-x64-musl": "1.2.3",
+ "@rolldown/binding-openharmony-arm64": "1.2.3",
+ "@rolldown/binding-win32-arm64-msvc": "1.2.3",
+ "@rolldown/binding-win32-x64-msvc": "1.2.3"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.2.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz",
+ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.33.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.25",
+ "rolldown": "~1.2.1",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.4.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/control-ui/package.json b/control-ui/package.json
new file mode 100644
index 00000000..69d4d5e5
--- /dev/null
+++ b/control-ui/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "omegaclaw-control-ui",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "vite build",
+ "preview": "vite preview --host 0.0.0.0",
+ "check:server": "node --check server.mjs",
+ "test": "node --test server.test.mjs"
+ },
+ "dependencies": {
+ "react": "19.2.8",
+ "react-dom": "19.2.8"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "6.0.5",
+ "vite": "8.2.1"
+ }
+}
diff --git a/control-ui/server.mjs b/control-ui/server.mjs
new file mode 100644
index 00000000..4ee4c4d1
--- /dev/null
+++ b/control-ui/server.mjs
@@ -0,0 +1,472 @@
+import { randomInt } from "node:crypto";
+import { spawn } from "node:child_process";
+import { createReadStream, existsSync } from "node:fs";
+import { createServer } from "node:http";
+import { extname, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const PORT = Number(process.env.PORT || 3210);
+const DIST_DIR = fileURLToPath(new URL("./dist/", import.meta.url));
+const COMPOSE_FILE = process.env.COMPOSE_FILE || "/opt/omegaclaw/compose.yaml";
+const PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || "omegaclaw";
+const AGENT_CONTAINER = process.env.OMEGACLAW_CONTAINER_NAME || "omegaclaw";
+const MAX_BODY_BYTES = 32 * 1024;
+const MAX_COMMAND_OUTPUT = 64 * 1024;
+
+const CHANNELS = new Set(["irc", "telegram", "slack", "websocket", "mattermost"]);
+const PROVIDERS = new Set([
+ "Anthropic",
+ "OpenAI",
+ "ASICloud",
+ "ASIOne",
+ "OpenRouter",
+ "OpenAIAPI",
+]);
+const REASONING_MODES = new Set(["low", "medium", "high"]);
+const API_KEY_ENV = {
+ Anthropic: "ANTHROPIC_API_KEY",
+ OpenAI: "OPENAI_API_KEY",
+ ASICloud: "ASI_API_KEY",
+ ASIOne: "ASIONE_API_KEY",
+ OpenRouter: "OPENROUTER_API_KEY",
+ OpenAIAPI: "OPENAIAPI_API_KEY",
+};
+
+const MIME_TYPES = {
+ ".css": "text/css; charset=utf-8",
+ ".html": "text/html; charset=utf-8",
+ ".ico": "image/x-icon",
+ ".js": "text/javascript; charset=utf-8",
+ ".json": "application/json; charset=utf-8",
+ ".png": "image/png",
+ ".svg": "image/svg+xml",
+ ".webp": "image/webp",
+};
+
+let operation = null;
+
+export class ValidationError extends Error {}
+
+function text(value, field, { required = true, max = 512 } = {}) {
+ const result = String(value ?? "").trim();
+ if (required && !result) {
+ throw new ValidationError(`${field} is required.`);
+ }
+ if (result.length > max) {
+ throw new ValidationError(`${field} is too long.`);
+ }
+ if (/[\0\r\n]/.test(result)) {
+ throw new ValidationError(`${field} contains unsupported characters.`);
+ }
+ return result;
+}
+
+function enumValue(value, values, field) {
+ const result = text(value, field);
+ if (!values.has(result)) {
+ throw new ValidationError(`${field} is not supported.`);
+ }
+ return result;
+}
+
+function urlValue(value, field, protocols) {
+ const result = text(value, field, { max: 2048 });
+ let parsed;
+ try {
+ parsed = new URL(result);
+ } catch {
+ throw new ValidationError(`${field} must be a valid URL.`);
+ }
+ if (!protocols.includes(parsed.protocol)) {
+ throw new ValidationError(`${field} must use ${protocols.join(" or ")}.`);
+ }
+ return result.replace(/\/$/, "");
+}
+
+export function validateConfiguration(input) {
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
+ throw new ValidationError("A configuration object is required.");
+ }
+
+ const channel = enumValue(input.channel, CHANNELS, "Channel");
+ const provider = enumValue(input.provider, PROVIDERS, "LLM provider");
+ const model = text(input.model, "Model", { max: 256 });
+ const apiKey = text(input.apiKey, "API key", { max: 4096 });
+ const authSecret = text(input.authSecret, "Authentication code", { max: 128 });
+ if (authSecret.length < 4) {
+ throw new ValidationError("Authentication code must contain at least 4 characters.");
+ }
+
+ const reasoningMode = enumValue(
+ input.reasoningMode || "medium",
+ REASONING_MODES,
+ "Reasoning mode",
+ );
+ const maxOutputToken = Number(input.maxOutputToken || 6000);
+ if (!Number.isInteger(maxOutputToken) || maxOutputToken < 128 || maxOutputToken > 32000) {
+ throw new ValidationError("Max output tokens must be an integer from 128 to 32000.");
+ }
+
+ const config = {
+ channel,
+ provider,
+ model,
+ apiKey,
+ authSecret,
+ reasoningMode,
+ maxOutputToken,
+ importKnowledge: input.importKnowledge === true,
+ openaiApiUrl: "http://host.docker.internal:11434/v1",
+ ircChannel: "##omegaclaw",
+ ircServer: "irc.quakenet.org",
+ ircPort: "6667",
+ ircUser: "omegaclaw",
+ telegramBotToken: "",
+ telegramChatId: "",
+ slackBotToken: "",
+ slackChannelId: "",
+ websocketUrl: "",
+ websocketToken: "",
+ mattermostUrl: "https://chat.singularitynet.io",
+ mattermostChannelId: "",
+ mattermostBotToken: "",
+ };
+
+ if (provider === "OpenAIAPI") {
+ config.openaiApiUrl = urlValue(input.openaiApiUrl, "OpenAI API endpoint", ["http:", "https:"]);
+ }
+
+ if (channel === "irc") {
+ config.ircChannel = text(input.ircChannel, "IRC channel", { max: 128 });
+ config.ircServer = text(input.ircServer || config.ircServer, "IRC server", { max: 253 });
+ const port = Number(input.ircPort || 6667);
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
+ throw new ValidationError("IRC port must be from 1 to 65535.");
+ }
+ config.ircPort = String(port);
+ config.ircUser = text(input.ircUser || config.ircUser, "IRC nickname", { max: 64 });
+ }
+
+ if (channel === "telegram") {
+ config.telegramBotToken = text(input.telegramBotToken, "Telegram bot token", { max: 512 });
+ config.telegramChatId = text(input.telegramChatId, "Telegram chat ID", {
+ required: false,
+ max: 128,
+ });
+ }
+
+ if (channel === "slack") {
+ config.slackBotToken = text(input.slackBotToken, "Slack bot token", { max: 512 });
+ config.slackChannelId = text(input.slackChannelId, "Slack channel ID", {
+ required: false,
+ max: 128,
+ });
+ }
+
+ if (channel === "websocket") {
+ config.websocketUrl = urlValue(input.websocketUrl, "WebSocket URL", ["ws:", "wss:"]);
+ config.websocketToken = text(input.websocketToken, "WebSocket token", {
+ required: false,
+ max: 2048,
+ });
+ }
+
+ if (channel === "mattermost") {
+ config.mattermostUrl = urlValue(input.mattermostUrl, "Mattermost URL", ["http:", "https:"]);
+ config.mattermostChannelId = text(input.mattermostChannelId, "Mattermost channel ID", {
+ max: 128,
+ });
+ config.mattermostBotToken = text(input.mattermostBotToken, "Mattermost bot token", {
+ max: 512,
+ });
+ }
+
+ return config;
+}
+
+export function configurationEnvironment(config) {
+ const environment = {
+ ANTHROPIC_API_KEY: "",
+ OPENAI_API_KEY: "",
+ ASI_API_KEY: "",
+ ASIONE_API_KEY: "",
+ OPENROUTER_API_KEY: "",
+ OPENAIAPI_API_KEY: "",
+ TG_BOT_TOKEN: config.telegramBotToken,
+ SL_BOT_TOKEN: config.slackBotToken,
+ MM_BOT_TOKEN: config.mattermostBotToken,
+ OMEGACLAW_AUTH_SECRET: config.authSecret,
+ IMPORT_KB_ON_START: config.importKnowledge ? "1" : "0",
+ OMEGACLAW_CHANNEL: config.channel,
+ OMEGACLAW_PROVIDER: config.provider,
+ OMEGACLAW_EMBEDDING_PROVIDER: config.provider === "OpenAI" ? "OpenAI" : "Local",
+ OMEGACLAW_MODEL: config.model,
+ OMEGACLAW_REASONING_MODE: config.reasoningMode,
+ OMEGACLAW_MAX_OUTPUT_TOKEN: String(config.maxOutputToken),
+ OMEGACLAW_OPENAIAPI_URL: config.openaiApiUrl,
+ OMEGACLAW_IRC_CHANNEL: config.ircChannel,
+ OMEGACLAW_IRC_SERVER: config.ircServer,
+ OMEGACLAW_IRC_PORT: config.ircPort,
+ OMEGACLAW_IRC_USER: config.ircUser,
+ OMEGACLAW_TG_CHAT_ID: config.telegramChatId,
+ OMEGACLAW_SL_CHANNEL_ID: config.slackChannelId,
+ OMEGACLAW_WS_URL: config.websocketUrl,
+ OMEGACLAW_WS_TOKEN: config.websocketToken,
+ OMEGACLAW_MM_URL: config.mattermostUrl,
+ OMEGACLAW_MM_CHANNEL_ID: config.mattermostChannelId,
+ };
+ environment[API_KEY_ENV[config.provider]] = config.apiKey;
+ return environment;
+}
+
+function run(command, args, { environment = {}, timeout = 600_000 } = {}) {
+ return new Promise((resolvePromise, reject) => {
+ const child = spawn(command, args, {
+ env: { ...process.env, ...environment },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ let settled = false;
+
+ const append = (current, chunk) => (current + chunk.toString()).slice(-MAX_COMMAND_OUTPUT);
+ child.stdout.on("data", (chunk) => {
+ stdout = append(stdout, chunk);
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr = append(stderr, chunk);
+ });
+
+ const timer = setTimeout(() => {
+ if (!settled) {
+ child.kill("SIGKILL");
+ reject(new Error(`${command} timed out.`));
+ }
+ }, timeout);
+
+ child.on("error", (error) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ reject(error);
+ });
+
+ child.on("close", (code) => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ if (code === 0) {
+ resolvePromise({ stdout: stdout.trim(), stderr: stderr.trim() });
+ } else {
+ const detail = stderr.trim().split("\n").slice(-6).join("\n");
+ reject(new Error(detail || `${command} exited with status ${code}.`));
+ }
+ });
+ });
+}
+
+function composeArgs(...args) {
+ return ["compose", "--project-name", PROJECT_NAME, "--file", COMPOSE_FILE, ...args];
+}
+
+async function agentStatus() {
+ const { stdout: containerId } = await run(
+ "docker",
+ ["ps", "--all", "--quiet", "--filter", `name=^/${AGENT_CONTAINER}$`],
+ { timeout: 10_000 },
+ );
+ if (!containerId) {
+ return {
+ running: false,
+ status: "not_created",
+ startedAt: null,
+ finishedAt: null,
+ exitCode: null,
+ error: "",
+ configuration: { channel: null, provider: null, model: null },
+ operation,
+ };
+ }
+
+ const { stdout } = await run("docker", ["inspect", containerId], { timeout: 10_000 });
+ const [container] = JSON.parse(stdout);
+ const labels = container?.Config?.Labels || {};
+ const state = container?.State || {};
+ return {
+ running: state.Running === true,
+ status: state.Status || "unknown",
+ startedAt: state.StartedAt || null,
+ finishedAt: state.FinishedAt || null,
+ exitCode: state.ExitCode ?? null,
+ error: state.Error || "",
+ configuration: {
+ channel: labels["io.omegaclaw.control.channel"] || null,
+ provider: labels["io.omegaclaw.control.provider"] || null,
+ model: labels["io.omegaclaw.control.model"] || null,
+ },
+ operation,
+ };
+}
+
+function securityHeaders(response) {
+ response.setHeader("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
+ response.setHeader("Referrer-Policy", "no-referrer");
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.setHeader("X-Frame-Options", "DENY");
+}
+
+function json(response, statusCode, body) {
+ securityHeaders(response);
+ response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
+ response.end(JSON.stringify(body));
+}
+
+async function readJson(request) {
+ const contentType = String(request.headers["content-type"] || "");
+ if (!contentType.toLowerCase().startsWith("application/json")) {
+ throw new ValidationError("Content-Type must be application/json.");
+ }
+
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) {
+ throw new ValidationError("Request body is too large.");
+ }
+ chunks.push(chunk);
+ }
+ try {
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ } catch {
+ throw new ValidationError("Request body must contain valid JSON.");
+ }
+}
+
+function requireControlRequest(request) {
+ if (request.headers["x-omega-control"] !== "browser") {
+ throw new ValidationError("Missing control request header.");
+ }
+}
+
+async function startAgent(request, response) {
+ if (operation) {
+ return json(response, 409, { error: `A ${operation} operation is already in progress.` });
+ }
+
+ operation = "start";
+ try {
+ const config = validateConfiguration(await readJson(request));
+ await run(
+ "docker",
+ composeArgs("--profile", "agent", "up", "--detach", "--force-recreate", "--no-deps", "--pull", "missing", "omegaclaw"),
+ {
+ environment: configurationEnvironment(config),
+ timeout: 30 * 60_000,
+ },
+ );
+ operation = null;
+ const status = await agentStatus();
+ return json(response, 200, {
+ ...status,
+ authSecret: config.authSecret,
+ message: "OmegaClaw started with the new configuration.",
+ });
+ } finally {
+ operation = null;
+ }
+}
+
+async function stopAgent(response) {
+ if (operation) {
+ return json(response, 409, { error: `A ${operation} operation is already in progress.` });
+ }
+
+ operation = "stop";
+ try {
+ await run("docker", composeArgs("--profile", "agent", "stop", "--timeout", "20", "omegaclaw"), {
+ timeout: 60_000,
+ });
+ operation = null;
+ return json(response, 200, {
+ ...(await agentStatus()),
+ message: "OmegaClaw stopped safely.",
+ });
+ } finally {
+ operation = null;
+ }
+}
+
+function serveStatic(request, response) {
+ const requestUrl = new URL(request.url, "http://localhost");
+ const requestedPath = requestUrl.pathname === "/" ? "/index.html" : requestUrl.pathname;
+ let filePath = resolve(DIST_DIR, `.${decodeURIComponent(requestedPath)}`);
+ if (!filePath.startsWith(`${resolve(DIST_DIR)}${sep}`)) {
+ return json(response, 404, { error: "Not found." });
+ }
+ if (!existsSync(filePath)) {
+ filePath = resolve(DIST_DIR, "index.html");
+ }
+
+ securityHeaders(response);
+ response.writeHead(200, {
+ "Cache-Control": extname(filePath) === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
+ "Content-Type": MIME_TYPES[extname(filePath)] || "application/octet-stream",
+ });
+ createReadStream(filePath).pipe(response);
+}
+
+export function createControlServer() {
+ return createServer(async (request, response) => {
+ try {
+ const requestUrl = new URL(request.url, "http://localhost");
+
+ if (request.method === "GET" && requestUrl.pathname === "/api/health") {
+ return json(response, 200, { ok: true });
+ }
+ if (request.method === "GET" && requestUrl.pathname === "/api/status") {
+ return json(response, 200, await agentStatus());
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/api/start") {
+ requireControlRequest(request);
+ return await startAgent(request, response);
+ }
+ if (request.method === "POST" && requestUrl.pathname === "/api/stop") {
+ requireControlRequest(request);
+ return await stopAgent(response);
+ }
+ if (requestUrl.pathname.startsWith("/api/")) {
+ return json(response, 404, { error: "API endpoint not found." });
+ }
+ if (request.method !== "GET" && request.method !== "HEAD") {
+ return json(response, 405, { error: "Method not allowed." });
+ }
+ return serveStatic(request, response);
+ } catch (error) {
+ const statusCode = error instanceof ValidationError ? 400 : 500;
+ const message = statusCode === 400 ? error.message : "The container operation failed. Check Docker Desktop and try again.";
+ if (statusCode === 500) {
+ console.error(`[control-api] ${error.message}`);
+ }
+ if (!response.headersSent) {
+ return json(response, statusCode, { error: message });
+ }
+ response.end();
+ }
+ });
+}
+
+if (process.env.NODE_ENV !== "test") {
+ const server = createControlServer();
+ server.listen(PORT, "0.0.0.0", () => {
+ console.log(`Omega Control is ready on port ${PORT}.`);
+ });
+
+ for (const signal of ["SIGINT", "SIGTERM"]) {
+ process.on(signal, () => server.close(() => process.exit(0)));
+ }
+}
+
+export function randomAuthSecret() {
+ return String(randomInt(100000, 1000000));
+}
diff --git a/control-ui/server.test.mjs b/control-ui/server.test.mjs
new file mode 100644
index 00000000..f2696751
--- /dev/null
+++ b/control-ui/server.test.mjs
@@ -0,0 +1,49 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+process.env.NODE_ENV = "test";
+const { configurationEnvironment, validateConfiguration, ValidationError } = await import("./server.mjs");
+
+const valid = {
+ channel: "telegram",
+ provider: "OpenAI",
+ model: "gpt-5.5",
+ apiKey: "secret-key",
+ authSecret: "123456",
+ reasoningMode: "medium",
+ maxOutputToken: 6000,
+ telegramBotToken: "123:telegram-secret",
+ telegramChatId: "",
+};
+
+test("validates and maps a supported configuration", () => {
+ const config = validateConfiguration(valid);
+ const environment = configurationEnvironment(config);
+
+ assert.equal(config.channel, "telegram");
+ assert.equal(environment.OPENAI_API_KEY, "secret-key");
+ assert.equal(environment.ANTHROPIC_API_KEY, "");
+ assert.equal(environment.TG_BOT_TOKEN, "123:telegram-secret");
+ assert.equal(environment.OMEGACLAW_EMBEDDING_PROVIDER, "OpenAI");
+});
+
+test("rejects unsupported providers", () => {
+ assert.throws(
+ () => validateConfiguration({ ...valid, provider: "UnknownAI" }),
+ (error) => error instanceof ValidationError && /not supported/.test(error.message),
+ );
+});
+
+test("requires channel-specific credentials", () => {
+ assert.throws(
+ () => validateConfiguration({ ...valid, telegramBotToken: "" }),
+ (error) => error instanceof ValidationError && /Telegram bot token is required/.test(error.message),
+ );
+});
+
+test("rejects control characters in Compose values", () => {
+ assert.throws(
+ () => validateConfiguration({ ...valid, model: "gpt-safe\nINJECTED=value" }),
+ (error) => error instanceof ValidationError && /unsupported characters/.test(error.message),
+ );
+});
diff --git a/control-ui/src/App.jsx b/control-ui/src/App.jsx
new file mode 100644
index 00000000..cf6308ed
--- /dev/null
+++ b/control-ui/src/App.jsx
@@ -0,0 +1,494 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+const CHANNELS = [
+ { id: "irc", name: "IRC", note: "Simple, open chat" },
+ { id: "telegram", name: "Telegram", note: "Talk to your bot" },
+ { id: "slack", name: "Slack", note: "Connect a workspace" },
+ { id: "websocket", name: "WebSocket", note: "Bring your own client" },
+ { id: "mattermost", name: "Mattermost", note: "Self-hosted teams" },
+];
+
+const PROVIDERS = [
+ { id: "Anthropic", name: "Anthropic", model: "claude-opus-4-8", mark: "A" },
+ { id: "OpenAI", name: "OpenAI", model: "gpt-5.5", mark: "O" },
+ { id: "ASICloud", name: "ASI Cloud", model: "minimax/minimax-m3", mark: "AC" },
+ { id: "ASIOne", name: "ASI:One", model: "asi1-ultra", mark: "A1" },
+ { id: "OpenRouter", name: "OpenRouter", model: "z-ai/glm-5.2", mark: "OR" },
+ { id: "OpenAIAPI", name: "Custom API", model: "qwen3.5:9b", mark: "<>" },
+];
+
+function generateAuthCode() {
+ const values = new Uint32Array(1);
+ window.crypto.getRandomValues(values);
+ return String(100000 + (values[0] % 900000));
+}
+
+const initialForm = {
+ channel: "irc",
+ provider: "Anthropic",
+ model: "claude-opus-4-8",
+ apiKey: "",
+ authSecret: generateAuthCode(),
+ reasoningMode: "medium",
+ maxOutputToken: 6000,
+ importKnowledge: false,
+ openaiApiUrl: "http://host.docker.internal:11434/v1",
+ ircChannel: "##omegaclaw",
+ ircServer: "irc.quakenet.org",
+ ircPort: 6667,
+ ircUser: "omegaclaw",
+ telegramBotToken: "",
+ telegramChatId: "",
+ slackBotToken: "",
+ slackChannelId: "",
+ websocketUrl: "",
+ websocketToken: "",
+ mattermostUrl: "https://chat.singularitynet.io",
+ mattermostChannelId: "",
+ mattermostBotToken: "",
+};
+
+async function api(path, options = {}) {
+ const response = await fetch(path, {
+ ...options,
+ headers: {
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
+ ...(options.method === "POST" ? { "X-Omega-Control": "browser" } : {}),
+ ...options.headers,
+ },
+ });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) {
+ throw new Error(body.error || "The request failed.");
+ }
+ return body;
+}
+
+function Icon({ name }) {
+ const paths = {
+ channel: <>>,
+ brain: <>>,
+ play: ,
+ stop: ,
+ key: <>>,
+ refresh: <>>,
+ eye: <>>,
+ eyeOff: <>>,
+ check: ,
+ spark: <>>,
+ };
+ return ;
+}
+
+function Field({ label, hint, children, wide = false }) {
+ return (
+
+ );
+}
+
+function SecretInput({ id, value, onChange, placeholder, required = false, autoComplete = "new-password" }) {
+ const [visible, setVisible] = useState(false);
+ return (
+
+
+
+
+ );
+}
+
+function ChannelFields({ form, setField }) {
+ if (form.channel === "irc") {
+ return (
+
+
+ setField("ircChannel", event.target.value)} required />
+
+
+ setField("ircUser", event.target.value)} required />
+
+
+ setField("ircServer", event.target.value)} required />
+
+
+ setField("ircPort", event.target.value)} required />
+
+
+ );
+ }
+
+ if (form.channel === "telegram") {
+ return (
+
+
+ setField("telegramBotToken", event.target.value)} placeholder="123456:AA..." required />
+
+
+ setField("telegramChatId", event.target.value)} placeholder="Auto-bind" />
+
+
+ );
+ }
+
+ if (form.channel === "slack") {
+ return (
+
+
+ setField("slackBotToken", event.target.value)} placeholder="xoxb-..." required />
+
+
+ setField("slackChannelId", event.target.value)} placeholder="C0123456789" />
+
+
+ );
+ }
+
+ if (form.channel === "websocket") {
+ return (
+
+
+ setField("websocketUrl", event.target.value)} placeholder="wss://chat.example.com/agent" required />
+
+
+ setField("websocketToken", event.target.value)} placeholder="Optional" />
+
+
+ );
+ }
+
+ return (
+
+
+ setField("mattermostUrl", event.target.value)} required />
+
+
+ setField("mattermostChannelId", event.target.value)} required />
+
+
+ setField("mattermostBotToken", event.target.value)} required />
+
+
+ );
+}
+
+function StatusPill({ status, loading }) {
+ const running = status?.running;
+ return (
+
+
+ {loading ? "Checking" : running ? "OmegaClaw online" : "OmegaClaw stopped"}
+
+ );
+}
+
+function InteractionGuide({ started }) {
+ if (!started) return null;
+ const channel = CHANNELS.find((item) => item.id === started.channel)?.name || started.channel;
+ return (
+
+
+
+
Connection ready
+
Meet your claw in {channel}
+
+ Send auth {started.authSecret} as the first message. The first user who authenticates becomes the owner of this OmegaClaw memory.
+
+ {started.channel === "irc" && (
+
Open QuakeNet web chat ↗
+ )}
+
+
+ One-time auth code
+ {started.authSecret}
+
+
+ );
+}
+
+export default function App() {
+ const [form, setForm] = useState(initialForm);
+ const [status, setStatus] = useState(null);
+ const [statusLoading, setStatusLoading] = useState(true);
+ const [busy, setBusy] = useState("");
+ const [notice, setNotice] = useState(null);
+ const [accepted, setAccepted] = useState(false);
+ const [started, setStarted] = useState(null);
+ const [showApiKey, setShowApiKey] = useState(false);
+
+ const selectedProvider = useMemo(
+ () => PROVIDERS.find((provider) => provider.id === form.provider),
+ [form.provider],
+ );
+
+ const setField = useCallback((field, value) => {
+ setForm((current) => ({ ...current, [field]: value }));
+ }, []);
+
+ const refreshStatus = useCallback(async (quiet = false) => {
+ if (!quiet) setStatusLoading(true);
+ try {
+ const nextStatus = await api("/api/status");
+ setStatus(nextStatus);
+ } catch (error) {
+ if (!quiet) setNotice({ type: "error", text: error.message });
+ } finally {
+ if (!quiet) setStatusLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ refreshStatus();
+ const timer = window.setInterval(() => refreshStatus(true), 4000);
+ return () => window.clearInterval(timer);
+ }, [refreshStatus]);
+
+ function chooseProvider(provider) {
+ setForm((current) => ({ ...current, provider: provider.id, model: provider.model }));
+ }
+
+ async function handleStart(event) {
+ event.preventDefault();
+ setBusy("start");
+ setNotice(null);
+ try {
+ const result = await api("/api/start", {
+ method: "POST",
+ body: JSON.stringify(form),
+ });
+ setStatus(result);
+ setStarted({ channel: form.channel, authSecret: result.authSecret });
+ setNotice({ type: "success", text: result.message });
+ } catch (error) {
+ setNotice({ type: "error", text: error.message });
+ } finally {
+ setBusy("");
+ }
+ }
+
+ async function handleStop() {
+ setBusy("stop");
+ setNotice(null);
+ try {
+ const result = await api("/api/stop", { method: "POST" });
+ setStatus(result);
+ setStarted(null);
+ setNotice({ type: "success", text: result.message });
+ } catch (error) {
+ setNotice({ type: "error", text: error.message });
+ } finally {
+ setBusy("");
+ }
+ }
+
+ return (
+
+
+
+
+
+
Local control plane / OmegaClaw
+
Give your claw
a voice and a mind.
+
+ Choose where it listens, choose how it thinks, then bring the agent online. Your credentials stay in the local Docker runtime.
+
+
+
+
+
+
Ω
+
CHANNEL
+
MODEL
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/control-ui/src/main.jsx b/control-ui/src/main.jsx
new file mode 100644
index 00000000..9798c7ca
--- /dev/null
+++ b/control-ui/src/main.jsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App.jsx";
+import "./styles.css";
+
+createRoot(document.getElementById("root")).render(
+
+
+ ,
+);
diff --git a/control-ui/src/styles.css b/control-ui/src/styles.css
new file mode 100644
index 00000000..e56fbe08
--- /dev/null
+++ b/control-ui/src/styles.css
@@ -0,0 +1,246 @@
+:root {
+ color: #eff7f2;
+ background: #08110f;
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --ink: #eff7f2;
+ --muted: #96a69f;
+ --faint: #62716b;
+ --panel: #101b18;
+ --panel-light: #14231f;
+ --line: rgba(219, 244, 231, 0.12);
+ --green: #a9f5c7;
+ --green-strong: #56d989;
+ --dark-green: #173b2b;
+ --amber: #f0c674;
+ --red: #ff8e82;
+}
+
+* { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body { margin: 0; min-width: 320px; min-height: 100vh; background: #08110f; }
+button, input, select { font: inherit; }
+button, summary, label { -webkit-tap-highlight-color: transparent; }
+button { color: inherit; }
+a { color: var(--green); }
+.icon { width: 1.25rem; height: 1.25rem; flex: none; }
+
+body::before {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ opacity: 0.28;
+ background-image: radial-gradient(rgba(230, 255, 239, 0.22) 0.55px, transparent 0.55px);
+ background-size: 5px 5px;
+ mask-image: linear-gradient(to bottom, black, transparent 72%);
+}
+
+main { width: min(1440px, 100%); margin: 0 auto; padding: 0 4vw 3rem; }
+
+.topbar {
+ min-height: 86px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand { display: inline-flex; align-items: center; gap: 0.7rem; color: var(--ink); text-decoration: none; font-size: 1rem; letter-spacing: -0.02em; }
+.brand b { font-weight: 500; color: var(--muted); }
+.brand-mark {
+ width: 2.35rem;
+ height: 2.35rem;
+ border-radius: 50%;
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid rgba(169, 245, 199, 0.42);
+ color: var(--green);
+ font-family: Georgia, serif;
+ font-size: 1.3rem;
+ box-shadow: inset 0 0 20px rgba(86, 217, 137, 0.08), 0 0 28px rgba(86, 217, 137, 0.08);
+}
+.brand-mark.small { width: 1.9rem; height: 1.9rem; font-size: 1rem; }
+.topbar-actions { display: flex; align-items: center; gap: 0.65rem; }
+.refresh-button { display: grid; place-items: center; width: 2.7rem; height: 2.7rem; border: 1px solid var(--line); border-radius: 50%; background: transparent; cursor: pointer; }
+.refresh-button:hover { color: var(--green); border-color: rgba(169, 245, 199, 0.35); }
+.status-pill { display: flex; align-items: center; gap: 0.6rem; height: 2.7rem; padding: 0 1rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 0.78rem; font-weight: 650; letter-spacing: 0.02em; background: rgba(16, 27, 24, 0.75); }
+.status-dot { width: 0.48rem; height: 0.48rem; border-radius: 50%; background: #66716d; box-shadow: 0 0 0 4px rgba(102, 113, 109, 0.1); }
+.is-running .status-dot { background: var(--green-strong); box-shadow: 0 0 0 4px rgba(86, 217, 137, 0.1), 0 0 12px rgba(86, 217, 137, 0.55); }
+.is-running { color: var(--green); }
+
+.hero { min-height: 440px; display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr); align-items: center; gap: 3rem; padding: 4.5rem 2vw 4rem; overflow: hidden; }
+.eyebrow { margin: 0 0 0.8rem; color: var(--green); font-size: 0.68rem; line-height: 1.4; font-weight: 750; text-transform: uppercase; letter-spacing: 0.19em; }
+.eyebrow span { color: var(--amber); }
+.hero h1 { max-width: 760px; margin: 0; color: var(--ink); font-family: Georgia, "Times New Roman", serif; font-weight: 400; font-size: clamp(3rem, 6vw, 6.2rem); line-height: 0.96; letter-spacing: -0.065em; }
+.hero h1 em { color: var(--green); font-weight: 400; }
+.hero-lede { max-width: 590px; margin: 1.8rem 0 0; color: var(--muted); font-size: clamp(1rem, 1.35vw, 1.18rem); line-height: 1.7; }
+
+.hero-orbit { position: relative; justify-self: center; width: min(36vw, 440px); aspect-ratio: 1; display: grid; place-items: center; }
+.hero-orbit::before { content: ""; position: absolute; width: 35%; height: 35%; border-radius: 50%; background: rgba(86, 217, 137, 0.11); filter: blur(35px); }
+.orbit { position: absolute; border: 1px solid rgba(169, 245, 199, 0.19); border-radius: 50%; }
+.orbit::before, .orbit::after { content: ""; position: absolute; width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 14px var(--green); }
+.orbit-one { inset: 9%; animation: orbit-spin 22s linear infinite; }
+.orbit-one::before { top: 7%; left: 24%; }
+.orbit-one::after { right: -4px; top: 50%; background: var(--amber); box-shadow: 0 0 14px var(--amber); }
+.orbit-two { inset: 24%; border-style: dashed; animation: orbit-spin 17s linear infinite reverse; }
+.orbit-two::before { bottom: 8%; left: 22%; }
+.orbit-two::after { display: none; }
+.core-mark { width: 31%; aspect-ratio: 1; display: grid; place-items: center; border-radius: 50%; border: 1px solid rgba(169, 245, 199, 0.34); font-family: Georgia, serif; font-size: clamp(3rem, 7vw, 6rem); color: var(--green); background: radial-gradient(circle at 50% 40%, #17352a, #0d1815 68%); box-shadow: 0 0 70px rgba(86, 217, 137, 0.13), inset 0 0 45px rgba(86, 217, 137, 0.08); }
+.orbit-label { position: absolute; padding: 0.4rem 0.65rem; color: var(--faint); border: 1px solid var(--line); border-radius: 4px; background: #0b1512; font-size: 0.55rem; font-weight: 700; letter-spacing: 0.15em; }
+.label-channel { left: 2%; bottom: 22%; }
+.label-model { right: 0; top: 25%; }
+@keyframes orbit-spin { to { transform: rotate(360deg); } }
+
+.configuration-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 22px; overflow: hidden; box-shadow: 0 40px 100px rgba(0, 0, 0, 0.24); }
+.config-card { min-width: 0; padding: clamp(1.5rem, 3vw, 3rem); background: rgba(15, 27, 23, 0.96); }
+.llm-card { background: rgba(18, 31, 27, 0.96); }
+.card-heading { position: relative; display: grid; grid-template-columns: 3.6rem 1fr; align-items: start; gap: 1.15rem; margin-bottom: 2rem; }
+.heading-icon { width: 3.6rem; height: 3.6rem; display: grid; place-items: center; border-radius: 14px; color: var(--green); background: linear-gradient(145deg, rgba(169, 245, 199, 0.12), rgba(169, 245, 199, 0.025)); border: 1px solid rgba(169, 245, 199, 0.16); }
+.heading-icon .icon { width: 1.55rem; height: 1.55rem; }
+.card-heading .eyebrow { margin-bottom: 0.25rem; }
+.card-heading h2 { margin: 0; font-family: Georgia, serif; font-size: 2.2rem; font-weight: 400; letter-spacing: -0.04em; }
+.card-heading p:last-child { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.86rem; }
+.step-number { position: absolute; right: 0; top: 0; color: rgba(239, 247, 242, 0.12); font-family: Georgia, serif; font-size: 2.1rem; }
+
+.channel-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.65rem; }
+.channel-option, .provider-option { position: relative; border: 1px solid var(--line); background: rgba(6, 14, 12, 0.4); cursor: pointer; text-align: left; transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; }
+.channel-option:hover, .provider-option:hover { border-color: rgba(169, 245, 199, 0.3); transform: translateY(-1px); }
+.channel-option { min-height: 68px; display: flex; flex-direction: column; justify-content: center; padding: 0.9rem 2.3rem 0.9rem 1rem; border-radius: 10px; }
+.channel-option span { font-size: 0.87rem; font-weight: 680; }
+.channel-option small { margin-top: 0.22rem; color: var(--faint); font-size: 0.67rem; }
+.channel-option i, .provider-option i { position: absolute; display: none; place-items: center; border-radius: 50%; color: #0a1612; background: var(--green); }
+.channel-option i { right: 0.75rem; top: 50%; width: 1.15rem; height: 1.15rem; transform: translateY(-50%); }
+.channel-option i .icon, .provider-option i .icon { width: 0.75rem; height: 0.75rem; stroke-width: 3; }
+.channel-option.selected, .provider-option.selected { border-color: rgba(169, 245, 199, 0.65); background: linear-gradient(135deg, rgba(86, 217, 137, 0.12), rgba(86, 217, 137, 0.035)); }
+.channel-option.selected i, .provider-option.selected i { display: grid; }
+.channel-option:last-child:nth-child(odd) { grid-column: span 2; }
+
+.section-divider { display: flex; align-items: center; gap: 0.7rem; margin: 2rem 0 1.15rem; color: var(--faint); font-size: 0.61rem; text-transform: uppercase; font-weight: 700; letter-spacing: 0.14em; }
+.section-divider::before, .section-divider::after { content: ""; height: 1px; background: var(--line); }
+.section-divider::before { width: 1rem; }
+.section-divider::after { flex: 1; }
+
+.field-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; }
+.field { min-width: 0; display: flex; flex-direction: column; gap: 0.45rem; }
+.field-wide { grid-column: 1 / -1; }
+.field-label { color: #cbd8d1; font-size: 0.71rem; font-weight: 680; letter-spacing: 0.025em; }
+.field-hint { color: var(--faint); font-size: 0.65rem; line-height: 1.45; }
+input, select { width: 100%; min-width: 0; height: 2.9rem; border: 1px solid var(--line); border-radius: 8px; outline: none; padding: 0 0.85rem; color: var(--ink); background: rgba(4, 11, 9, 0.5); transition: border-color 150ms ease, box-shadow 150ms ease; }
+input::placeholder { color: #50605a; }
+input:focus, select:focus { border-color: rgba(169, 245, 199, 0.6); box-shadow: 0 0 0 3px rgba(86, 217, 137, 0.08); }
+select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); background-position: calc(100% - 16px) 20px, calc(100% - 11px) 20px; background-size: 5px 5px; background-repeat: no-repeat; }
+.secret-input { position: relative; display: block; }
+.secret-input input { padding-right: 3rem; }
+.icon-button { position: absolute; right: 0.35rem; top: 50%; width: 2.2rem; height: 2.2rem; transform: translateY(-50%); display: grid; place-items: center; border: 0; border-radius: 6px; color: var(--faint); background: transparent; cursor: pointer; }
+.icon-button:hover { color: var(--green); background: rgba(169, 245, 199, 0.06); }
+.inline-control { display: flex; gap: 0.45rem; }
+.inline-control button { border: 1px solid var(--line); border-radius: 8px; padding: 0 0.8rem; color: var(--green); background: rgba(169, 245, 199, 0.05); font-size: 0.68rem; font-weight: 700; cursor: pointer; }
+.auth-row { display: grid; grid-template-columns: 2.5rem 1fr; gap: 0.8rem; align-items: start; margin-top: 1.2rem; padding: 1rem; border: 1px solid rgba(240, 198, 116, 0.16); border-radius: 11px; background: rgba(240, 198, 116, 0.035); }
+.auth-row-icon { width: 2.5rem; height: 2.5rem; display: grid; place-items: center; color: var(--amber); border-radius: 8px; background: rgba(240, 198, 116, 0.08); }
+
+.provider-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.55rem; }
+.provider-option { min-height: 78px; display: flex; flex-direction: column; justify-content: center; gap: 0.5rem; padding: 0.75rem; border-radius: 10px; }
+.provider-option > span:last-of-type { color: #cbd8d1; font-size: 0.69rem; font-weight: 680; }
+.provider-mark { width: 1.7rem; height: 1.7rem; display: grid; place-items: center; border: 1px solid rgba(239, 247, 242, 0.15); border-radius: 6px; color: var(--green); font-family: Georgia, serif; font-size: 0.66rem; }
+.provider-option i { right: 0.5rem; top: 0.5rem; width: 1rem; height: 1rem; }
+.llm-fields { display: grid; gap: 1rem; margin-top: 1.5rem; }
+.advanced-settings { margin-top: 1.5rem; border-top: 1px solid var(--line); }
+.advanced-settings summary { display: flex; justify-content: space-between; padding: 1rem 0 0; color: #cbd8d1; font-size: 0.72rem; font-weight: 700; cursor: pointer; list-style: none; }
+.advanced-settings summary::-webkit-details-marker { display: none; }
+.advanced-settings summary span { color: var(--faint); font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.13em; }
+.advanced-grid { margin-top: 1rem; }
+.toggle-row { display: grid; grid-template-columns: auto 1fr; column-gap: 0.7rem; align-items: center; cursor: pointer; }
+.toggle-row input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.toggle-track { grid-row: span 2; width: 2.5rem; height: 1.4rem; padding: 0.16rem; border-radius: 999px; background: #2a3833; transition: background 150ms ease; }
+.toggle-track i { display: block; width: 1.08rem; height: 1.08rem; border-radius: 50%; background: #84928c; transition: transform 150ms ease, background 150ms ease; }
+.toggle-row input:checked + .toggle-track { background: rgba(86, 217, 137, 0.3); }
+.toggle-row input:checked + .toggle-track i { transform: translateX(1.08rem); background: var(--green); }
+.toggle-row b { display: block; color: #cbd8d1; font-size: 0.72rem; }
+.toggle-row small { display: block; margin-top: 0.2rem; color: var(--faint); font-size: 0.63rem; }
+
+.control-deck { position: relative; display: grid; grid-template-columns: minmax(230px, 0.8fr) minmax(240px, 0.75fr) minmax(440px, 1.45fr); align-items: center; gap: 2rem; margin: 1rem 0 0; padding: 2rem; border: 1px solid var(--line); border-radius: 18px; background: linear-gradient(115deg, rgba(16, 29, 25, 0.98), rgba(10, 20, 17, 0.98)); overflow: hidden; }
+.control-deck::before { content: ""; position: absolute; width: 330px; height: 330px; right: 10%; border-radius: 50%; background: rgba(86, 217, 137, 0.05); filter: blur(45px); pointer-events: none; }
+.control-copy { position: relative; }
+.control-copy .eyebrow { margin-bottom: 0.35rem; }
+.control-copy h2 { margin: 0; font-family: Georgia, serif; font-weight: 400; font-size: 1.6rem; letter-spacing: -0.035em; }
+.control-copy > p:last-child { margin: 0.45rem 0 0; color: var(--faint); font-size: 0.69rem; line-height: 1.5; overflow-wrap: anywhere; }
+.acceptance { position: relative; display: grid; grid-template-columns: 1.35rem 1fr; gap: 0.65rem; align-items: start; cursor: pointer; }
+.acceptance input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+.acceptance > span { width: 1.35rem; height: 1.35rem; display: grid; place-items: center; border: 1px solid rgba(239, 247, 242, 0.2); border-radius: 5px; color: transparent; background: rgba(0, 0, 0, 0.18); }
+.acceptance > span .icon { width: 0.8rem; height: 0.8rem; stroke-width: 3; }
+.acceptance input:checked + span { color: #07110d; border-color: var(--green); background: var(--green); }
+.acceptance input:focus-visible + span { outline: 3px solid rgba(169, 245, 199, 0.25); }
+.acceptance small { color: var(--muted); font-size: 0.67rem; line-height: 1.5; }
+.large-actions { position: relative; display: grid; grid-template-columns: minmax(250px, 1fr) minmax(150px, 0.55fr); gap: 0.7rem; }
+.large-actions button { min-height: 72px; display: flex; align-items: center; gap: 0.9rem; border-radius: 12px; padding: 0.85rem 1.15rem; cursor: pointer; transition: transform 150ms ease, filter 150ms ease, opacity 150ms ease; }
+.large-actions button:not(:disabled):hover { transform: translateY(-2px); filter: brightness(1.05); }
+.large-actions button:disabled { cursor: not-allowed; opacity: 0.36; }
+.button-icon { width: 2.65rem; height: 2.65rem; display: grid; place-items: center; border-radius: 9px; }
+.button-icon .icon { width: 1.4rem; height: 1.4rem; }
+.large-actions button > span:last-child { display: flex; flex-direction: column; gap: 0.2rem; text-align: left; }
+.large-actions b { font-size: 0.86rem; }
+.large-actions small { font-size: 0.61rem; opacity: 0.67; }
+.start-button { border: 1px solid #c1f7d4; color: #07130d; background: var(--green); box-shadow: 0 12px 32px rgba(86, 217, 137, 0.13); }
+.start-button .button-icon { color: var(--green); background: #10271c; }
+.stop-button { border: 1px solid rgba(255, 142, 130, 0.22); color: #efb6af; background: rgba(255, 142, 130, 0.055); }
+.stop-button .button-icon { color: var(--red); background: rgba(255, 142, 130, 0.09); }
+.notice { grid-column: 1 / -1; position: relative; margin-top: -0.5rem; padding: 0.75rem 1rem; border-radius: 8px; font-size: 0.72rem; }
+.notice.success { color: var(--green); background: rgba(86, 217, 137, 0.08); border: 1px solid rgba(86, 217, 137, 0.14); }
+.notice.error { color: #ffc0b8; background: rgba(255, 105, 90, 0.08); border: 1px solid rgba(255, 105, 90, 0.14); }
+
+.ready-card { display: grid; grid-template-columns: auto 1fr auto; gap: 1.2rem; align-items: center; margin: 1rem 0 0; padding: 1.5rem 2rem; border: 1px solid rgba(169, 245, 199, 0.22); border-radius: 16px; background: linear-gradient(110deg, rgba(23, 59, 43, 0.55), rgba(11, 24, 19, 0.9)); }
+.ready-icon { width: 3rem; height: 3rem; display: grid; place-items: center; border-radius: 50%; color: #08120d; background: var(--green); }
+.ready-card .eyebrow { margin-bottom: 0.25rem; }
+.ready-card h2 { margin: 0; font-family: Georgia, serif; font-size: 1.4rem; font-weight: 400; }
+.ready-card p:not(.eyebrow) { max-width: 730px; margin: 0.35rem 0; color: var(--muted); font-size: 0.76rem; line-height: 1.6; }
+.ready-card code { padding: 0.12rem 0.3rem; border-radius: 4px; color: var(--green); background: rgba(0, 0, 0, 0.28); }
+.ready-card a { font-size: 0.7rem; font-weight: 700; text-decoration: none; }
+.auth-ticket { min-width: 180px; padding: 0.9rem 1.2rem; border: 1px dashed rgba(169, 245, 199, 0.35); border-radius: 10px; text-align: center; background: rgba(2, 8, 6, 0.28); }
+.auth-ticket span { display: block; color: var(--faint); font-size: 0.55rem; text-transform: uppercase; letter-spacing: 0.14em; }
+.auth-ticket strong { display: block; margin-top: 0.3rem; color: var(--green); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 1.3rem; letter-spacing: 0.18em; }
+
+footer { min-height: 120px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 1rem; padding: 2rem 0; color: var(--faint); font-size: 0.63rem; }
+footer p { margin: 0; }
+
+@media (max-width: 1120px) {
+ .configuration-grid { grid-template-columns: 1fr; }
+ .control-deck { grid-template-columns: 1fr 1fr; }
+ .large-actions { grid-column: 1 / -1; }
+}
+
+@media (max-width: 760px) {
+ main { padding-inline: 1rem; }
+ .topbar { min-height: 72px; }
+ .status-pill { padding-inline: 0.7rem; }
+ .refresh-button { display: none; }
+ .hero { min-height: auto; grid-template-columns: 1fr; padding: 3.5rem 0 3rem; }
+ .hero-orbit { display: none; }
+ .hero h1 { font-size: clamp(3.2rem, 15vw, 5rem); }
+ .config-card { padding: 1.3rem; }
+ .provider-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .field-grid { grid-template-columns: 1fr; }
+ .field-wide { grid-column: auto; }
+ .control-deck { grid-template-columns: 1fr; padding: 1.3rem; }
+ .large-actions { grid-column: auto; grid-template-columns: 1fr; }
+ .ready-card { grid-template-columns: auto 1fr; padding: 1.3rem; }
+ .auth-ticket { grid-column: 1 / -1; }
+ footer { grid-template-columns: auto 1fr; }
+ footer > span:last-child { display: none; }
+}
+
+@media (max-width: 430px) {
+ .brand > span:last-child { display: none; }
+ .channel-options { grid-template-columns: 1fr; }
+ .channel-option:last-child:nth-child(odd) { grid-column: auto; }
+ .provider-grid { grid-template-columns: 1fr 1fr; }
+ .card-heading { grid-template-columns: 3rem 1fr; }
+ .heading-icon { width: 3rem; height: 3rem; }
+ .inline-control { flex-direction: column; }
+ .inline-control button { min-height: 2.5rem; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; }
+}
diff --git a/control-ui/vite.config.js b/control-ui/vite.config.js
new file mode 100644
index 00000000..dd6b2512
--- /dev/null
+++ b/control-ui/vite.config.js
@@ -0,0 +1,12 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ port: 3210,
+ proxy: {
+ "/api": "http://127.0.0.1:3211",
+ },
+ },
+});
diff --git a/scripts/start-control-ui b/scripts/start-control-ui
new file mode 100755
index 00000000..2b9fc065
--- /dev/null
+++ b/scripts/start-control-ui
@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
+project_dir="$(cd -- "${script_dir}/.." && pwd)"
+control_port="${OMEGACLAW_CONTROL_PORT:-3210}"
+control_url="http://localhost:${control_port}"
+
+cd -- "$project_dir"
+docker compose up --detach --build control
+
+echo "Waiting for Omega Control at ${control_url} ..."
+for _ in $(seq 1 60); do
+ health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>/dev/null || true)"
+ if [ "$health" = "healthy" ]; then
+ break
+ fi
+ sleep 1
+done
+
+health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>/dev/null || true)"
+if [ "$health" != "healthy" ]; then
+ echo "Omega Control did not become healthy. Run: docker compose logs control" >&2
+ exit 1
+fi
+
+echo "Opening ${control_url}"
+if command -v open >/dev/null 2>&1; then
+ open "$control_url"
+elif command -v xdg-open >/dev/null 2>&1; then
+ xdg-open "$control_url" >/dev/null 2>&1 &
+elif command -v cmd.exe >/dev/null 2>&1; then
+ cmd.exe /c start "" "$control_url" >/dev/null 2>&1
+else
+ echo "Open ${control_url} in your browser."
+fi
diff --git a/scripts/start-control-ui.ps1 b/scripts/start-control-ui.ps1
new file mode 100644
index 00000000..2ec2c8b2
--- /dev/null
+++ b/scripts/start-control-ui.ps1
@@ -0,0 +1,30 @@
+$ErrorActionPreference = "Stop"
+
+$ProjectDirectory = Split-Path -Parent $PSScriptRoot
+$ControlPort = if ($env:OMEGACLAW_CONTROL_PORT) { $env:OMEGACLAW_CONTROL_PORT } else { "3210" }
+$ControlUrl = "http://localhost:$ControlPort"
+
+Push-Location $ProjectDirectory
+try {
+ docker compose up --detach --build control
+ if ($LASTEXITCODE -ne 0) { throw "Docker Compose could not start Omega Control." }
+
+ Write-Host "Waiting for Omega Control at $ControlUrl ..."
+ $Healthy = $false
+ for ($Attempt = 0; $Attempt -lt 60; $Attempt++) {
+ $Health = docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' omegaclaw-control 2>$null
+ if ($Health -eq "healthy") {
+ $Healthy = $true
+ break
+ }
+ Start-Sleep -Seconds 1
+ }
+
+ if (-not $Healthy) {
+ throw "Omega Control did not become healthy. Run: docker compose logs control"
+ }
+
+ Start-Process $ControlUrl
+} finally {
+ Pop-Location
+}