From 79984702de1024535ce11415e068a818d132bf5c Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Thu, 5 Mar 2026 00:17:23 +0700 Subject: [PATCH 01/18] feat: provision full OpenClaw workspaces from agent creation --- src/app/api/agents/route.ts | 41 ++++++++++++++++++++- src/components/panels/agent-detail-tabs.tsx | 13 +++++++ src/lib/validation.ts | 3 ++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index 3f835f0..1f52638 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createAgentSchema } from '@/lib/validation'; +import { runOpenClaw } from '@/lib/command'; +import { config as appConfig } from '@/lib/config'; +import { resolveWithin } from '@/lib/paths'; +import path from 'node:path'; /** * GET /api/agents - List all agents with optional filtering @@ -123,6 +127,7 @@ export async function POST(request: NextRequest) { const { name, + openclaw_id, role, session_key, soul_content, @@ -130,9 +135,16 @@ export async function POST(request: NextRequest) { config = {}, template, gateway_config, - write_to_gateway + write_to_gateway, + provision_openclaw_workspace, + openclaw_workspace_path } = body; + const openclawId = (openclaw_id || name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + // Resolve template if specified let finalRole = role; let finalConfig: Record = { ...config }; @@ -158,6 +170,32 @@ export async function POST(request: NextRequest) { if (existingAgent) { return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); } + + if (provision_openclaw_workspace) { + if (!appConfig.openclawStateDir) { + return NextResponse.json( + { error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' }, + { status: 500 } + ); + } + + const workspacePath = openclaw_workspace_path + ? path.resolve(openclaw_workspace_path) + : resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId)); + + try { + await runOpenClaw( + ['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'], + { timeoutMs: 20000 } + ); + } catch (provisionError: any) { + logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed'); + return NextResponse.json( + { error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' }, + { status: 502 } + ); + } + } const now = Math.floor(Date.now() / 1000); @@ -215,7 +253,6 @@ export async function POST(request: NextRequest) { // Write to gateway config if requested if (write_to_gateway && finalConfig) { try { - const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-'); await writeAgentToConfig({ id: openclawId, name, diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index ee1973b..1e6bee1 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -852,6 +852,7 @@ export function CreateAgentModal({ dockerNetwork: 'none' as 'none' | 'bridge', session_key: '', write_to_gateway: true, + provision_openclaw_workspace: true, }) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -916,10 +917,12 @@ export function CreateAgentModal({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formData.name, + openclaw_id: formData.id || undefined, role: formData.role, session_key: formData.session_key || undefined, template: selectedTemplate || undefined, write_to_gateway: formData.write_to_gateway, + provision_openclaw_workspace: formData.provision_openclaw_workspace, gateway_config: { model: { primary: primaryModel }, identity: { name: formData.name, theme: formData.role, emoji: formData.emoji }, @@ -1199,6 +1202,16 @@ export function CreateAgentModal({ /> Add to gateway config (openclaw.json) + + )} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f26e7a5..fb13b93 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial() export const createAgentSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), + openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(), role: z.string().min(1, 'Role is required').max(100).optional(), session_key: z.string().max(200).optional(), soul_content: z.string().max(50000).optional(), @@ -53,6 +54,8 @@ export const createAgentSchema = z.object({ template: z.string().max(100).optional(), gateway_config: z.record(z.string(), z.unknown()).optional(), write_to_gateway: z.boolean().optional(), + provision_openclaw_workspace: z.boolean().optional(), + openclaw_workspace_path: z.string().min(1).max(500).optional(), }) export const bulkUpdateTaskStatusSchema = z.object({ From d59b2e70a14f1d6a3e055834f05754dd96620491 Mon Sep 17 00:00:00 2001 From: fulgore Date: Thu, 5 Mar 2026 13:57:15 +1000 Subject: [PATCH 02/18] fix: macOS compatibility for status commands and gateway client id Replace Linux-only commands (uptime -s, free -m, df --output=pcent) with cross-platform alternatives using process.platform detection and Node.js os module. Rename gateway client ID from control-ui to openclaw-control-ui. Co-authored-by: Claude Opus 4.6 --- .env.example | 2 +- src/app/api/status/route.ts | 87 ++++++++++++++++++++++++------------- src/lib/websocket.ts | 2 +- 3 files changed, 60 insertions(+), 31 deletions(-) diff --git a/.env.example b/.env.example index 9da5f96..f774500 100644 --- a/.env.example +++ b/.env.example @@ -65,7 +65,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL= NEXT_PUBLIC_GATEWAY_URL= # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token # Gateway client id used in websocket handshake (role=operator UI client). -NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui +NEXT_PUBLIC_GATEWAY_CLIENT_ID=openclaw-control-ui # === Data Paths (all optional, defaults to .data/ in project root) === # MISSION_CONTROL_DATA_DIR=.data diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index 1be21a1..c8cb536 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server' import net from 'node:net' +import os from 'node:os' import { existsSync, statSync } from 'node:fs' import path from 'node:path' import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command' @@ -195,29 +196,48 @@ async function getSystemStatus(workspaceId: number) { } try { - // System uptime - const { stdout: uptimeOutput } = await runCommand('uptime', ['-s'], { - timeoutMs: 3000 - }) - const bootTime = new Date(uptimeOutput.trim()) - status.uptime = Date.now() - bootTime.getTime() + // System uptime (cross-platform) + if (process.platform === 'darwin') { + const { stdout } = await runCommand('sysctl', ['-n', 'kern.boottime'], { + timeoutMs: 3000 + }) + // Output format: { sec = 1234567890, usec = 0 } ... + const match = stdout.match(/sec\s*=\s*(\d+)/) + if (match) { + status.uptime = Date.now() - parseInt(match[1]) * 1000 + } + } else { + const { stdout } = await runCommand('uptime', ['-s'], { + timeoutMs: 3000 + }) + const bootTime = new Date(stdout.trim()) + status.uptime = Date.now() - bootTime.getTime() + } } catch (error) { logger.error({ err: error }, 'Error getting uptime') } try { - // Memory info - const { stdout: memOutput } = await runCommand('free', ['-m'], { - timeoutMs: 3000 - }) - const memLines = memOutput.split('\n') - const memLine = memLines.find(line => line.startsWith('Mem:')) - if (memLine) { - const parts = memLine.split(/\s+/) - status.memory = { - total: parseInt(parts[1]) || 0, - used: parseInt(parts[2]) || 0, - available: parseInt(parts[6]) || 0 + // Memory info (cross-platform) + if (process.platform === 'darwin') { + const totalBytes = os.totalmem() + const freeBytes = os.freemem() + const totalMB = Math.round(totalBytes / (1024 * 1024)) + const usedMB = Math.round((totalBytes - freeBytes) / (1024 * 1024)) + const availableMB = Math.round(freeBytes / (1024 * 1024)) + status.memory = { total: totalMB, used: usedMB, available: availableMB } + } else { + const { stdout: memOutput } = await runCommand('free', ['-m'], { + timeoutMs: 3000 + }) + const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:')) + if (memLine) { + const parts = memLine.split(/\s+/) + status.memory = { + total: parseInt(parts[1]) || 0, + used: parseInt(parts[2]) || 0, + available: parseInt(parts[6]) || 0 + } } } } catch (error) { @@ -414,14 +434,17 @@ async function performHealthCheck() { }) } - // Check disk space + // Check disk space (cross-platform: use df -h / and parse capacity column) try { - const { stdout } = await runCommand('df', ['/', '--output=pcent'], { + const { stdout } = await runCommand('df', ['-h', '/'], { timeoutMs: 3000 }) const lines = stdout.trim().split('\n') const last = lines[lines.length - 1] || '' - const usagePercent = parseInt(last.replace('%', '').trim() || '0') + const parts = last.split(/\s+/) + // On macOS capacity is col 4 ("85%"), on Linux use% is col 4 as well + const pctField = parts.find(p => p.endsWith('%')) || '0%' + const usagePercent = parseInt(pctField.replace('%', '') || '0') health.checks.push({ name: 'Disk Space', @@ -436,15 +459,21 @@ async function performHealthCheck() { }) } - // Check memory usage + // Check memory usage (cross-platform) try { - const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 }) - const lines = stdout.split('\n') - const memLine = lines.find((line) => line.startsWith('Mem:')) - const parts = (memLine || '').split(/\s+/) - const total = parseInt(parts[1] || '0') - const available = parseInt(parts[6] || '0') - const usagePercent = Math.round(((total - available) / total) * 100) + let usagePercent: number + if (process.platform === 'darwin') { + const totalBytes = os.totalmem() + const freeBytes = os.freemem() + usagePercent = Math.round(((totalBytes - freeBytes) / totalBytes) * 100) + } else { + const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 }) + const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:')) + const parts = (memLine || '').split(/\s+/) + const total = parseInt(parts[1] || '0') + const available = parseInt(parts[6] || '0') + usagePercent = Math.round(((total - available) / total) * 100) + } health.checks.push({ name: 'Memory Usage', diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts index a1ab370..7c02e56 100644 --- a/src/lib/websocket.ts +++ b/src/lib/websocket.ts @@ -16,7 +16,7 @@ const log = createClientLogger('WebSocket') // Gateway protocol version (v3 required by OpenClaw 2026.x) const PROTOCOL_VERSION = 3 -const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui' +const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'openclaw-control-ui' // Heartbeat configuration const PING_INTERVAL_MS = 30_000 From ec9ba456286f35d09c5c7ac138ff75526ef8f3a1 Mon Sep 17 00:00:00 2001 From: nyk <93952610+0xNyk@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:57:17 +0700 Subject: [PATCH 03/18] feat: provision full OpenClaw workspaces from agent creation --- src/app/api/agents/route.ts | 41 ++++++++++++++++++++- src/components/panels/agent-detail-tabs.tsx | 13 +++++++ src/lib/validation.ts | 3 ++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index 3f835f0..1f52638 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createAgentSchema } from '@/lib/validation'; +import { runOpenClaw } from '@/lib/command'; +import { config as appConfig } from '@/lib/config'; +import { resolveWithin } from '@/lib/paths'; +import path from 'node:path'; /** * GET /api/agents - List all agents with optional filtering @@ -123,6 +127,7 @@ export async function POST(request: NextRequest) { const { name, + openclaw_id, role, session_key, soul_content, @@ -130,9 +135,16 @@ export async function POST(request: NextRequest) { config = {}, template, gateway_config, - write_to_gateway + write_to_gateway, + provision_openclaw_workspace, + openclaw_workspace_path } = body; + const openclawId = (openclaw_id || name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + // Resolve template if specified let finalRole = role; let finalConfig: Record = { ...config }; @@ -158,6 +170,32 @@ export async function POST(request: NextRequest) { if (existingAgent) { return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 }); } + + if (provision_openclaw_workspace) { + if (!appConfig.openclawStateDir) { + return NextResponse.json( + { error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' }, + { status: 500 } + ); + } + + const workspacePath = openclaw_workspace_path + ? path.resolve(openclaw_workspace_path) + : resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId)); + + try { + await runOpenClaw( + ['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'], + { timeoutMs: 20000 } + ); + } catch (provisionError: any) { + logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed'); + return NextResponse.json( + { error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' }, + { status: 502 } + ); + } + } const now = Math.floor(Date.now() / 1000); @@ -215,7 +253,6 @@ export async function POST(request: NextRequest) { // Write to gateway config if requested if (write_to_gateway && finalConfig) { try { - const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-'); await writeAgentToConfig({ id: openclawId, name, diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index ee1973b..1e6bee1 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -852,6 +852,7 @@ export function CreateAgentModal({ dockerNetwork: 'none' as 'none' | 'bridge', session_key: '', write_to_gateway: true, + provision_openclaw_workspace: true, }) const [isCreating, setIsCreating] = useState(false) const [error, setError] = useState(null) @@ -916,10 +917,12 @@ export function CreateAgentModal({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: formData.name, + openclaw_id: formData.id || undefined, role: formData.role, session_key: formData.session_key || undefined, template: selectedTemplate || undefined, write_to_gateway: formData.write_to_gateway, + provision_openclaw_workspace: formData.provision_openclaw_workspace, gateway_config: { model: { primary: primaryModel }, identity: { name: formData.name, theme: formData.role, emoji: formData.emoji }, @@ -1199,6 +1202,16 @@ export function CreateAgentModal({ /> Add to gateway config (openclaw.json) + + )} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f26e7a5..fb13b93 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial() export const createAgentSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), + openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(), role: z.string().min(1, 'Role is required').max(100).optional(), session_key: z.string().max(200).optional(), soul_content: z.string().max(50000).optional(), @@ -53,6 +54,8 @@ export const createAgentSchema = z.object({ template: z.string().max(100).optional(), gateway_config: z.record(z.string(), z.unknown()).optional(), write_to_gateway: z.boolean().optional(), + provision_openclaw_workspace: z.boolean().optional(), + openclaw_workspace_path: z.string().min(1).max(500).optional(), }) export const bulkUpdateTaskStatusSchema = z.object({ From b130b881a0a26cedfb6e4207a3c882cbcd844d6b Mon Sep 17 00:00:00 2001 From: Bhavik Patel Date: Thu, 5 Mar 2026 07:58:45 +0400 Subject: [PATCH 04/18] docs: improve workspace and memory UX guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #146 — How to add workspace: - Add Workspace Management section to README with Super Admin panel docs - Add Super Admin API endpoints to API overview table - Add info banner in Settings panel (admin only) linking to Super Admin Issue #143 — Memory tab in agent view: - Add info banner in agent Memory tab clearly distinguishing agent working memory (DB scratchpad) from workspace memory files - Add clickable link to Memory Browser page from agent Memory tab - Improve subtitle text with WORKING.md storage detail Fixes #146 Fixes #143 --- README.md | 22 +++++++++++++++++++++ src/components/panels/agent-detail-tabs.tsx | 10 +++++++++- src/components/panels/settings-panel.tsx | 17 ++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dc4ab10..8edfc04 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot ### Integrations Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management. +### Workspace Management +Workspaces (tenant instances) are created and managed through the **Super Admin** panel, accessible from the sidebar under **Admin > Super Admin**. From there, admins can: +- **Create** new client instances (slug, display name, Linux user, gateway port, plan tier) +- **Monitor** provisioning jobs and their step-by-step progress +- **Decommission** tenants with optional cleanup of state directories and Linux users + +Each workspace gets its own isolated environment with a dedicated OpenClaw gateway, state directory, and workspace root. See the [Super Admin API](#api-overview) endpoints under `/api/super/*` for programmatic access. + ### Update Checker Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard. @@ -277,6 +285,20 @@ All endpoints require authentication unless noted. Full reference below. +
+Super Admin (Workspace/Tenant Management) + +| Method | Path | Role | Description | +|--------|------|------|-------------| +| `GET` | `/api/super/tenants` | admin | List all tenants with latest provisioning status | +| `POST` | `/api/super/tenants` | admin | Create tenant and queue bootstrap job | +| `POST` | `/api/super/tenants/[id]/decommission` | admin | Queue tenant decommission job | +| `GET` | `/api/super/provision-jobs` | admin | List provisioning jobs (filter: `?tenant_id=`, `?status=`) | +| `POST` | `/api/super/provision-jobs` | admin | Queue additional job for existing tenant | +| `POST` | `/api/super/provision-jobs/[id]/action` | admin | Approve, reject, or cancel a provisioning job | + +
+
Direct CLI diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 1e6bee1..4f5ffd0 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -517,7 +517,7 @@ export function MemoryTab({

Working Memory

- Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files. + This is agent-level scratchpad memory (stored as WORKING.md in the database), not the workspace memory folder.

@@ -543,6 +543,14 @@ export function MemoryTab({
+ {/* Info Banner */} +
+ Agent Memory vs Workspace Memory:{' '} + This tab edits only this agent's private working memory (a scratchpad stored in the database). + To browse or edit all workspace memory files (daily logs, knowledge base, MEMORY.md, etc.), visit the{' '} + Memory Browser page. +
+ {/* Memory Content */}
+ {/* Workspace Info */} + {currentUser?.role === 'admin' && ( +
+ Workspace Management:{' '} + To create or manage workspaces (tenant instances), go to the{' '} + {' '} + panel under Admin > Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs. +
+ )} + {/* Feedback */} {feedback && (
Date: Thu, 5 Mar 2026 07:58:47 +0400 Subject: [PATCH 05/18] fix: restore @mention autocomplete visibility in task modal - Increase MentionTextarea dropdown z-index to z-[60] so it renders above z-50 modals (was z-20, clipped by overflow-y-auto on modal) - Replace plain textarea in broadcast section with MentionTextarea for consistent @mention support across all text inputs - Add hint text to broadcast placeholder about @mention usage Fixes #172 --- src/components/panels/task-board-panel.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index e7dc395..74a6810 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -217,7 +217,7 @@ function MentionTextarea({ className={className} /> {open && filtered.length > 0 && ( -
+
{filtered.map((option, index) => (
)}
-