Gateway: add browser control UI

This commit is contained in:
Peter Steinberger
2025-12-18 22:40:46 +00:00
parent c34da133f6
commit df0c51a63b
21 changed files with 1799 additions and 16 deletions

View File

@@ -22,13 +22,14 @@ WhatsApp / Telegram
┌──────────────────────────┐
│ Gateway │ ws://127.0.0.1:18789 (loopback-only)
│ Gateway │ ws://127.0.0.1:18789 (default: loopback)
│ (control UI) │ http://127.0.0.1:18789/ui/
│ (single source) │ tcp://0.0.0.0:18790 (optional Bridge)
└───────────┬───────────────┘
├─ Pi agent (RPC)
├─ CLI (clawdis …)
├─ WebChat (loopback UI)
├─ Control UI (browser)
├─ macOS app (Clawdis.app)
└─ iOS node via Bridge + pairing
```
@@ -60,7 +61,9 @@ Only the Pi CLI is supported now; legacy Claude/Codex/Gemini paths have been rem
## Network model (the “new reality”)
- **One Gateway per host**. The Gateway is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` and is not exposed on the LAN.
- **Loopback-first**: the Gateway WebSocket listens on `ws://127.0.0.1:18789` by default.
- To expose it on your tailnet, set `gateway.bind: "tailnet"` (or run `clawdis gateway --bind tailnet`) and set `CLAWDIS_GATEWAY_TOKEN` (required for non-loopback binds).
- The browser Control UI is served from the Gateway at `http://<host>:18789/ui/` when assets are built.
- **Bridge for nodes**: when enabled, the Gateway also exposes a bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable). For tailnet-only setups, set `bridge.bind: "tailnet"` in `~/.clawdis/clawdis.json`.
- **Remote control**: use a VPN/tailnet or an SSH tunnel (`ssh -N -L 18789:127.0.0.1:18789 user@host`). The macOS app can drive this flow.
- **Wide-Area Bonjour (optional)**: for auto-discovery across networks (Vienna ⇄ London) over Tailscale, use unicast DNS-SD on `clawdis.internal.`; see `docs/bonjour.md`.
@@ -79,6 +82,7 @@ Runtime requirement: **Node ≥22.0.0** (not bundled). The macOS app and CLI bot
# From source (recommended while the npm package is still settling)
pnpm install
pnpm build
pnpm ui:build
# Link your WhatsApp (stores creds under ~/.clawdis/credentials)
pnpm clawdis login
@@ -86,6 +90,9 @@ pnpm clawdis login
# Start the gateway (WebSocket control plane)
pnpm clawdis gateway --port 18789 --verbose
# Open the browser Control UI (after ui:build)
# http://127.0.0.1:18789/ui/
# Send a WhatsApp message (WhatsApp sends go through the Gateway)
pnpm clawdis send --to +1234567890 --message "Hello from the CLAWDIS!"
@@ -156,6 +163,7 @@ Optional: enable/configure clawds dedicated browser control (defaults are alr
- [Configuration Guide](./docs/configuration.md)
- [Gateway runbook](./docs/gateway.md)
- [Web surfaces (Control UI)](./docs/web.md)
- [Discovery + transports](./docs/discovery.md)
- [Bonjour / mDNS + Wide-Area Bonjour](./docs/bonjour.md)
- [Agent Runtime](./docs/agent.md)
@@ -197,14 +205,13 @@ Bot-mode support (grammY only) shares the same `main` session as WhatsApp/WebCha
| `clawdis send` | Send a message (WhatsApp default; `--provider telegram` for bot mode). WhatsApp sends go via the Gateway WS; Telegram sends are direct. |
| `clawdis agent` | Talk directly to the agent (no WhatsApp send) |
| `clawdis browser ...` | Manage clawds dedicated browser (status/tabs/open/screenshot). |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway` | Start the Gateway server (WS control plane). Params: `--port`, `--bind`, `--token`, `--force`, `--verbose`. |
| `clawdis gateway health|status|send|agent|call` | Gateway WS clients; assume a running gateway. |
| `clawdis wake` | Enqueue a system event and optionally trigger a heartbeat via the Gateway. |
| `clawdis cron ...` | Manage scheduled jobs (via Gateway). |
| `clawdis nodes ...` | Manage nodes (pairing + status) via the Gateway. |
| `clawdis status` | Web session health + session store summary |
| `clawdis health` | Reports cached provider state from the running gateway. |
| `clawdis webchat` | Start the loopback-only WebChat HTTP server |
#### Gateway client params (WS only)
- `--url` (default `ws://127.0.0.1:18789`)

51
docs/control-ui.md Normal file
View File

@@ -0,0 +1,51 @@
---
summary: "Browser-based control UI for the Gateway (chat, nodes, config)"
read_when:
- You want to operate the Gateway from a browser
- You want Tailnet access without SSH tunnels
---
# Control UI (browser)
The Control UI is a small **Vite + Lit** single-page app served by the Gateway under:
- `http://<host>:18789/ui/`
It speaks **directly to the Gateway WebSocket** on the same port.
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- List nodes via Gateway WS (`node.list`)
- View/edit `~/.clawdis/clawdis.json` via Gateway WS (`config.get`, `config.set`)
## Tailnet access (recommended)
Expose the Gateway on your Tailscale interface and require a token:
```bash
clawdis gateway --bind tailnet --token "$(openssl rand -hex 32)"
```
Then open:
- `http://<tailscale-ip>:18789/ui/`
Paste the token into the UI settings (its sent as `connect.params.auth.token`).
## Building the UI
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
pnpm ui:install
pnpm ui:build
```
For local development (separate dev server):
```bash
pnpm ui:install
pnpm ui:dev
```
Then point the UI at your Gateway WS URL (e.g. `ws://127.0.0.1:18789`).

View File

@@ -50,7 +50,8 @@ Most operations flow through the **Gateway** (`clawdis gateway`), a single long-
## Network model
- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: Gateway WS is `ws://127.0.0.1:18789` (not exposed on the LAN).
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
- For Tailnet access, run `clawdis gateway --bind tailnet --token ...` (token is required for non-loopback binds).
- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
- **Canvas host**: LAN/tailnet HTTP file server (default `18793`) for node WebViews; see `docs/configuration.md` (`canvasHost`).
- **Remote use**: SSH tunnel or tailnet/VPN; see `docs/remote.md` and `docs/discovery.md`.
@@ -117,10 +118,12 @@ Example:
- [Clawd personal assistant setup](./clawd.md)
- [AGENTS.md template (default)](./AGENTS.default.md)
- [Gateway runbook](./gateway.md)
- [Web surfaces (Control UI)](./web.md)
- [Discovery + transports](./discovery.md)
- [Remote access](./remote.md)
- Providers and UX:
- [WebChat](./webchat.md)
- [Control UI (browser)](./control-ui.md)
- [Telegram](./telegram.md)
- [Group messages](./group-messages.md)
- [Media: images](./images.md)

75
docs/web.md Normal file
View File

@@ -0,0 +1,75 @@
---
summary: "Gateway web surfaces: Control UI, bind modes, and security"
read_when:
- You want to access the Gateway over Tailscale
- You want the browser Control UI and config editing
---
# Web (Gateway)
The Gateway serves a small **browser Control UI** (Vite + Lit) from the same port as the Gateway WebSocket:
- `http://<host>:18789/ui/`
The UI talks directly to the Gateway WS and supports:
- Chat (`chat.history`, `chat.send`, `chat.abort`)
- Nodes (`node.list`, `node.describe`, `node.invoke`)
- Config (`config.get`, `config.set`) for `~/.clawdis/clawdis.json`
## Config (default-on)
The Control UI is **enabled by default** when assets are present (`dist/control-ui`).
You can control it via config:
```json5
{
gateway: {
controlUi: { enabled: true } // set false to disable /ui/
}
}
```
## Tailnet access
To access the UI across Tailscale, bind the Gateway to the Tailnet interface and require a token.
### Via config (recommended)
```json5
{
gateway: {
bind: "tailnet",
controlUi: { enabled: true }
}
}
```
Then start the gateway (token required for non-loopback binds):
```bash
export CLAWDIS_GATEWAY_TOKEN="…your token…"
clawdis gateway
```
Open:
- `http://<tailscale-ip>:18789/ui/`
### Via CLI (one-off)
```bash
clawdis gateway --bind tailnet --token "…your token…"
```
## Security notes
- Binding the Gateway to a non-loopback address **requires** `CLAWDIS_GATEWAY_TOKEN`.
- The token is sent as `connect.params.auth.token` by the UI and other clients.
## Building the UI
The Gateway serves static files from `dist/control-ui`. Build them with:
```bash
pnpm ui:install
pnpm ui:build
```

View File

@@ -11,6 +11,9 @@
"dev": "tsx src/index.ts",
"docs:list": "tsx scripts/docs-list.ts",
"build": "tsc -p tsconfig.json",
"ui:install": "pnpm -C ui install",
"ui:dev": "pnpm -C ui dev",
"ui:build": "pnpm -C ui build",
"start": "tsx src/index.ts",
"clawdis": "tsx src/index.ts",
"clawdis:rpc": "tsx src/index.ts agent --mode rpc --json",

View File

@@ -26,7 +26,8 @@ vi.mock("../gateway/call.js", () => ({
}));
vi.mock("../gateway/server.js", () => ({
startGatewayServer: (port: number) => startGatewayServer(port),
startGatewayServer: (port: number, opts?: unknown) =>
startGatewayServer(port, opts),
}));
vi.mock("../globals.js", () => ({

View File

@@ -1,4 +1,5 @@
import type { Command } from "commander";
import { loadConfig } from "../config/config.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js";
import {
@@ -46,6 +47,10 @@ export function registerGatewayCli(program: Command) {
.command("gateway")
.description("Run the WebSocket Gateway")
.option("--port <port>", "Port for the gateway WebSocket", "18789")
.option(
"--bind <mode>",
'Bind mode ("loopback"|"tailnet"|"lan"|"auto"). Defaults to config gateway.bind (or loopback).',
)
.option(
"--token <token>",
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
@@ -115,6 +120,22 @@ export function registerGatewayCli(program: Command) {
if (opts.token) {
process.env.CLAWDIS_GATEWAY_TOKEN = String(opts.token);
}
const cfg = loadConfig();
const bindRaw = String(opts.bind ?? cfg.gateway?.bind ?? "loopback");
const bind =
bindRaw === "loopback" ||
bindRaw === "tailnet" ||
bindRaw === "lan" ||
bindRaw === "auto"
? bindRaw
: null;
if (!bind) {
defaultRuntime.error(
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
);
defaultRuntime.exit(1);
return;
}
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
let shuttingDown = false;
@@ -161,7 +182,7 @@ export function registerGatewayCli(program: Command) {
process.once("SIGINT", onSigint);
try {
server = await startGatewayServer(port);
server = await startGatewayServer(port, { bind });
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway failed to start: ${err.message}`);

View File

@@ -101,6 +101,20 @@ export type CanvasHostConfig = {
port?: number;
};
export type GatewayControlUiConfig = {
/** If false, the Gateway will not serve the Control UI under /ui/. Default: true. */
enabled?: boolean;
};
export type GatewayConfig = {
/**
* Bind address policy for the Gateway WebSocket + Control UI HTTP server.
* Default: loopback (127.0.0.1).
*/
bind?: BridgeBindMode;
controlUi?: GatewayControlUiConfig;
};
export type ClawdisConfig = {
identity?: {
name?: string;
@@ -148,6 +162,7 @@ export type ClawdisConfig = {
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
gateway?: GatewayConfig;
};
// New branding path (preferred)
@@ -311,8 +326,40 @@ const ClawdisSchema = z.object({
port: z.number().int().positive().optional(),
})
.optional(),
gateway: z
.object({
bind: z
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("tailnet"),
z.literal("loopback"),
])
.optional(),
controlUi: z
.object({
enabled: z.boolean().optional(),
})
.optional(),
})
.optional(),
});
export type ConfigValidationIssue = {
path: string;
message: string;
};
export type ConfigFileSnapshot = {
path: string;
exists: boolean;
raw: string | null;
parsed: unknown;
valid: boolean;
config: ClawdisConfig;
issues: ConfigValidationIssue[];
};
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -371,3 +418,106 @@ export function loadConfig(): ClawdisConfig {
return {};
}
}
export function validateConfigObject(
raw: unknown,
):
| { ok: true; config: ClawdisConfig }
| { ok: false; issues: ConfigValidationIssue[] } {
const validated = ClawdisSchema.safeParse(raw);
if (!validated.success) {
return {
ok: false,
issues: validated.error.issues.map((iss) => ({
path: iss.path.join("."),
message: iss.message,
})),
};
}
return { ok: true, config: applyIdentityDefaults(validated.data) };
}
export function parseConfigJson5(
raw: string,
): { ok: true; parsed: unknown } | { ok: false; error: string } {
try {
return { ok: true, parsed: JSON5.parse(raw) as unknown };
} catch (err) {
return { ok: false, error: String(err) };
}
}
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const configPath = CONFIG_PATH_CLAWDIS;
const exists = fs.existsSync(configPath);
if (!exists) {
return {
path: configPath,
exists: false,
raw: null,
parsed: {},
valid: true,
config: {},
issues: [],
};
}
try {
const raw = fs.readFileSync(configPath, "utf-8");
const parsedRes = parseConfigJson5(raw);
if (!parsedRes.ok) {
return {
path: configPath,
exists: true,
raw,
parsed: {},
valid: false,
config: {},
issues: [
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
],
};
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
valid: false,
config: {},
issues: validated.issues,
};
}
return {
path: configPath,
exists: true,
raw,
parsed: parsedRes.parsed,
valid: true,
config: validated.config,
issues: [],
};
} catch (err) {
return {
path: configPath,
exists: true,
raw: null,
parsed: {},
valid: false,
config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }],
};
}
}
export async function writeConfigFile(cfg: ClawdisConfig) {
await fs.promises.mkdir(path.dirname(CONFIG_PATH_CLAWDIS), {
recursive: true,
});
const json = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.promises.writeFile(CONFIG_PATH_CLAWDIS, json, "utf-8");
}

136
src/gateway/control-ui.ts Normal file
View File

@@ -0,0 +1,136 @@
import fs from "node:fs";
import type { IncomingMessage, ServerResponse } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
const UI_PREFIX = "/ui/";
function resolveControlUiRoot(): string | null {
const here = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
// Running from dist: dist/gateway/control-ui.js -> dist/control-ui
path.resolve(here, "../control-ui"),
// Running from source: src/gateway/control-ui.ts -> dist/control-ui
path.resolve(here, "../../dist/control-ui"),
// Fallback to cwd (dev)
path.resolve(process.cwd(), "dist", "control-ui"),
];
for (const dir of candidates) {
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
}
return null;
}
function contentTypeForExt(ext: string): string {
switch (ext) {
case ".html":
return "text/html; charset=utf-8";
case ".js":
return "application/javascript; charset=utf-8";
case ".css":
return "text/css; charset=utf-8";
case ".json":
case ".map":
return "application/json; charset=utf-8";
case ".svg":
return "image/svg+xml";
case ".png":
return "image/png";
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".ico":
return "image/x-icon";
case ".txt":
return "text/plain; charset=utf-8";
default:
return "application/octet-stream";
}
}
function respondNotFound(res: ServerResponse) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
function serveFile(res: ServerResponse, filePath: string) {
const ext = path.extname(filePath).toLowerCase();
res.setHeader("Content-Type", contentTypeForExt(ext));
// Static UI should never be cached aggressively while iterating; allow the
// browser to revalidate.
res.setHeader("Cache-Control", "no-cache");
res.end(fs.readFileSync(filePath));
}
function isSafeRelativePath(relPath: string) {
if (!relPath) return false;
const normalized = path.posix.normalize(relPath);
if (normalized.startsWith("../") || normalized === "..") return false;
if (normalized.includes("\0")) return false;
return true;
}
export function handleControlUiHttpRequest(
req: IncomingMessage,
res: ServerResponse,
): boolean {
const urlRaw = req.url;
if (!urlRaw) return false;
if (req.method !== "GET" && req.method !== "HEAD") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Method Not Allowed");
return true;
}
const url = new URL(urlRaw, "http://localhost");
if (url.pathname === "/ui") {
res.statusCode = 302;
res.setHeader("Location", UI_PREFIX);
res.end();
return true;
}
if (!url.pathname.startsWith(UI_PREFIX)) return false;
const root = resolveControlUiRoot();
if (!root) {
res.statusCode = 503;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(
"Control UI assets not found. Build them with `pnpm ui:build` (or run `pnpm ui:dev` during development).",
);
return true;
}
const rel = url.pathname.slice(UI_PREFIX.length);
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
const fileRel = requested || "index.html";
if (!isSafeRelativePath(fileRel)) {
respondNotFound(res);
return true;
}
const filePath = path.join(root, fileRel);
if (!filePath.startsWith(root)) {
respondNotFound(res);
return true;
}
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
serveFile(res, filePath);
return true;
}
// SPA fallback (client-side router): serve index.html for unknown paths.
const indexPath = path.join(root, "index.html");
if (fs.existsSync(indexPath)) {
serveFile(res, indexPath);
return true;
}
respondNotFound(res);
return true;
}

View File

@@ -9,6 +9,10 @@ import {
ChatEventSchema,
ChatHistoryParamsSchema,
ChatSendParamsSchema,
type ConfigGetParams,
ConfigGetParamsSchema,
type ConfigSetParams,
ConfigSetParamsSchema,
type ConnectParams,
ConnectParamsSchema,
type CronAddParams,
@@ -125,6 +129,12 @@ export const validateSessionsListParams = ajv.compile<SessionsListParams>(
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
SessionsPatchParamsSchema,
);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
ConfigGetParamsSchema,
);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateCronListParams =
ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
@@ -181,6 +191,8 @@ export {
NodeInvokeParamsSchema,
SessionsListParamsSchema,
SessionsPatchParamsSchema,
ConfigGetParamsSchema,
ConfigSetParamsSchema,
CronJobSchema,
CronListParamsSchema,
CronStatusParamsSchema,
@@ -218,6 +230,8 @@ export type {
NodePairRequestParams,
NodePairListParams,
NodePairApproveParams,
ConfigGetParams,
ConfigSetParams,
NodePairRejectParams,
NodePairVerifyParams,
NodeListParams,

View File

@@ -291,6 +291,18 @@ export const SessionsPatchParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ConfigGetParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ConfigSetParamsSchema = Type.Object(
{
raw: NonEmptyString,
},
{ additionalProperties: false },
);
export const CronScheduleSchema = Type.Union([
Type.Object(
{
@@ -541,6 +553,8 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeInvokeParams: NodeInvokeParamsSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,
ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema,
CronJob: CronJobSchema,
CronListParams: CronListParamsSchema,
CronStatusParams: CronStatusParamsSchema,
@@ -582,6 +596,8 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type CronJob = Static<typeof CronJobSchema>;
export type CronListParams = Static<typeof CronListParamsSchema>;
export type CronStatusParams = Static<typeof CronStatusParamsSchema>;

View File

@@ -26,7 +26,15 @@ import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot, type HealthSummary } from "../commands/health.js";
import { getStatusSummary } from "../commands/status.js";
import { type ClawdisConfig, loadConfig } from "../config/config.js";
import {
type ClawdisConfig,
CONFIG_PATH_CLAWDIS,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import {
loadSessionStore,
resolveStorePath,
@@ -90,6 +98,7 @@ import { setHeartbeatsEnabled } from "../web/auto-reply.js";
import { sendMessageWhatsApp } from "../web/outbound.js";
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
import { buildMessageWithAttachments } from "./chat-attachments.js";
import { handleControlUiHttpRequest } from "./control-ui.js";
import {
type ConnectParams,
ErrorCodes,
@@ -105,6 +114,8 @@ import {
validateChatAbortParams,
validateChatHistoryParams,
validateChatSendParams,
validateConfigGetParams,
validateConfigSetParams,
validateConnectParams,
validateCronAddParams,
validateCronListParams,
@@ -183,6 +194,8 @@ type SessionsPatchResult = {
const METHODS = [
"health",
"status",
"config.get",
"config.set",
"voicewake.get",
"voicewake.set",
"sessions.list",
@@ -233,6 +246,27 @@ export type GatewayServer = {
close: () => Promise<void>;
};
export type GatewayServerOptions = {
/**
* Bind address policy for the Gateway WebSocket/HTTP server.
* - loopback: 127.0.0.1
* - lan: 0.0.0.0
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - auto: prefer tailnet, else LAN
*/
bind?: import("../config/config.js").BridgeBindMode;
/**
* Advanced override for the bind host, bypassing bind resolution.
* Prefer `bind` unless you really need a specific address.
*/
host?: string;
/**
* If false, do not serve the browser Control UI under /ui/.
* Default: config `gateway.controlUi.enabled` (or true when absent).
*/
controlUiEnabled?: boolean;
};
function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) return false;
if (ip === "127.0.0.1") return true;
@@ -242,6 +276,21 @@ function isLoopbackAddress(ip: string | undefined): boolean {
return false;
}
function resolveGatewayBindHost(
bind: import("../config/config.js").BridgeBindMode | undefined,
): string | null {
const mode = bind ?? "loopback";
if (mode === "loopback") return "127.0.0.1";
if (mode === "lan") return "0.0.0.0";
if (mode === "tailnet") return pickPrimaryTailnetIPv4() ?? null;
if (mode === "auto") return pickPrimaryTailnetIPv4() ?? "0.0.0.0";
return "127.0.0.1";
}
function isLoopbackHost(host: string): boolean {
return isLoopbackAddress(host);
}
let presenceVersion = 1;
let healthVersion = 1;
let healthCache: HealthSummary | null = null;
@@ -774,9 +823,44 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) {
return healthRefresh;
}
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const host = "127.0.0.1";
const httpServer: HttpServer = createHttpServer();
export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
const cfgForServer = loadConfig();
const bindMode = opts.bind ?? cfgForServer.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
if (!bindHost) {
throw new Error(
"gateway bind is tailnet, but no tailnet interface was found; refusing to start gateway",
);
}
const controlUiEnabled =
opts.controlUiEnabled ?? cfgForServer.gateway?.controlUi?.enabled ?? true;
if (!isLoopbackHost(bindHost) && !getGatewayToken()) {
throw new Error(
`refusing to bind gateway to ${bindHost}:${port} without CLAWDIS_GATEWAY_TOKEN`,
);
}
const httpServer: HttpServer = createHttpServer((req, res) => {
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
if (controlUiEnabled) {
if (req.url === "/") {
res.statusCode = 302;
res.setHeader("Location", "/ui/");
res.end();
return;
}
if (handleControlUiHttpRequest(req, res)) return;
}
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
});
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
let canvasHost: CanvasHostServer | null = null;
@@ -794,18 +878,18 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
};
httpServer.once("error", onError);
httpServer.once("listening", onListening);
httpServer.listen(port, host);
httpServer.listen(port, bindHost);
});
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "EADDRINUSE") {
throw new GatewayLockError(
`another gateway instance is already listening on ws://${host}:${port}`,
`another gateway instance is already listening on ws://${bindHost}:${port}`,
err,
);
}
throw new GatewayLockError(
`failed to bind gateway socket on ws://${host}:${port}: ${String(err)}`,
`failed to bind gateway socket on ws://${bindHost}:${port}: ${String(err)}`,
err,
);
}
@@ -827,6 +911,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
{ sessionKey: string; clientRunId: string }
>();
const chatRunBuffers = new Map<string, string>();
const chatDeltaSentAt = new Map<string, number>();
const chatAbortControllers = new Map<
string,
{ controller: AbortController; sessionId: string; sessionKey: string }
@@ -1171,6 +1256,63 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const snap = await refreshHealthSnapshot({ probe: false });
return { ok: true, payloadJSON: JSON.stringify(snap) };
}
case "config.get": {
const params = parseParams();
if (!validateConfigGetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
}
case "config.set": {
const params = parseParams();
if (!validateConfigSetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
},
};
}
const raw = String((params as { raw?: unknown }).raw ?? "");
const parsedRes = parseConfigJson5(raw);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDIS,
config: validated.config,
}),
};
}
case "sessions.list": {
const params = parseParams();
if (!validateSessionsListParams(params)) {
@@ -1366,6 +1508,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
chatDeltaSentAt.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (
current?.clientRunId === runId &&
@@ -1970,6 +2113,23 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
};
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
chatRunBuffers.set(clientRunId, evt.data.text);
const now = Date.now();
const last = chatDeltaSentAt.get(clientRunId) ?? 0;
// Throttle UI delta events so slow clients don't accumulate unbounded buffers.
if (now - last >= 150) {
chatDeltaSentAt.set(clientRunId, now);
const payload = {
...base,
state: "delta" as const,
message: {
role: "assistant",
content: [{ type: "text", text: evt.data.text }],
timestamp: now,
},
};
broadcast("chat", payload, { dropIfSlow: true });
bridgeSendToSession(sessionKey, "chat", payload);
}
} else if (
evt.stream === "job" &&
typeof evt.data?.state === "string" &&
@@ -1977,6 +2137,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
) {
const text = chatRunBuffers.get(clientRunId)?.trim() ?? "";
chatRunBuffers.delete(clientRunId);
chatDeltaSentAt.delete(clientRunId);
if (evt.data.state === "done") {
const payload = {
...base,
@@ -2457,6 +2618,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
active.controller.abort();
chatAbortControllers.delete(runId);
chatRunBuffers.delete(runId);
chatDeltaSentAt.delete(runId);
const current = chatRunSessions.get(active.sessionId);
if (
current?.clientRunId === runId &&
@@ -2795,6 +2957,69 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
respond(true, status, undefined);
break;
}
case "config.get": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigGetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
),
);
break;
}
const snapshot = await readConfigFileSnapshot();
respond(true, snapshot, undefined);
break;
}
case "config.set": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigSetParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
),
);
break;
}
const raw = String((params as { raw?: unknown }).raw ?? "");
const parsedRes = parseConfigJson5(raw);
if (!parsedRes.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
);
break;
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
details: { issues: validated.issues },
}),
);
break;
}
await writeConfigFile(validated.config);
respond(
true,
{
ok: true,
path: CONFIG_PATH_CLAWDIS,
config: validated.config,
},
undefined,
);
break;
}
case "sessions.list": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateSessionsListParams(params)) {
@@ -3814,7 +4039,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
});
defaultRuntime.log(
`gateway listening on ws://127.0.0.1:${port} (PID ${process.pid})`,
`gateway listening on ws://${bindHost}:${port} (PID ${process.pid})`,
);
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);

14
ui/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clawdis Control</title>
<meta name="color-scheme" content="dark light" />
</head>
<body>
<clawdis-app></clawdis-app>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

17
ui/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "clawdis-control-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm dlx vite@8.0.0-beta.3",
"build": "pnpm dlx vite@8.0.0-beta.3 build",
"preview": "pnpm dlx vite@8.0.0-beta.3 preview"
},
"dependencies": {
"lit": "^3.3.1"
},
"devDependencies": {
"typescript": "^5.9.3",
"vite": "8.0.0-beta.3"
}
}

3
ui/src/main.ts Normal file
View File

@@ -0,0 +1,3 @@
import "./styles.css";
import "./ui/app.ts";

106
ui/src/styles.css Normal file
View File

@@ -0,0 +1,106 @@
:root {
--bg: #0b0f19;
--panel: rgba(255, 255, 255, 0.06);
--panel2: rgba(255, 255, 255, 0.09);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.65);
--border: rgba(255, 255, 255, 0.12);
--accent: #ff4500;
--danger: #ff4d4f;
--ok: #25d366;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
"Liberation Mono", "Courier New", monospace;
color-scheme: dark;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font: 14px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
"Apple Color Emoji", "Segoe UI Emoji";
background: radial-gradient(1200px 800px at 25% 10%, #111b3a 0%, var(--bg) 55%)
fixed;
color: var(--text);
}
a {
color: inherit;
}
button,
input,
textarea,
select {
font: inherit;
color: inherit;
}
.row {
display: flex;
gap: 12px;
align-items: center;
}
.pill {
display: inline-flex;
align-items: center;
gap: 8px;
border: 1px solid var(--border);
padding: 6px 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.2);
}
.btn {
border: 1px solid var(--border);
background: var(--panel);
padding: 7px 10px;
border-radius: 10px;
cursor: pointer;
}
.btn:hover {
background: var(--panel2);
}
.btn.primary {
border-color: rgba(255, 69, 0, 0.35);
background: rgba(255, 69, 0, 0.18);
}
.btn.danger {
border-color: rgba(255, 77, 79, 0.35);
background: rgba(255, 77, 79, 0.16);
}
.field {
display: grid;
gap: 6px;
}
.field label {
color: var(--muted);
font-size: 12px;
}
.field input,
.field textarea,
.field select {
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
padding: 8px 10px;
outline: none;
}
.field textarea {
font-family: var(--mono);
min-height: 220px;
resize: vertical;
white-space: pre;
}
.muted {
color: var(--muted);
}
.mono {
font-family: var(--mono);
}

695
ui/src/ui/app.ts Normal file
View File

@@ -0,0 +1,695 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { GatewayBrowserClient, type GatewayEventFrame } from "./gateway";
import { loadSettings, saveSettings, type UiSettings } from "./storage";
type Tab = "chat" | "nodes" | "config";
@customElement("clawdis-app")
export class ClawdisApp extends LitElement {
static styles = css`
:host {
display: block;
height: 100%;
}
.shell {
height: 100%;
display: grid;
grid-template-rows: auto 1fr;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: rgba(0, 0, 0, 0.18);
backdrop-filter: blur(14px);
}
nav {
display: flex;
gap: 10px;
align-items: center;
}
.tab {
border: 1px solid transparent;
padding: 7px 10px;
border-radius: 10px;
cursor: pointer;
user-select: none;
color: var(--muted);
}
.tab.active {
color: var(--text);
border-color: rgba(255, 69, 0, 0.35);
background: rgba(255, 69, 0, 0.12);
}
main {
padding: 16px;
max-width: 1120px;
width: 100%;
margin: 0 auto;
}
.grid {
display: grid;
gap: 14px;
}
.card {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 16px;
padding: 12px;
}
.statusDot {
width: 8px;
height: 8px;
border-radius: 999px;
background: var(--danger);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.3);
}
.statusDot.ok {
background: var(--ok);
}
.title {
font-weight: 650;
letter-spacing: 0.2px;
}
.split {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 14px;
align-items: start;
}
@media (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
}
.messages {
display: grid;
gap: 10px;
max-height: 60vh;
overflow: auto;
padding: 8px;
}
.msg {
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.2);
border-radius: 14px;
padding: 10px 12px;
}
.msg .meta {
font-size: 12px;
color: var(--muted);
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.msg.user {
border-color: rgba(255, 255, 255, 0.14);
}
.msg.assistant {
border-color: rgba(255, 69, 0, 0.25);
background: rgba(255, 69, 0, 0.08);
}
.compose {
display: grid;
gap: 10px;
}
.compose textarea {
min-height: 92px;
font-family: var(--mono);
}
.nodes {
display: grid;
gap: 10px;
}
.nodeRow {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--border);
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
}
.nodeRow .top {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
font-size: 12px;
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 8px;
color: var(--muted);
background: rgba(0, 0, 0, 0.18);
}
.error {
color: var(--danger);
font-family: var(--mono);
white-space: pre-wrap;
}
`;
@state() private settings: UiSettings = loadSettings();
@state() private tab: Tab = "chat";
@state() private connected = false;
@state() private hello: unknown = null;
@state() private lastError: string | null = null;
@state() private sessionKey = this.settings.sessionKey;
@state() private chatLoading = false;
@state() private chatSending = false;
@state() private chatMessage = "";
@state() private chatMessages: unknown[] = [];
@state() private chatStream: string | null = null;
@state() private chatRunId: string | null = null;
@state() private nodesLoading = false;
@state() private nodes: Array<Record<string, unknown>> = [];
@state() private configLoading = false;
@state() private configRaw = "{\n}\n";
@state() private configValid: boolean | null = null;
@state() private configIssues: unknown[] = [];
@state() private configSaving = false;
private client: GatewayBrowserClient | null = null;
connectedCallback() {
super.connectedCallback();
this.connect();
}
private connect() {
this.lastError = null;
this.hello = null;
this.connected = false;
this.client?.stop();
this.client = new GatewayBrowserClient({
url: this.settings.gatewayUrl,
token: this.settings.token.trim() ? this.settings.token : undefined,
clientName: "clawdis-control-ui",
mode: "webchat",
onHello: (hello) => {
this.connected = true;
this.hello = hello;
void this.refreshActiveTab();
},
onClose: ({ code, reason }) => {
this.connected = false;
this.lastError = `disconnected (${code}): ${reason || "no reason"}`;
},
onEvent: (evt) => this.onEvent(evt),
onGap: ({ expected, received }) => {
this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
},
});
this.client.start();
}
private onEvent(evt: GatewayEventFrame) {
if (evt.event === "chat") {
const payload = evt.payload as
| {
runId: string;
sessionKey: string;
state: "delta" | "final" | "aborted" | "error";
message?: unknown;
errorMessage?: string;
}
| undefined;
if (!payload) return;
if (payload.sessionKey !== this.sessionKey) return;
if (payload.runId && this.chatRunId && payload.runId !== this.chatRunId)
return;
if (payload.state === "delta") {
this.chatStream = extractText(payload.message) ?? this.chatStream;
} else if (payload.state === "final") {
this.chatStream = null;
this.chatRunId = null;
void this.loadChatHistory();
} else if (payload.state === "error") {
this.chatStream = null;
this.chatRunId = null;
this.lastError = payload.errorMessage ?? "chat error";
}
}
}
private async refreshActiveTab() {
if (this.tab === "chat") await this.loadChatHistory();
if (this.tab === "nodes") await this.loadNodes();
if (this.tab === "config") await this.loadConfig();
}
private async loadChatHistory() {
if (!this.client || !this.connected) return;
this.chatLoading = true;
this.lastError = null;
try {
const res = (await this.client.request("chat.history", {
sessionKey: this.sessionKey,
limit: 200,
})) as { messages?: unknown[] };
this.chatMessages = Array.isArray(res.messages) ? res.messages : [];
} catch (err) {
this.lastError = String(err);
} finally {
this.chatLoading = false;
}
}
private async sendChat() {
if (!this.client || !this.connected) return;
const msg = this.chatMessage.trim();
if (!msg) return;
this.chatSending = true;
this.lastError = null;
const runId = crypto.randomUUID();
this.chatRunId = runId;
this.chatStream = "";
try {
await this.client.request("chat.send", {
sessionKey: this.sessionKey,
message: msg,
deliver: false,
idempotencyKey: runId,
});
this.chatMessage = "";
// Final chat state will refresh history, but do an eager refresh in case
// the run completed without emitting a chat event (older gateways).
void this.loadChatHistory();
} catch (err) {
this.chatRunId = null;
this.chatStream = null;
this.lastError = String(err);
} finally {
this.chatSending = false;
}
}
private async loadNodes() {
if (!this.client || !this.connected) return;
this.nodesLoading = true;
this.lastError = null;
try {
const res = (await this.client.request("node.list", {})) as {
nodes?: Array<Record<string, unknown>>;
};
this.nodes = Array.isArray(res.nodes) ? res.nodes : [];
} catch (err) {
this.lastError = String(err);
} finally {
this.nodesLoading = false;
}
}
private async loadConfig() {
if (!this.client || !this.connected) return;
this.configLoading = true;
this.lastError = null;
try {
const res = (await this.client.request("config.get", {})) as {
raw?: string | null;
valid?: boolean;
issues?: unknown[];
config?: unknown;
};
if (typeof res.raw === "string") {
this.configRaw = res.raw;
} else {
const cfg = res.config ?? {};
this.configRaw = `${JSON.stringify(cfg, null, 2).trimEnd()}\n`;
}
this.configValid = typeof res.valid === "boolean" ? res.valid : null;
this.configIssues = Array.isArray(res.issues) ? res.issues : [];
} catch (err) {
this.lastError = String(err);
} finally {
this.configLoading = false;
}
}
private async saveConfig() {
if (!this.client || !this.connected) return;
this.configSaving = true;
this.lastError = null;
try {
await this.client.request("config.set", { raw: this.configRaw });
await this.loadConfig();
} catch (err) {
this.lastError = String(err);
} finally {
this.configSaving = false;
}
}
private setTab(next: Tab) {
this.tab = next;
void this.refreshActiveTab();
}
private applySettings(next: UiSettings) {
this.settings = next;
saveSettings(next);
}
render() {
const proto = this.settings.gatewayUrl.startsWith("wss://") ? "wss" : "ws";
const connectedBadge = html`
<span class="pill" title=${this.connected ? "connected" : "disconnected"}>
<span class="statusDot ${this.connected ? "ok" : ""}"></span>
<span class="mono">${proto}</span>
<span class="mono">${this.settings.gatewayUrl}</span>
</span>
`;
return html`
<div class="shell">
<header>
<div class="row">
<div class="title">Clawdis Control</div>
${connectedBadge}
</div>
<nav>
${this.renderTabs()}
</nav>
</header>
<main>
<div class="grid">
${this.renderSettingsCard()} ${this.renderActiveTab()}
${this.lastError
? html`<div class="card"><div class="error">${this.lastError}</div></div>`
: nothing}
</div>
</main>
</div>
`;
}
private renderTabs() {
const tab = (id: Tab, label: string) => html`
<div
class="tab ${this.tab === id ? "active" : ""}"
@click=${() => this.setTab(id)}
>
${label}
</div>
`;
return html`${tab("chat", "Chat")} ${tab("nodes", "Nodes")}
${tab("config", "Config")}`;
}
private renderSettingsCard() {
return html`
<div class="card">
<div class="split">
<div class="field">
<label>Gateway WebSocket URL</label>
<input
.value=${this.settings.gatewayUrl}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.applySettings({ ...this.settings, gatewayUrl: v });
}}
placeholder="ws://100.x.y.z:18789"
/>
</div>
<div class="field">
<label>Gateway Token (CLAWDIS_GATEWAY_TOKEN)</label>
<input
.value=${this.settings.token}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.applySettings({ ...this.settings, token: v });
}}
placeholder="paste token"
/>
</div>
</div>
<div class="row" style="justify-content: space-between; margin-top: 10px;">
<div class="muted">
Tip: for Tailnet access, start the gateway with a token and bind to
the Tailnet interface.
</div>
<div class="row">
<button class="btn" @click=${() => this.connect()}>
Reconnect
</button>
<button class="btn danger" @click=${() => this.client?.stop()}>
Disconnect
</button>
</div>
</div>
</div>
`;
}
private renderActiveTab() {
if (this.tab === "chat") return this.renderChat();
if (this.tab === "nodes") return this.renderNodes();
if (this.tab === "config") return this.renderConfig();
return nothing;
}
private renderChat() {
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="field" style="min-width: 220px;">
<label>Session Key</label>
<input
.value=${this.sessionKey}
@input=${(e: Event) => {
const v = (e.target as HTMLInputElement).value;
this.sessionKey = v;
this.applySettings({ ...this.settings, sessionKey: v });
}}
/>
</div>
<button
class="btn"
?disabled=${this.chatLoading || !this.connected}
@click=${() => this.loadChatHistory()}
>
${this.chatLoading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="muted">Messages come from the session JSONL logs.</div>
</div>
<div class="messages" style="margin-top: 12px;">
${this.chatMessages.map((m) => renderMessage(m))}
${this.chatStream
? html`${renderMessage({
role: "assistant",
content: [{ type: "text", text: this.chatStream }],
})}`
: nothing}
</div>
<div class="compose" style="margin-top: 12px;">
<div class="field">
<label>Message</label>
<textarea
.value=${this.chatMessage}
@input=${(e: Event) => {
this.chatMessage = (e.target as HTMLTextAreaElement).value;
}}
placeholder="Ask the model…"
></textarea>
</div>
<div class="row" style="justify-content: flex-end;">
<button
class="btn primary"
?disabled=${this.chatSending || !this.connected}
@click=${() => this.sendChat()}
>
${this.chatSending ? "Sending…" : "Send"}
</button>
</div>
</div>
</div>
`;
}
private renderNodes() {
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="title">Nodes</div>
<button
class="btn"
?disabled=${this.nodesLoading || !this.connected}
@click=${() => this.loadNodes()}
>
${this.nodesLoading ? "Loading…" : "Refresh"}
</button>
</div>
<div class="nodes" style="margin-top: 12px;">
${this.nodes.length === 0
? html`<div class="muted">No nodes found.</div>`
: this.nodes.map((n) => renderNode(n))}
</div>
</div>
`;
}
private renderConfig() {
const validity =
this.configValid === null
? "unknown"
: this.configValid
? "valid"
: "invalid";
return html`
<div class="card">
<div class="row" style="justify-content: space-between;">
<div class="row">
<div class="title">Config</div>
<span class="pill"><span class="mono">${validity}</span></span>
</div>
<div class="row">
<button
class="btn"
?disabled=${this.configLoading || !this.connected}
@click=${() => this.loadConfig()}
>
${this.configLoading ? "Loading…" : "Reload"}
</button>
<button
class="btn primary"
?disabled=${this.configSaving || !this.connected}
@click=${() => this.saveConfig()}
>
${this.configSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div class="muted" style="margin-top: 10px;">
Writes to <span class="mono">~/.clawdis/clawdis.json</span>. Some
changes may require a gateway restart.
</div>
<div class="field" style="margin-top: 12px;">
<label>Raw JSON5</label>
<textarea
.value=${this.configRaw}
@input=${(e: Event) => {
this.configRaw = (e.target as HTMLTextAreaElement).value;
}}
></textarea>
</div>
${this.configIssues.length > 0
? html`<div class="card" style="margin-top: 12px;">
<div class="title">Issues</div>
<div class="error">${JSON.stringify(this.configIssues, null, 2)}</div>
</div>`
: nothing}
</div>
`;
}
}
function renderNode(node: Record<string, unknown>) {
const connected = Boolean(node.connected);
const paired = Boolean(node.paired);
const title =
(typeof node.displayName === "string" && node.displayName.trim()) ||
(typeof node.nodeId === "string" ? node.nodeId : "unknown");
const caps = Array.isArray(node.caps) ? (node.caps as unknown[]) : [];
const commands = Array.isArray(node.commands) ? (node.commands as unknown[]) : [];
return html`
<div class="nodeRow">
<div class="top">
<div class="row">
<span class="statusDot ${connected ? "ok" : ""}"></span>
<div class="title">${title}</div>
</div>
<div class="row muted">
<span>${paired ? "paired" : "unpaired"}</span>
<span>·</span>
<span>${connected ? "connected" : "offline"}</span>
</div>
</div>
<div class="muted mono">
${typeof node.nodeId === "string" ? node.nodeId : ""}
${typeof node.remoteIp === "string" ? `· ${node.remoteIp}` : ""}
${typeof node.version === "string" ? `· ${node.version}` : ""}
</div>
${caps.length > 0
? html`<div class="chips">
${caps.slice(0, 24).map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>`
: nothing}
${commands.length > 0
? html`<div class="chips">
${commands
.slice(0, 24)
.map((c) => html`<span class="chip">${String(c)}</span>`)}
</div>`
: nothing}
</div>
`;
}
function renderMessage(message: unknown) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const text =
extractText(message) ??
(typeof m.content === "string"
? m.content
: JSON.stringify(message, null, 2));
const ts =
typeof m.timestamp === "number"
? new Date(m.timestamp).toLocaleTimeString()
: "";
const klass =
role === "assistant" ? "assistant" : role === "user" ? "user" : "";
return html`
<div class="msg ${klass}">
<div class="meta">
<span class="mono">${role}</span>
<span class="mono">${ts}</span>
</div>
<div style="white-space: pre-wrap;">${text}</div>
</div>
`;
}
function extractText(message: unknown): string | null {
const m = message as Record<string, unknown>;
const content = m.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
const parts = content
.map((p) => {
const item = p as Record<string, unknown>;
if (item.type === "text" && typeof item.text === "string") return item.text;
return null;
})
.filter((v): v is string => typeof v === "string");
if (parts.length > 0) return parts.join("\n");
}
if (typeof m.text === "string") return m.text;
return null;
}

170
ui/src/ui/gateway.ts Normal file
View File

@@ -0,0 +1,170 @@
export type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
export type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
};
export type GatewayBrowserClientOptions = {
url: string;
token?: string;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this.closed = false;
this.connect();
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.sendConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
};
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
}
private flushPending(err: Error) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private sendConnect() {
const params = {
minProtocol: 2,
maxProtocol: 2,
client: {
name: this.opts.clientName ?? "clawdis-control-ui",
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? "webchat",
instanceId: this.opts.instanceId,
},
caps: [],
auth: this.opts.token ? { token: this.opts.token } : undefined,
userAgent: navigator.userAgent,
locale: navigator.language,
};
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch(() => {
this.ws?.close(1008, "connect failed");
});
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
this.opts.onEvent?.(evt);
return;
}
if (frame.type === "res") {
const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id);
if (!pending) return;
this.pending.delete(res.id);
if (res.ok) pending.resolve(res.payload);
else pending.reject(new Error(res.error?.message ?? "request failed"));
return;
}
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected"));
}
const id = crypto.randomUUID();
const frame = { type: "req", id, method, params };
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this.ws.send(JSON.stringify(frame));
return p;
}
}

44
ui/src/ui/storage.ts Normal file
View File

@@ -0,0 +1,44 @@
const KEY = "clawdis.control.settings.v1";
export type UiSettings = {
gatewayUrl: string;
token: string;
sessionKey: string;
};
export function loadSettings(): UiSettings {
const defaultUrl = (() => {
const proto = location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${location.host}`;
})();
const defaults: UiSettings = {
gatewayUrl: defaultUrl,
token: "",
sessionKey: "main",
};
try {
const raw = localStorage.getItem(KEY);
if (!raw) return defaults;
const parsed = JSON.parse(raw) as Partial<UiSettings>;
return {
gatewayUrl:
typeof parsed.gatewayUrl === "string" && parsed.gatewayUrl.trim()
? parsed.gatewayUrl.trim()
: defaults.gatewayUrl,
token: typeof parsed.token === "string" ? parsed.token : defaults.token,
sessionKey:
typeof parsed.sessionKey === "string" && parsed.sessionKey.trim()
? parsed.sessionKey.trim()
: defaults.sessionKey,
};
} catch {
return defaults;
}
}
export function saveSettings(next: UiSettings) {
localStorage.setItem(KEY, JSON.stringify(next));
}

13
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"skipLibCheck": true,
"types": ["vite/client"],
"useDefineForClassFields": false
},
"include": ["src"]
}

19
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
const here = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
base: "/ui/",
build: {
outDir: path.resolve(here, "../dist/control-ui"),
emptyOutDir: true,
sourcemap: true,
},
server: {
host: true,
port: 5173,
strictPort: true,
},
});