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/20] 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 047216dbe2c200b3f74e0dda060b350a662da066 Mon Sep 17 00:00:00 2001 From: Bhavikprit Date: Wed, 4 Mar 2026 23:10:19 +0400 Subject: [PATCH 02/20] feat(#163): add Agent Self-Diagnostics API endpoint New GET /api/agents/[id]/diagnostics endpoint enabling agents to query their own performance data for self-optimization. Sections (selectable via ?section= query param): - summary: KPIs (throughput, error rate, activity count) - tasks: completion breakdown by status/priority, throughput/day - errors: error frequency by type, recent error details - activity: activity breakdown with hourly timeline - trends: current vs previous period comparison with auto-alerts - tokens: token usage by model with cost totals Features: - Scoped to requesting agent only (no cross-agent data access) - Configurable time window via ?hours= param (1-720h) - Automatic trend alerts for error spikes, throughput drops, stalls - Works with existing activities, tasks, and token_usage tables Fixes #163 --- src/app/api/agents/[id]/diagnostics/route.ts | 285 +++++++++++++++++++ 1 file changed, 285 insertions(+) create mode 100644 src/app/api/agents/[id]/diagnostics/route.ts diff --git a/src/app/api/agents/[id]/diagnostics/route.ts b/src/app/api/agents/[id]/diagnostics/route.ts new file mode 100644 index 0000000..567d5fa --- /dev/null +++ b/src/app/api/agents/[id]/diagnostics/route.ts @@ -0,0 +1,285 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getDatabase } from '@/lib/db'; +import { requireRole } from '@/lib/auth'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API + * + * Provides an agent with its own performance metrics, error analysis, + * and trend data so it can self-optimize. + * + * Query params: + * hours - Time window in hours (default: 24, max: 720 = 30 days) + * section - Comma-separated sections to include (default: all) + * Options: summary, tasks, errors, activity, trends, tokens + * + * Response includes: + * summary - High-level KPIs (throughput, error rate, activity count) + * tasks - Task completion breakdown by status and priority + * errors - Error frequency, types, and recent error details + * activity - Activity breakdown by type with hourly timeline + * trends - Multi-period comparison for trend detection + * tokens - Token usage by model with cost estimates + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = requireRole(request, 'viewer'); + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + try { + const db = getDatabase(); + const resolvedParams = await params; + const agentId = resolvedParams.id; + const workspaceId = auth.user.workspace_id ?? 1; + + // Resolve agent by ID or name + let agent: any; + if (/^\d+$/.test(agentId)) { + agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId); + } else { + agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId); + } + + if (!agent) { + return NextResponse.json({ error: 'Agent not found' }, { status: 404 }); + } + + const { searchParams } = new URL(request.url); + const hours = Math.min(Math.max(parseInt(searchParams.get('hours') || '24', 10) || 24, 1), 720); + const sectionParam = searchParams.get('section') || 'summary,tasks,errors,activity,trends,tokens'; + const sections = new Set(sectionParam.split(',').map(s => s.trim())); + + const now = Math.floor(Date.now() / 1000); + const since = now - hours * 3600; + + const result: Record = { + agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status }, + timeframe: { hours, since, until: now }, + }; + + if (sections.has('summary')) { + result.summary = buildSummary(db, agent.name, workspaceId, since); + } + + if (sections.has('tasks')) { + result.tasks = buildTaskMetrics(db, agent.name, workspaceId, since); + } + + if (sections.has('errors')) { + result.errors = buildErrorAnalysis(db, agent.name, workspaceId, since); + } + + if (sections.has('activity')) { + result.activity = buildActivityBreakdown(db, agent.name, workspaceId, since); + } + + if (sections.has('trends')) { + result.trends = buildTrends(db, agent.name, workspaceId, hours); + } + + if (sections.has('tokens')) { + result.tokens = buildTokenMetrics(db, agent.name, workspaceId, since); + } + + return NextResponse.json(result); + } catch (error) { + logger.error({ err: error }, 'GET /api/agents/[id]/diagnostics error'); + return NextResponse.json({ error: 'Failed to fetch diagnostics' }, { status: 500 }); + } +} + +/** High-level KPIs */ +function buildSummary(db: any, agentName: string, workspaceId: number, since: number) { + const tasksDone = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ?` + ).get(agentName, workspaceId, since) as any).c; + + const tasksTotal = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ?` + ).get(agentName, workspaceId) as any).c; + + const activityCount = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ?` + ).get(agentName, workspaceId, since) as any).c; + + const errorCount = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND type LIKE '%error%'` + ).get(agentName, workspaceId, since) as any).c; + + const errorRate = activityCount > 0 ? Math.round((errorCount / activityCount) * 10000) / 100 : 0; + + return { + tasks_completed: tasksDone, + tasks_total: tasksTotal, + activity_count: activityCount, + error_count: errorCount, + error_rate_percent: errorRate, + }; +} + +/** Task completion breakdown */ +function buildTaskMetrics(db: any, agentName: string, workspaceId: number, since: number) { + const byStatus = db.prepare( + `SELECT status, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY status` + ).all(agentName, workspaceId) as Array<{ status: string; count: number }>; + + const byPriority = db.prepare( + `SELECT priority, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY priority` + ).all(agentName, workspaceId) as Array<{ priority: string; count: number }>; + + const recentCompleted = db.prepare( + `SELECT id, title, priority, updated_at FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? ORDER BY updated_at DESC LIMIT 10` + ).all(agentName, workspaceId, since) as any[]; + + // Estimate throughput: tasks completed per day in the window + const windowDays = Math.max((Math.floor(Date.now() / 1000) - since) / 86400, 1); + const completedInWindow = recentCompleted.length; + const throughputPerDay = Math.round((completedInWindow / windowDays) * 100) / 100; + + return { + by_status: Object.fromEntries(byStatus.map(r => [r.status, r.count])), + by_priority: Object.fromEntries(byPriority.map(r => [r.priority, r.count])), + recent_completed: recentCompleted, + throughput_per_day: throughputPerDay, + }; +} + +/** Error frequency and analysis */ +function buildErrorAnalysis(db: any, agentName: string, workspaceId: number, since: number) { + const errorActivities = db.prepare( + `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') GROUP BY type ORDER BY count DESC` + ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>; + + const recentErrors = db.prepare( + `SELECT id, type, description, data, created_at FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND (type LIKE '%error%' OR type LIKE '%fail%') ORDER BY created_at DESC LIMIT 20` + ).all(agentName, workspaceId, since) as any[]; + + return { + by_type: errorActivities, + total: errorActivities.reduce((sum, e) => sum + e.count, 0), + recent: recentErrors.map(e => ({ + ...e, + data: e.data ? JSON.parse(e.data) : null, + })), + }; +} + +/** Activity breakdown with hourly timeline */ +function buildActivityBreakdown(db: any, agentName: string, workspaceId: number, since: number) { + const byType = db.prepare( + `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY type ORDER BY count DESC` + ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>; + + const timeline = db.prepare( + `SELECT (created_at / 3600) * 3600 as hour_bucket, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY hour_bucket ORDER BY hour_bucket ASC` + ).all(agentName, workspaceId, since) as Array<{ hour_bucket: number; count: number }>; + + return { + by_type: byType, + timeline: timeline.map(t => ({ + timestamp: t.hour_bucket, + hour: new Date(t.hour_bucket * 1000).toISOString(), + count: t.count, + })), + }; +} + +/** Multi-period trend comparison for anomaly/trend detection */ +function buildTrends(db: any, agentName: string, workspaceId: number, hours: number) { + const now = Math.floor(Date.now() / 1000); + + // Compare current period vs previous period of same length + const currentSince = now - hours * 3600; + const previousSince = currentSince - hours * 3600; + + const periodMetrics = (since: number, until: number) => { + const activities = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ?` + ).get(agentName, workspaceId, since, until) as any).c; + + const errors = (db.prepare( + `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ? AND (type LIKE '%error%' OR type LIKE '%fail%')` + ).get(agentName, workspaceId, since, until) as any).c; + + const tasksCompleted = (db.prepare( + `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? AND updated_at < ?` + ).get(agentName, workspaceId, since, until) as any).c; + + return { activities, errors, tasks_completed: tasksCompleted }; + }; + + const current = periodMetrics(currentSince, now); + const previous = periodMetrics(previousSince, currentSince); + + const pctChange = (cur: number, prev: number) => { + if (prev === 0) return cur > 0 ? 100 : 0; + return Math.round(((cur - prev) / prev) * 10000) / 100; + }; + + return { + current_period: { since: currentSince, until: now, ...current }, + previous_period: { since: previousSince, until: currentSince, ...previous }, + change: { + activities_pct: pctChange(current.activities, previous.activities), + errors_pct: pctChange(current.errors, previous.errors), + tasks_completed_pct: pctChange(current.tasks_completed, previous.tasks_completed), + }, + alerts: buildTrendAlerts(current, previous), + }; +} + +/** Generate automatic alerts from trend data */ +function buildTrendAlerts(current: { activities: number; errors: number; tasks_completed: number }, previous: { activities: number; errors: number; tasks_completed: number }) { + const alerts: Array<{ level: string; message: string }> = []; + + // Error rate spike + if (current.errors > 0 && previous.errors > 0) { + const errorIncrease = (current.errors - previous.errors) / previous.errors; + if (errorIncrease > 0.5) { + alerts.push({ level: 'warning', message: `Error count increased ${Math.round(errorIncrease * 100)}% vs previous period` }); + } + } else if (current.errors > 3 && previous.errors === 0) { + alerts.push({ level: 'warning', message: `New error pattern: ${current.errors} errors (none in previous period)` }); + } + + // Throughput drop + if (previous.tasks_completed > 0 && current.tasks_completed === 0) { + alerts.push({ level: 'info', message: 'No tasks completed in current period (possible stall)' }); + } else if (previous.tasks_completed > 2 && current.tasks_completed < previous.tasks_completed * 0.5) { + alerts.push({ level: 'info', message: `Task throughput dropped ${Math.round((1 - current.tasks_completed / previous.tasks_completed) * 100)}%` }); + } + + // Activity drop (possible offline) + if (previous.activities > 5 && current.activities < previous.activities * 0.25) { + alerts.push({ level: 'warning', message: `Activity dropped ${Math.round((1 - current.activities / previous.activities) * 100)}% — agent may be stalled` }); + } + + return alerts; +} + +/** Token usage by model */ +function buildTokenMetrics(db: any, agentName: string, workspaceId: number, since: number) { + try { + // session_id on token_usage may store agent name or session key + const byModel = db.prepare( + `SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COUNT(*) as request_count FROM token_usage WHERE session_id = ? AND workspace_id = ? AND created_at >= ? GROUP BY model ORDER BY (input_tokens + output_tokens) DESC` + ).all(agentName, workspaceId, since) as Array<{ model: string; input_tokens: number; output_tokens: number; request_count: number }>; + + const total = byModel.reduce((acc, r) => ({ + input_tokens: acc.input_tokens + r.input_tokens, + output_tokens: acc.output_tokens + r.output_tokens, + requests: acc.requests + r.request_count, + }), { input_tokens: 0, output_tokens: 0, requests: 0 }); + + return { + by_model: byModel, + total, + }; + } catch { + // token_usage table may not exist + return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 } }; + } +} From d59b2e70a14f1d6a3e055834f05754dd96620491 Mon Sep 17 00:00:00 2001 From: fulgore Date: Thu, 5 Mar 2026 13:57:15 +1000 Subject: [PATCH 03/20] 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 04/20] 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 05/20] 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 06/20] 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) => (
)}
-