Nyk commited on
Commit
7f29d16
·
2 Parent(s): 1a8fd6dfd791da

chore: resolve merge conflict with main for PR #180

Browse files
.env.example CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  # === Authentication ===
2
  # Admin user seeded on first run (only if no users exist in DB)
3
  AUTH_USER=admin
@@ -65,7 +69,7 @@ NEXT_PUBLIC_GATEWAY_PROTOCOL=
65
  NEXT_PUBLIC_GATEWAY_URL=
66
  # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
67
  # Gateway client id used in websocket handshake (role=operator UI client).
68
- NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui
69
 
70
  # === Data Paths (all optional, defaults to .data/ in project root) ===
71
  # MISSION_CONTROL_DATA_DIR=.data
 
1
+ # === Server Port ===
2
+ # Port the Next.js server listens on (dev and production)
3
+ # PORT=3000
4
+
5
  # === Authentication ===
6
  # Admin user seeded on first run (only if no users exist in DB)
7
  AUTH_USER=admin
 
69
  NEXT_PUBLIC_GATEWAY_URL=
70
  # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token
71
  # Gateway client id used in websocket handshake (role=operator UI client).
72
+ NEXT_PUBLIC_GATEWAY_CLIENT_ID=openclaw-control-ui
73
 
74
  # === Data Paths (all optional, defaults to .data/ in project root) ===
75
  # MISSION_CONTROL_DATA_DIR=.data
Dockerfile CHANGED
@@ -32,9 +32,9 @@ COPY --from=build /app/src/lib/schema.sql ./src/lib/schema.sql
32
  RUN mkdir -p .data && chown nextjs:nodejs .data
33
  RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
34
  USER nextjs
35
- EXPOSE 3000
36
  ENV PORT=3000
 
37
  ENV HOSTNAME=0.0.0.0
38
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
39
- CMD curl -f http://localhost:3000/login || exit 1
40
  CMD ["node", "server.js"]
 
32
  RUN mkdir -p .data && chown nextjs:nodejs .data
33
  RUN apt-get update && apt-get install -y curl --no-install-recommends && rm -rf /var/lib/apt/lists/*
34
  USER nextjs
 
35
  ENV PORT=3000
36
+ EXPOSE 3000
37
  ENV HOSTNAME=0.0.0.0
38
  HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
39
+ CMD curl -f http://localhost:${PORT:-3000}/login || exit 1
40
  CMD ["node", "server.js"]
README.md CHANGED
@@ -113,6 +113,14 @@ Inter-agent communication via the comms API. Agents can send messages to each ot
113
  ### Integrations
114
  Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
115
 
 
 
 
 
 
 
 
 
116
  ### Update Checker
117
  Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
118
 
@@ -286,6 +294,20 @@ All endpoints require authentication unless noted. Full reference below.
286
 
287
  </details>
288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  <details>
290
  <summary><strong>Direct CLI</strong></summary>
291
 
@@ -427,6 +449,24 @@ pnpm test:e2e # Playwright E2E
427
  pnpm quality:gate # All checks
428
  ```
429
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
430
  ## Roadmap
431
 
432
  See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped.
 
113
  ### Integrations
114
  Outbound webhooks with delivery history, configurable alert rules with cooldowns, and multi-gateway connection management. Optional 1Password CLI integration for secret management.
115
 
116
+ ### Workspace Management
117
+ Workspaces (tenant instances) are created and managed through the **Super Admin** panel, accessible from the sidebar under **Admin > Super Admin**. From there, admins can:
118
+ - **Create** new client instances (slug, display name, Linux user, gateway port, plan tier)
119
+ - **Monitor** provisioning jobs and their step-by-step progress
120
+ - **Decommission** tenants with optional cleanup of state directories and Linux users
121
+
122
+ 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.
123
+
124
  ### Update Checker
125
  Automatic GitHub release check notifies you when a new version is available, displayed as a banner in the dashboard.
126
 
 
294
 
295
  </details>
296
 
297
+ <details>
298
+ <summary><strong>Super Admin (Workspace/Tenant Management)</strong></summary>
299
+
300
+ | Method | Path | Role | Description |
301
+ |--------|------|------|-------------|
302
+ | `GET` | `/api/super/tenants` | admin | List all tenants with latest provisioning status |
303
+ | `POST` | `/api/super/tenants` | admin | Create tenant and queue bootstrap job |
304
+ | `POST` | `/api/super/tenants/[id]/decommission` | admin | Queue tenant decommission job |
305
+ | `GET` | `/api/super/provision-jobs` | admin | List provisioning jobs (filter: `?tenant_id=`, `?status=`) |
306
+ | `POST` | `/api/super/provision-jobs` | admin | Queue additional job for existing tenant |
307
+ | `POST` | `/api/super/provision-jobs/[id]/action` | admin | Approve, reject, or cancel a provisioning job |
308
+
309
+ </details>
310
+
311
  <details>
312
  <summary><strong>Direct CLI</strong></summary>
313
 
 
449
  pnpm quality:gate # All checks
450
  ```
451
 
452
+ ## Agent Diagnostics Contract
453
+
454
+ `GET /api/agents/{id}/diagnostics` is self-scoped by default.
455
+
456
+ - Self access:
457
+ - Session user where `username === agent.name`, or
458
+ - API-key request with `x-agent-name` matching `{id}` agent name
459
+ - Cross-agent access:
460
+ - Allowed only with explicit `?privileged=1` and admin auth
461
+ - Query validation:
462
+ - `hours` must be an integer between `1` and `720`
463
+ - `section` must be a comma-separated subset of `summary,tasks,errors,activity,trends,tokens`
464
+
465
+ Trend alerts in the `trends.alerts` response are derived from current-vs-previous window comparisons:
466
+
467
+ - `warning`: error spikes or severe activity drop
468
+ - `info`: throughput drops or potential stall patterns
469
+
470
  ## Roadmap
471
 
472
  See [open issues](https://github.com/builderz-labs/mission-control/issues) for planned work and the [v1.0.0 release notes](https://github.com/builderz-labs/mission-control/releases/tag/v1.0.0) for what shipped.
docker-compose.yml CHANGED
@@ -3,7 +3,9 @@ services:
3
  build: .
4
  container_name: mission-control
5
  ports:
6
- - "${MC_PORT:-3000}:3000"
 
 
7
  env_file:
8
  - path: .env
9
  required: false
 
3
  build: .
4
  container_name: mission-control
5
  ports:
6
+ - "${MC_PORT:-3000}:${PORT:-3000}"
7
+ environment:
8
+ - PORT=${PORT:-3000}
9
  env_file:
10
  - path: .env
11
  required: false
openapi.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -3,9 +3,9 @@
3
  "version": "1.3.0",
4
  "description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
5
  "scripts": {
6
- "dev": "next dev --hostname 127.0.0.1",
7
  "build": "next build",
8
- "start": "next start --hostname 0.0.0.0 --port 3005",
9
  "lint": "eslint .",
10
  "typecheck": "tsc --noEmit",
11
  "test": "vitest run",
 
3
  "version": "1.3.0",
4
  "description": "OpenClaw Mission Control — open-source agent orchestration dashboard",
5
  "scripts": {
6
+ "dev": "next dev --hostname 127.0.0.1 --port ${PORT:-3000}",
7
  "build": "next build",
8
+ "start": "next start --hostname 0.0.0.0 --port ${PORT:-3000}",
9
  "lint": "eslint .",
10
  "typecheck": "tsc --noEmit",
11
  "test": "vitest run",
playwright.config.ts CHANGED
@@ -18,9 +18,18 @@ export default defineConfig({
18
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
19
  ],
20
  webServer: {
21
- command: 'pnpm start',
22
  url: 'http://127.0.0.1:3005',
23
  reuseExistingServer: true,
24
- timeout: 30_000,
 
 
 
 
 
 
 
 
 
25
  }
26
  })
 
18
  { name: 'chromium', use: { ...devices['Desktop Chrome'] } }
19
  ],
20
  webServer: {
21
+ command: 'node .next/standalone/server.js',
22
  url: 'http://127.0.0.1:3005',
23
  reuseExistingServer: true,
24
+ timeout: 120_000,
25
+ env: {
26
+ ...process.env,
27
+ HOSTNAME: process.env.HOSTNAME || '127.0.0.1',
28
+ PORT: process.env.PORT || '3005',
29
+ MC_DISABLE_RATE_LIMIT: process.env.MC_DISABLE_RATE_LIMIT || '1',
30
+ API_KEY: process.env.API_KEY || 'test-api-key-e2e-12345',
31
+ AUTH_USER: process.env.AUTH_USER || 'testadmin',
32
+ AUTH_PASS: process.env.AUTH_PASS || 'testpass1234!',
33
+ },
34
  }
35
  })
src/app/[[...panel]]/page.tsx CHANGED
@@ -1,7 +1,7 @@
1
  'use client'
2
 
3
  import { useEffect, useState } from 'react'
4
- import { usePathname } from 'next/navigation'
5
  import { NavRail } from '@/components/layout/nav-rail'
6
  import { HeaderBar } from '@/components/layout/header-bar'
7
  import { LiveFeed } from '@/components/layout/live-feed'
@@ -42,6 +42,7 @@ import { useServerEvents } from '@/lib/use-server-events'
42
  import { useMissionControl } from '@/store'
43
 
44
  export default function Home() {
 
45
  const { connect } = useWebSocket()
46
  const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
47
 
@@ -62,7 +63,13 @@ export default function Home() {
62
 
63
  // Fetch current user
64
  fetch('/api/auth/me')
65
- .then(res => res.ok ? res.json() : null)
 
 
 
 
 
 
66
  .then(data => { if (data?.user) setCurrentUser(data.user) })
67
  .catch(() => {})
68
 
@@ -120,7 +127,7 @@ export default function Home() {
120
  const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
121
  connect(wsUrl, wsToken)
122
  })
123
- }, [connect, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
124
 
125
  if (!isClient) {
126
  return (
 
1
  'use client'
2
 
3
  import { useEffect, useState } from 'react'
4
+ import { usePathname, useRouter } from 'next/navigation'
5
  import { NavRail } from '@/components/layout/nav-rail'
6
  import { HeaderBar } from '@/components/layout/header-bar'
7
  import { LiveFeed } from '@/components/layout/live-feed'
 
42
  import { useMissionControl } from '@/store'
43
 
44
  export default function Home() {
45
+ const router = useRouter()
46
  const { connect } = useWebSocket()
47
  const { activeTab, setActiveTab, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable, liveFeedOpen, toggleLiveFeed } = useMissionControl()
48
 
 
63
 
64
  // Fetch current user
65
  fetch('/api/auth/me')
66
+ .then(async (res) => {
67
+ if (res.ok) return res.json()
68
+ if (res.status === 401) {
69
+ router.replace(`/login?next=${encodeURIComponent(pathname)}`)
70
+ }
71
+ return null
72
+ })
73
  .then(data => { if (data?.user) setCurrentUser(data.user) })
74
  .catch(() => {})
75
 
 
127
  const wsUrl = explicitWsUrl || `${gatewayProto}://${gatewayHost}:${gatewayPort}`
128
  connect(wsUrl, wsToken)
129
  })
130
+ }, [connect, pathname, router, setCurrentUser, setDashboardMode, setGatewayAvailable, setSubscription, setUpdateAvailable])
131
 
132
  if (!isClient) {
133
  return (
src/app/api/agents/[id]/diagnostics/route.ts ADDED
@@ -0,0 +1,343 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from 'next/server';
2
+ import { getDatabase } from '@/lib/db';
3
+ import { requireRole } from '@/lib/auth';
4
+ import { logger } from '@/lib/logger';
5
+
6
+ const ALLOWED_SECTIONS = ['summary', 'tasks', 'errors', 'activity', 'trends', 'tokens'] as const;
7
+ type DiagnosticsSection = (typeof ALLOWED_SECTIONS)[number];
8
+
9
+ function parseHoursParam(raw: string | null): { value?: number; error?: string } {
10
+ if (raw === null) return { value: 24 };
11
+ const parsed = Number(raw);
12
+ if (!Number.isInteger(parsed)) {
13
+ return { error: 'hours must be an integer between 1 and 720' };
14
+ }
15
+ if (parsed < 1 || parsed > 720) {
16
+ return { error: 'hours must be between 1 and 720' };
17
+ }
18
+ return { value: parsed };
19
+ }
20
+
21
+ function parseSectionsParam(raw: string | null): { value?: Set<DiagnosticsSection>; error?: string } {
22
+ if (!raw || raw.trim().length === 0) {
23
+ return { value: new Set(ALLOWED_SECTIONS) };
24
+ }
25
+
26
+ const requested = raw
27
+ .split(',')
28
+ .map((section) => section.trim())
29
+ .filter(Boolean);
30
+
31
+ if (requested.length === 0) {
32
+ return { error: 'section must include at least one valid value' };
33
+ }
34
+
35
+ const invalid = requested.filter((section) => !ALLOWED_SECTIONS.includes(section as DiagnosticsSection));
36
+ if (invalid.length > 0) {
37
+ return { error: `Invalid section value(s): ${invalid.join(', ')}` };
38
+ }
39
+
40
+ return { value: new Set(requested as DiagnosticsSection[]) };
41
+ }
42
+
43
+ /**
44
+ * GET /api/agents/[id]/diagnostics - Agent Self-Diagnostics API
45
+ *
46
+ * Provides an agent with its own performance metrics, error analysis,
47
+ * and trend data so it can self-optimize.
48
+ *
49
+ * Query params:
50
+ * hours - Time window in hours (default: 24, max: 720 = 30 days)
51
+ * section - Comma-separated sections to include (default: all)
52
+ * Options: summary, tasks, errors, activity, trends, tokens
53
+ *
54
+ * Response includes:
55
+ * summary - High-level KPIs (throughput, error rate, activity count)
56
+ * tasks - Task completion breakdown by status and priority
57
+ * errors - Error frequency, types, and recent error details
58
+ * activity - Activity breakdown by type with hourly timeline
59
+ * trends - Multi-period comparison for trend detection
60
+ * tokens - Token usage by model with cost estimates
61
+ */
62
+ export async function GET(
63
+ request: NextRequest,
64
+ { params }: { params: Promise<{ id: string }> }
65
+ ) {
66
+ const auth = requireRole(request, 'viewer');
67
+ if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status });
68
+
69
+ try {
70
+ const db = getDatabase();
71
+ const resolvedParams = await params;
72
+ const agentId = resolvedParams.id;
73
+ const workspaceId = auth.user.workspace_id ?? 1;
74
+
75
+ // Resolve agent by ID or name
76
+ let agent: any;
77
+ if (/^\d+$/.test(agentId)) {
78
+ agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE id = ? AND workspace_id = ?').get(Number(agentId), workspaceId);
79
+ } else {
80
+ agent = db.prepare('SELECT id, name, role, status, last_seen, created_at FROM agents WHERE name = ? AND workspace_id = ?').get(agentId, workspaceId);
81
+ }
82
+
83
+ if (!agent) {
84
+ return NextResponse.json({ error: 'Agent not found' }, { status: 404 });
85
+ }
86
+
87
+ const { searchParams } = new URL(request.url);
88
+ const requesterAgentName = (request.headers.get('x-agent-name') || '').trim();
89
+ const privileged = searchParams.get('privileged') === '1';
90
+ const isSelfRequest = (requesterAgentName || auth.user.username) === agent.name;
91
+
92
+ // Self-only by default. Cross-agent access requires explicit privileged override.
93
+ if (!isSelfRequest && !(privileged && auth.user.role === 'admin')) {
94
+ return NextResponse.json(
95
+ { error: 'Diagnostics are self-scoped. Use privileged=1 with admin role for cross-agent access.' },
96
+ { status: 403 }
97
+ );
98
+ }
99
+
100
+ const parsedHours = parseHoursParam(searchParams.get('hours'));
101
+ if (parsedHours.error) {
102
+ return NextResponse.json({ error: parsedHours.error }, { status: 400 });
103
+ }
104
+
105
+ const parsedSections = parseSectionsParam(searchParams.get('section'));
106
+ if (parsedSections.error) {
107
+ return NextResponse.json({ error: parsedSections.error }, { status: 400 });
108
+ }
109
+
110
+ const hours = parsedHours.value as number;
111
+ const sections = parsedSections.value as Set<DiagnosticsSection>;
112
+
113
+ const now = Math.floor(Date.now() / 1000);
114
+ const since = now - hours * 3600;
115
+
116
+ const result: Record<string, any> = {
117
+ agent: { id: agent.id, name: agent.name, role: agent.role, status: agent.status },
118
+ timeframe: { hours, since, until: now },
119
+ };
120
+
121
+ if (sections.has('summary')) {
122
+ result.summary = buildSummary(db, agent.name, workspaceId, since);
123
+ }
124
+
125
+ if (sections.has('tasks')) {
126
+ result.tasks = buildTaskMetrics(db, agent.name, workspaceId, since);
127
+ }
128
+
129
+ if (sections.has('errors')) {
130
+ result.errors = buildErrorAnalysis(db, agent.name, workspaceId, since);
131
+ }
132
+
133
+ if (sections.has('activity')) {
134
+ result.activity = buildActivityBreakdown(db, agent.name, workspaceId, since);
135
+ }
136
+
137
+ if (sections.has('trends')) {
138
+ result.trends = buildTrends(db, agent.name, workspaceId, hours);
139
+ }
140
+
141
+ if (sections.has('tokens')) {
142
+ result.tokens = buildTokenMetrics(db, agent.name, workspaceId, since);
143
+ }
144
+
145
+ return NextResponse.json(result);
146
+ } catch (error) {
147
+ logger.error({ err: error }, 'GET /api/agents/[id]/diagnostics error');
148
+ return NextResponse.json({ error: 'Failed to fetch diagnostics' }, { status: 500 });
149
+ }
150
+ }
151
+
152
+ /** High-level KPIs */
153
+ function buildSummary(db: any, agentName: string, workspaceId: number, since: number) {
154
+ const tasksDone = (db.prepare(
155
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ?`
156
+ ).get(agentName, workspaceId, since) as any).c;
157
+
158
+ const tasksTotal = (db.prepare(
159
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ?`
160
+ ).get(agentName, workspaceId) as any).c;
161
+
162
+ const activityCount = (db.prepare(
163
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ?`
164
+ ).get(agentName, workspaceId, since) as any).c;
165
+
166
+ const errorCount = (db.prepare(
167
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND type LIKE '%error%'`
168
+ ).get(agentName, workspaceId, since) as any).c;
169
+
170
+ const errorRate = activityCount > 0 ? Math.round((errorCount / activityCount) * 10000) / 100 : 0;
171
+
172
+ return {
173
+ tasks_completed: tasksDone,
174
+ tasks_total: tasksTotal,
175
+ activity_count: activityCount,
176
+ error_count: errorCount,
177
+ error_rate_percent: errorRate,
178
+ };
179
+ }
180
+
181
+ /** Task completion breakdown */
182
+ function buildTaskMetrics(db: any, agentName: string, workspaceId: number, since: number) {
183
+ const byStatus = db.prepare(
184
+ `SELECT status, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY status`
185
+ ).all(agentName, workspaceId) as Array<{ status: string; count: number }>;
186
+
187
+ const byPriority = db.prepare(
188
+ `SELECT priority, COUNT(*) as count FROM tasks WHERE assigned_to = ? AND workspace_id = ? GROUP BY priority`
189
+ ).all(agentName, workspaceId) as Array<{ priority: string; count: number }>;
190
+
191
+ const recentCompleted = db.prepare(
192
+ `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`
193
+ ).all(agentName, workspaceId, since) as any[];
194
+
195
+ // Estimate throughput: tasks completed per day in the window
196
+ const windowDays = Math.max((Math.floor(Date.now() / 1000) - since) / 86400, 1);
197
+ const completedInWindow = recentCompleted.length;
198
+ const throughputPerDay = Math.round((completedInWindow / windowDays) * 100) / 100;
199
+
200
+ return {
201
+ by_status: Object.fromEntries(byStatus.map(r => [r.status, r.count])),
202
+ by_priority: Object.fromEntries(byPriority.map(r => [r.priority, r.count])),
203
+ recent_completed: recentCompleted,
204
+ throughput_per_day: throughputPerDay,
205
+ };
206
+ }
207
+
208
+ /** Error frequency and analysis */
209
+ function buildErrorAnalysis(db: any, agentName: string, workspaceId: number, since: number) {
210
+ const errorActivities = db.prepare(
211
+ `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`
212
+ ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
213
+
214
+ const recentErrors = db.prepare(
215
+ `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`
216
+ ).all(agentName, workspaceId, since) as any[];
217
+
218
+ return {
219
+ by_type: errorActivities,
220
+ total: errorActivities.reduce((sum, e) => sum + e.count, 0),
221
+ recent: recentErrors.map(e => ({
222
+ ...e,
223
+ data: e.data ? JSON.parse(e.data) : null,
224
+ })),
225
+ };
226
+ }
227
+
228
+ /** Activity breakdown with hourly timeline */
229
+ function buildActivityBreakdown(db: any, agentName: string, workspaceId: number, since: number) {
230
+ const byType = db.prepare(
231
+ `SELECT type, COUNT(*) as count FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? GROUP BY type ORDER BY count DESC`
232
+ ).all(agentName, workspaceId, since) as Array<{ type: string; count: number }>;
233
+
234
+ const timeline = db.prepare(
235
+ `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`
236
+ ).all(agentName, workspaceId, since) as Array<{ hour_bucket: number; count: number }>;
237
+
238
+ return {
239
+ by_type: byType,
240
+ timeline: timeline.map(t => ({
241
+ timestamp: t.hour_bucket,
242
+ hour: new Date(t.hour_bucket * 1000).toISOString(),
243
+ count: t.count,
244
+ })),
245
+ };
246
+ }
247
+
248
+ /** Multi-period trend comparison for anomaly/trend detection */
249
+ function buildTrends(db: any, agentName: string, workspaceId: number, hours: number) {
250
+ const now = Math.floor(Date.now() / 1000);
251
+
252
+ // Compare current period vs previous period of same length
253
+ const currentSince = now - hours * 3600;
254
+ const previousSince = currentSince - hours * 3600;
255
+
256
+ const periodMetrics = (since: number, until: number) => {
257
+ const activities = (db.prepare(
258
+ `SELECT COUNT(*) as c FROM activities WHERE actor = ? AND workspace_id = ? AND created_at >= ? AND created_at < ?`
259
+ ).get(agentName, workspaceId, since, until) as any).c;
260
+
261
+ const errors = (db.prepare(
262
+ `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%')`
263
+ ).get(agentName, workspaceId, since, until) as any).c;
264
+
265
+ const tasksCompleted = (db.prepare(
266
+ `SELECT COUNT(*) as c FROM tasks WHERE assigned_to = ? AND workspace_id = ? AND status = 'done' AND updated_at >= ? AND updated_at < ?`
267
+ ).get(agentName, workspaceId, since, until) as any).c;
268
+
269
+ return { activities, errors, tasks_completed: tasksCompleted };
270
+ };
271
+
272
+ const current = periodMetrics(currentSince, now);
273
+ const previous = periodMetrics(previousSince, currentSince);
274
+
275
+ const pctChange = (cur: number, prev: number) => {
276
+ if (prev === 0) return cur > 0 ? 100 : 0;
277
+ return Math.round(((cur - prev) / prev) * 10000) / 100;
278
+ };
279
+
280
+ return {
281
+ current_period: { since: currentSince, until: now, ...current },
282
+ previous_period: { since: previousSince, until: currentSince, ...previous },
283
+ change: {
284
+ activities_pct: pctChange(current.activities, previous.activities),
285
+ errors_pct: pctChange(current.errors, previous.errors),
286
+ tasks_completed_pct: pctChange(current.tasks_completed, previous.tasks_completed),
287
+ },
288
+ alerts: buildTrendAlerts(current, previous),
289
+ };
290
+ }
291
+
292
+ /** Generate automatic alerts from trend data */
293
+ function buildTrendAlerts(current: { activities: number; errors: number; tasks_completed: number }, previous: { activities: number; errors: number; tasks_completed: number }) {
294
+ const alerts: Array<{ level: string; message: string }> = [];
295
+
296
+ // Error rate spike
297
+ if (current.errors > 0 && previous.errors > 0) {
298
+ const errorIncrease = (current.errors - previous.errors) / previous.errors;
299
+ if (errorIncrease > 0.5) {
300
+ alerts.push({ level: 'warning', message: `Error count increased ${Math.round(errorIncrease * 100)}% vs previous period` });
301
+ }
302
+ } else if (current.errors > 3 && previous.errors === 0) {
303
+ alerts.push({ level: 'warning', message: `New error pattern: ${current.errors} errors (none in previous period)` });
304
+ }
305
+
306
+ // Throughput drop
307
+ if (previous.tasks_completed > 0 && current.tasks_completed === 0) {
308
+ alerts.push({ level: 'info', message: 'No tasks completed in current period (possible stall)' });
309
+ } else if (previous.tasks_completed > 2 && current.tasks_completed < previous.tasks_completed * 0.5) {
310
+ alerts.push({ level: 'info', message: `Task throughput dropped ${Math.round((1 - current.tasks_completed / previous.tasks_completed) * 100)}%` });
311
+ }
312
+
313
+ // Activity drop (possible offline)
314
+ if (previous.activities > 5 && current.activities < previous.activities * 0.25) {
315
+ alerts.push({ level: 'warning', message: `Activity dropped ${Math.round((1 - current.activities / previous.activities) * 100)}% — agent may be stalled` });
316
+ }
317
+
318
+ return alerts;
319
+ }
320
+
321
+ /** Token usage by model */
322
+ function buildTokenMetrics(db: any, agentName: string, workspaceId: number, since: number) {
323
+ try {
324
+ // session_id on token_usage may store agent name or session key
325
+ const byModel = db.prepare(
326
+ `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`
327
+ ).all(agentName, workspaceId, since) as Array<{ model: string; input_tokens: number; output_tokens: number; request_count: number }>;
328
+
329
+ const total = byModel.reduce((acc, r) => ({
330
+ input_tokens: acc.input_tokens + r.input_tokens,
331
+ output_tokens: acc.output_tokens + r.output_tokens,
332
+ requests: acc.requests + r.request_count,
333
+ }), { input_tokens: 0, output_tokens: 0, requests: 0 });
334
+
335
+ return {
336
+ by_model: byModel,
337
+ total,
338
+ };
339
+ } catch {
340
+ // token_usage table may not exist
341
+ return { by_model: [], total: { input_tokens: 0, output_tokens: 0, requests: 0 } };
342
+ }
343
+ }
src/app/api/agents/route.ts CHANGED
@@ -8,6 +8,10 @@ import { requireRole } from '@/lib/auth';
8
  import { mutationLimiter } from '@/lib/rate-limit';
9
  import { logger } from '@/lib/logger';
10
  import { validateBody, createAgentSchema } from '@/lib/validation';
 
 
 
 
11
 
12
  /**
13
  * GET /api/agents - List all agents with optional filtering
@@ -123,6 +127,7 @@ export async function POST(request: NextRequest) {
123
 
124
  const {
125
  name,
 
126
  role,
127
  session_key,
128
  soul_content,
@@ -130,9 +135,16 @@ export async function POST(request: NextRequest) {
130
  config = {},
131
  template,
132
  gateway_config,
133
- write_to_gateway
 
 
134
  } = body;
135
 
 
 
 
 
 
136
  // Resolve template if specified
137
  let finalRole = role;
138
  let finalConfig: Record<string, any> = { ...config };
@@ -158,6 +170,32 @@ export async function POST(request: NextRequest) {
158
  if (existingAgent) {
159
  return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
160
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  const now = Math.floor(Date.now() / 1000);
163
 
@@ -215,7 +253,6 @@ export async function POST(request: NextRequest) {
215
  // Write to gateway config if requested
216
  if (write_to_gateway && finalConfig) {
217
  try {
218
- const openclawId = (name || 'agent').toLowerCase().replace(/\s+/g, '-');
219
  await writeAgentToConfig({
220
  id: openclawId,
221
  name,
 
8
  import { mutationLimiter } from '@/lib/rate-limit';
9
  import { logger } from '@/lib/logger';
10
  import { validateBody, createAgentSchema } from '@/lib/validation';
11
+ import { runOpenClaw } from '@/lib/command';
12
+ import { config as appConfig } from '@/lib/config';
13
+ import { resolveWithin } from '@/lib/paths';
14
+ import path from 'node:path';
15
 
16
  /**
17
  * GET /api/agents - List all agents with optional filtering
 
127
 
128
  const {
129
  name,
130
+ openclaw_id,
131
  role,
132
  session_key,
133
  soul_content,
 
135
  config = {},
136
  template,
137
  gateway_config,
138
+ write_to_gateway,
139
+ provision_openclaw_workspace,
140
+ openclaw_workspace_path
141
  } = body;
142
 
143
+ const openclawId = (openclaw_id || name || 'agent')
144
+ .toLowerCase()
145
+ .replace(/[^a-z0-9]+/g, '-')
146
+ .replace(/^-|-$/g, '');
147
+
148
  // Resolve template if specified
149
  let finalRole = role;
150
  let finalConfig: Record<string, any> = { ...config };
 
170
  if (existingAgent) {
171
  return NextResponse.json({ error: 'Agent name already exists' }, { status: 409 });
172
  }
173
+
174
+ if (provision_openclaw_workspace) {
175
+ if (!appConfig.openclawStateDir) {
176
+ return NextResponse.json(
177
+ { error: 'OPENCLAW_STATE_DIR is not configured; cannot provision OpenClaw workspace' },
178
+ { status: 500 }
179
+ );
180
+ }
181
+
182
+ const workspacePath = openclaw_workspace_path
183
+ ? path.resolve(openclaw_workspace_path)
184
+ : resolveWithin(appConfig.openclawStateDir, path.join('workspaces', openclawId));
185
+
186
+ try {
187
+ await runOpenClaw(
188
+ ['agents', 'add', openclawId, '--name', name, '--workspace', workspacePath, '--non-interactive'],
189
+ { timeoutMs: 20000 }
190
+ );
191
+ } catch (provisionError: any) {
192
+ logger.error({ err: provisionError, openclawId, workspacePath }, 'OpenClaw workspace provisioning failed');
193
+ return NextResponse.json(
194
+ { error: provisionError?.message || 'Failed to provision OpenClaw agent workspace' },
195
+ { status: 502 }
196
+ );
197
+ }
198
+ }
199
 
200
  const now = Math.floor(Date.now() / 1000);
201
 
 
253
  // Write to gateway config if requested
254
  if (write_to_gateway && finalConfig) {
255
  try {
 
256
  await writeAgentToConfig({
257
  id: openclawId,
258
  name,
src/app/api/cleanup/route.ts CHANGED
@@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
  import { heavyLimiter } from '@/lib/rate-limit'
 
6
 
7
  interface CleanupResult {
8
  table: string
@@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
59
  preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
62
  return NextResponse.json({ retention: config.retention, preview })
63
  }
64
 
@@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
137
  }
138
  }
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  if (!dryRun && totalDeleted > 0) {
141
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
142
  logAuditEvent({
 
3
  import { getDatabase, logAuditEvent } from '@/lib/db'
4
  import { config } from '@/lib/config'
5
  import { heavyLimiter } from '@/lib/rate-limit'
6
+ import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
7
 
8
  interface CleanupResult {
9
  table: string
 
60
  preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
61
  }
62
 
63
+ if (ret.gatewaySessions > 0) {
64
+ preview.push({
65
+ table: 'Gateway Session Store',
66
+ retention_days: ret.gatewaySessions,
67
+ stale_count: countStaleGatewaySessions(ret.gatewaySessions),
68
+ note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
69
+ })
70
+ } else {
71
+ preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
72
+ }
73
+
74
  return NextResponse.json({ retention: config.retention, preview })
75
  }
76
 
 
149
  }
150
  }
151
 
152
+ if (ret.gatewaySessions > 0) {
153
+ const sessionPrune = dryRun
154
+ ? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
155
+ : pruneGatewaySessionsOlderThan(ret.gatewaySessions)
156
+ results.push({
157
+ table: 'Gateway Session Store',
158
+ deleted: sessionPrune.deleted,
159
+ cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
160
+ retention_days: ret.gatewaySessions,
161
+ })
162
+ totalDeleted += sessionPrune.deleted
163
+ }
164
+
165
  if (!dryRun && totalDeleted > 0) {
166
  const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
167
  logAuditEvent({
src/app/api/memory/route.ts CHANGED
@@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
9
  import { logger } from '@/lib/logger'
10
 
11
  const MEMORY_PATH = config.memoryDir
 
12
 
13
  // Ensure memory directory exists on startup
14
  if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
@@ -24,6 +25,16 @@ interface MemoryFile {
24
  children?: MemoryFile[]
25
  }
26
 
 
 
 
 
 
 
 
 
 
 
27
  function isWithinBase(base: string, candidate: string): boolean {
28
  if (candidate === base) return true
29
  return candidate.startsWith(base + sep)
@@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
137
  if (!MEMORY_PATH) {
138
  return NextResponse.json({ tree: [] })
139
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  const tree = await buildFileTree(MEMORY_PATH)
141
  return NextResponse.json({ tree })
142
  }
143
 
144
  if (action === 'content' && path) {
145
  // Return file content
 
 
 
146
  if (!MEMORY_PATH) {
147
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
148
  }
@@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
227
  }
228
  }
229
 
230
- await searchDirectory(MEMORY_PATH)
 
 
 
 
 
 
 
 
 
231
 
232
  return NextResponse.json({
233
  query,
@@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
256
  if (!path) {
257
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
258
  }
 
 
 
259
 
260
  if (!MEMORY_PATH) {
261
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
@@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
316
  if (!path) {
317
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
318
  }
 
 
 
319
 
320
  if (!MEMORY_PATH) {
321
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
 
9
  import { logger } from '@/lib/logger'
10
 
11
  const MEMORY_PATH = config.memoryDir
12
+ const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
13
 
14
  // Ensure memory directory exists on startup
15
  if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
 
25
  children?: MemoryFile[]
26
  }
27
 
28
+ function normalizeRelativePath(value: string): string {
29
+ return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
30
+ }
31
+
32
+ function isPathAllowed(relativePath: string): boolean {
33
+ if (!MEMORY_ALLOWED_PREFIXES.length) return true
34
+ const normalized = normalizeRelativePath(relativePath)
35
+ return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
36
+ }
37
+
38
  function isWithinBase(base: string, candidate: string): boolean {
39
  if (candidate === base) return true
40
  return candidate.startsWith(base + sep)
 
148
  if (!MEMORY_PATH) {
149
  return NextResponse.json({ tree: [] })
150
  }
151
+ if (MEMORY_ALLOWED_PREFIXES.length) {
152
+ const tree: MemoryFile[] = []
153
+ for (const prefix of MEMORY_ALLOWED_PREFIXES) {
154
+ const folder = prefix.replace(/\/$/, '')
155
+ const fullPath = join(MEMORY_PATH, folder)
156
+ if (!existsSync(fullPath)) continue
157
+ try {
158
+ const stats = await stat(fullPath)
159
+ if (!stats.isDirectory()) continue
160
+ tree.push({
161
+ path: folder,
162
+ name: folder,
163
+ type: 'directory',
164
+ modified: stats.mtime.getTime(),
165
+ children: await buildFileTree(fullPath, folder),
166
+ })
167
+ } catch {
168
+ // Skip unreadable roots
169
+ }
170
+ }
171
+ return NextResponse.json({ tree })
172
+ }
173
  const tree = await buildFileTree(MEMORY_PATH)
174
  return NextResponse.json({ tree })
175
  }
176
 
177
  if (action === 'content' && path) {
178
  // Return file content
179
+ if (!isPathAllowed(path)) {
180
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
181
+ }
182
  if (!MEMORY_PATH) {
183
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
184
  }
 
263
  }
264
  }
265
 
266
+ if (MEMORY_ALLOWED_PREFIXES.length) {
267
+ for (const prefix of MEMORY_ALLOWED_PREFIXES) {
268
+ const folder = prefix.replace(/\/$/, '')
269
+ const fullPath = join(MEMORY_PATH, folder)
270
+ if (!existsSync(fullPath)) continue
271
+ await searchDirectory(fullPath, folder)
272
+ }
273
+ } else {
274
+ await searchDirectory(MEMORY_PATH)
275
+ }
276
 
277
  return NextResponse.json({
278
  query,
 
301
  if (!path) {
302
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
303
  }
304
+ if (!isPathAllowed(path)) {
305
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
306
+ }
307
 
308
  if (!MEMORY_PATH) {
309
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
 
364
  if (!path) {
365
  return NextResponse.json({ error: 'Path is required' }, { status: 400 })
366
  }
367
+ if (!isPathAllowed(path)) {
368
+ return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
369
+ }
370
 
371
  if (!MEMORY_PATH) {
372
  return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
src/app/api/settings/route.ts CHANGED
@@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
23
  'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
24
  'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
25
  'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
 
26
 
27
  // Gateway
28
  'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
 
23
  'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
24
  'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
25
  'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
26
+ 'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
27
 
28
  // Gateway
29
  'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
src/app/api/status/route.ts CHANGED
@@ -1,5 +1,6 @@
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import net from 'node:net'
 
3
  import { existsSync, statSync } from 'node:fs'
4
  import path from 'node:path'
5
  import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
@@ -195,29 +196,48 @@ async function getSystemStatus(workspaceId: number) {
195
  }
196
 
197
  try {
198
- // System uptime
199
- const { stdout: uptimeOutput } = await runCommand('uptime', ['-s'], {
200
- timeoutMs: 3000
201
- })
202
- const bootTime = new Date(uptimeOutput.trim())
203
- status.uptime = Date.now() - bootTime.getTime()
 
 
 
 
 
 
 
 
 
 
 
204
  } catch (error) {
205
  logger.error({ err: error }, 'Error getting uptime')
206
  }
207
 
208
  try {
209
- // Memory info
210
- const { stdout: memOutput } = await runCommand('free', ['-m'], {
211
- timeoutMs: 3000
212
- })
213
- const memLines = memOutput.split('\n')
214
- const memLine = memLines.find(line => line.startsWith('Mem:'))
215
- if (memLine) {
216
- const parts = memLine.split(/\s+/)
217
- status.memory = {
218
- total: parseInt(parts[1]) || 0,
219
- used: parseInt(parts[2]) || 0,
220
- available: parseInt(parts[6]) || 0
 
 
 
 
 
 
 
 
221
  }
222
  }
223
  } catch (error) {
@@ -414,14 +434,17 @@ async function performHealthCheck() {
414
  })
415
  }
416
 
417
- // Check disk space
418
  try {
419
- const { stdout } = await runCommand('df', ['/', '--output=pcent'], {
420
  timeoutMs: 3000
421
  })
422
  const lines = stdout.trim().split('\n')
423
  const last = lines[lines.length - 1] || ''
424
- const usagePercent = parseInt(last.replace('%', '').trim() || '0')
 
 
 
425
 
426
  health.checks.push({
427
  name: 'Disk Space',
@@ -436,15 +459,21 @@ async function performHealthCheck() {
436
  })
437
  }
438
 
439
- // Check memory usage
440
  try {
441
- const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
442
- const lines = stdout.split('\n')
443
- const memLine = lines.find((line) => line.startsWith('Mem:'))
444
- const parts = (memLine || '').split(/\s+/)
445
- const total = parseInt(parts[1] || '0')
446
- const available = parseInt(parts[6] || '0')
447
- const usagePercent = Math.round(((total - available) / total) * 100)
 
 
 
 
 
 
448
 
449
  health.checks.push({
450
  name: 'Memory Usage',
 
1
  import { NextRequest, NextResponse } from 'next/server'
2
  import net from 'node:net'
3
+ import os from 'node:os'
4
  import { existsSync, statSync } from 'node:fs'
5
  import path from 'node:path'
6
  import { runCommand, runOpenClaw, runClawdbot } from '@/lib/command'
 
196
  }
197
 
198
  try {
199
+ // System uptime (cross-platform)
200
+ if (process.platform === 'darwin') {
201
+ const { stdout } = await runCommand('sysctl', ['-n', 'kern.boottime'], {
202
+ timeoutMs: 3000
203
+ })
204
+ // Output format: { sec = 1234567890, usec = 0 } ...
205
+ const match = stdout.match(/sec\s*=\s*(\d+)/)
206
+ if (match) {
207
+ status.uptime = Date.now() - parseInt(match[1]) * 1000
208
+ }
209
+ } else {
210
+ const { stdout } = await runCommand('uptime', ['-s'], {
211
+ timeoutMs: 3000
212
+ })
213
+ const bootTime = new Date(stdout.trim())
214
+ status.uptime = Date.now() - bootTime.getTime()
215
+ }
216
  } catch (error) {
217
  logger.error({ err: error }, 'Error getting uptime')
218
  }
219
 
220
  try {
221
+ // Memory info (cross-platform)
222
+ if (process.platform === 'darwin') {
223
+ const totalBytes = os.totalmem()
224
+ const freeBytes = os.freemem()
225
+ const totalMB = Math.round(totalBytes / (1024 * 1024))
226
+ const usedMB = Math.round((totalBytes - freeBytes) / (1024 * 1024))
227
+ const availableMB = Math.round(freeBytes / (1024 * 1024))
228
+ status.memory = { total: totalMB, used: usedMB, available: availableMB }
229
+ } else {
230
+ const { stdout: memOutput } = await runCommand('free', ['-m'], {
231
+ timeoutMs: 3000
232
+ })
233
+ const memLine = memOutput.split('\n').find(line => line.startsWith('Mem:'))
234
+ if (memLine) {
235
+ const parts = memLine.split(/\s+/)
236
+ status.memory = {
237
+ total: parseInt(parts[1]) || 0,
238
+ used: parseInt(parts[2]) || 0,
239
+ available: parseInt(parts[6]) || 0
240
+ }
241
  }
242
  }
243
  } catch (error) {
 
434
  })
435
  }
436
 
437
+ // Check disk space (cross-platform: use df -h / and parse capacity column)
438
  try {
439
+ const { stdout } = await runCommand('df', ['-h', '/'], {
440
  timeoutMs: 3000
441
  })
442
  const lines = stdout.trim().split('\n')
443
  const last = lines[lines.length - 1] || ''
444
+ const parts = last.split(/\s+/)
445
+ // On macOS capacity is col 4 ("85%"), on Linux use% is col 4 as well
446
+ const pctField = parts.find(p => p.endsWith('%')) || '0%'
447
+ const usagePercent = parseInt(pctField.replace('%', '') || '0')
448
 
449
  health.checks.push({
450
  name: 'Disk Space',
 
459
  })
460
  }
461
 
462
+ // Check memory usage (cross-platform)
463
  try {
464
+ let usagePercent: number
465
+ if (process.platform === 'darwin') {
466
+ const totalBytes = os.totalmem()
467
+ const freeBytes = os.freemem()
468
+ usagePercent = Math.round(((totalBytes - freeBytes) / totalBytes) * 100)
469
+ } else {
470
+ const { stdout } = await runCommand('free', ['-m'], { timeoutMs: 3000 })
471
+ const memLine = stdout.split('\n').find((line) => line.startsWith('Mem:'))
472
+ const parts = (memLine || '').split(/\s+/)
473
+ const total = parseInt(parts[1] || '0')
474
+ const available = parseInt(parts[6] || '0')
475
+ usagePercent = Math.round(((total - available) / total) * 100)
476
+ }
477
 
478
  health.checks.push({
479
  name: 'Memory Usage',
src/app/api/tasks/[id]/route.ts CHANGED
@@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
  import { resolveMentionRecipients } from '@/lib/mentions';
 
9
 
10
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
11
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@@ -115,7 +116,7 @@ export async function PUT(
115
  const {
116
  title,
117
  description,
118
- status,
119
  priority,
120
  project_id,
121
  assigned_to,
@@ -125,6 +126,12 @@ export async function PUT(
125
  tags,
126
  metadata
127
  } = body;
 
 
 
 
 
 
128
 
129
  const now = Math.floor(Date.now() / 1000);
130
  const descriptionMentionResolution = description !== undefined
@@ -152,15 +159,15 @@ export async function PUT(
152
  fieldsToUpdate.push('description = ?');
153
  updateParams.push(description);
154
  }
155
- if (status !== undefined) {
156
- if (status === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
157
  return NextResponse.json(
158
  { error: 'Aegis approval is required to move task to done.' },
159
  { status: 403 }
160
  )
161
  }
162
  fieldsToUpdate.push('status = ?');
163
- updateParams.push(status);
164
  }
165
  if (priority !== undefined) {
166
  fieldsToUpdate.push('priority = ?');
@@ -240,8 +247,8 @@ export async function PUT(
240
  // Track changes and log activities
241
  const changes: string[] = [];
242
 
243
- if (status && status !== currentTask.status) {
244
- changes.push(`status: ${currentTask.status} → ${status}`);
245
 
246
  // Create notification for status change if assigned
247
  if (currentTask.assigned_to) {
@@ -249,7 +256,7 @@ export async function PUT(
249
  currentTask.assigned_to,
250
  'status_change',
251
  'Task Status Updated',
252
- `Task "${currentTask.title}" status changed to ${status}`,
253
  'task',
254
  taskId,
255
  workspaceId
@@ -322,7 +329,7 @@ export async function PUT(
322
  priority: currentTask.priority,
323
  assigned_to: currentTask.assigned_to
324
  },
325
- newValues: { title, status, priority, assigned_to }
326
  },
327
  workspaceId
328
  );
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, updateTaskSchema } from '@/lib/validation';
8
  import { resolveMentionRecipients } from '@/lib/mentions';
9
+ import { normalizeTaskUpdateStatus } from '@/lib/task-status';
10
 
11
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
12
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
 
116
  const {
117
  title,
118
  description,
119
+ status: requestedStatus,
120
  priority,
121
  project_id,
122
  assigned_to,
 
126
  tags,
127
  metadata
128
  } = body;
129
+ const normalizedStatus = normalizeTaskUpdateStatus({
130
+ currentStatus: currentTask.status,
131
+ requestedStatus,
132
+ assignedTo: assigned_to,
133
+ assignedToProvided: assigned_to !== undefined,
134
+ })
135
 
136
  const now = Math.floor(Date.now() / 1000);
137
  const descriptionMentionResolution = description !== undefined
 
159
  fieldsToUpdate.push('description = ?');
160
  updateParams.push(description);
161
  }
162
+ if (normalizedStatus !== undefined) {
163
+ if (normalizedStatus === 'done' && !hasAegisApproval(db, taskId, workspaceId)) {
164
  return NextResponse.json(
165
  { error: 'Aegis approval is required to move task to done.' },
166
  { status: 403 }
167
  )
168
  }
169
  fieldsToUpdate.push('status = ?');
170
+ updateParams.push(normalizedStatus);
171
  }
172
  if (priority !== undefined) {
173
  fieldsToUpdate.push('priority = ?');
 
247
  // Track changes and log activities
248
  const changes: string[] = [];
249
 
250
+ if (normalizedStatus !== undefined && normalizedStatus !== currentTask.status) {
251
+ changes.push(`status: ${currentTask.status} → ${normalizedStatus}`);
252
 
253
  // Create notification for status change if assigned
254
  if (currentTask.assigned_to) {
 
256
  currentTask.assigned_to,
257
  'status_change',
258
  'Task Status Updated',
259
+ `Task "${currentTask.title}" status changed to ${normalizedStatus}`,
260
  'task',
261
  taskId,
262
  workspaceId
 
329
  priority: currentTask.priority,
330
  assigned_to: currentTask.assigned_to
331
  },
332
+ newValues: { title, status: normalizedStatus ?? currentTask.status, priority, assigned_to }
333
  },
334
  workspaceId
335
  );
src/app/api/tasks/route.ts CHANGED
@@ -6,6 +6,7 @@ import { mutationLimiter } from '@/lib/rate-limit';
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
  import { resolveMentionRecipients } from '@/lib/mentions';
 
9
 
10
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
11
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
@@ -163,7 +164,7 @@ export async function POST(request: NextRequest) {
163
  const {
164
  title,
165
  description,
166
- status = 'inbox',
167
  priority = 'medium',
168
  project_id,
169
  assigned_to,
@@ -173,6 +174,7 @@ export async function POST(request: NextRequest) {
173
  tags = [],
174
  metadata = {}
175
  } = body;
 
176
 
177
  // Check for duplicate title
178
  const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
@@ -212,7 +214,7 @@ export async function POST(request: NextRequest) {
212
  const dbResult = insertStmt.run(
213
  title,
214
  description,
215
- status,
216
  priority,
217
  resolvedProjectId,
218
  row.ticket_counter,
@@ -234,7 +236,7 @@ export async function POST(request: NextRequest) {
234
  // Log activity
235
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
236
  title,
237
- status,
238
  priority,
239
  assigned_to
240
  }, workspaceId);
 
6
  import { logger } from '@/lib/logger';
7
  import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation';
8
  import { resolveMentionRecipients } from '@/lib/mentions';
9
+ import { normalizeTaskCreateStatus } from '@/lib/task-status';
10
 
11
  function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined {
12
  if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined
 
164
  const {
165
  title,
166
  description,
167
+ status,
168
  priority = 'medium',
169
  project_id,
170
  assigned_to,
 
174
  tags = [],
175
  metadata = {}
176
  } = body;
177
+ const normalizedStatus = normalizeTaskCreateStatus(status, assigned_to)
178
 
179
  // Check for duplicate title
180
  const existingTask = db.prepare('SELECT id FROM tasks WHERE title = ? AND workspace_id = ?').get(title, workspaceId);
 
214
  const dbResult = insertStmt.run(
215
  title,
216
  description,
217
+ normalizedStatus,
218
  priority,
219
  resolvedProjectId,
220
  row.ticket_counter,
 
236
  // Log activity
237
  db_helpers.logActivity('task_created', 'task', taskId, created_by, `Created task: ${title}`, {
238
  title,
239
+ status: normalizedStatus,
240
  priority,
241
  assigned_to
242
  }, workspaceId);
src/components/dashboard/dashboard.tsx CHANGED
@@ -522,7 +522,7 @@ export function Dashboard() {
522
  {isLocal ? (
523
  <QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
524
  ) : (
525
- <QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
526
  )}
527
  </div>
528
  </div>
 
522
  {isLocal ? (
523
  <QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
524
  ) : (
525
+ <QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
526
  )}
527
  </div>
528
  </div>
src/components/layout/live-feed.tsx CHANGED
@@ -7,6 +7,7 @@ export function LiveFeed() {
7
  const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
8
  const isLocal = dashboardMode === 'local'
9
  const [expanded, setExpanded] = useState(true)
 
10
 
11
  // Combine logs, activities, and (in local mode) session events into a unified feed
12
  const sessionItems = isLocal
@@ -70,7 +71,7 @@ export function LiveFeed() {
70
  }
71
 
72
  return (
73
- <div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
74
  {/* Header */}
75
  <div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
76
  <div className="flex items-center gap-2">
@@ -80,7 +81,7 @@ export function LiveFeed() {
80
  </div>
81
  <div className="flex items-center gap-0.5">
82
  <button
83
- onClick={() => setExpanded(false)}
84
  className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
85
  title="Collapse feed"
86
  >
 
7
  const { logs, sessions, activities, connection, dashboardMode, toggleLiveFeed } = useMissionControl()
8
  const isLocal = dashboardMode === 'local'
9
  const [expanded, setExpanded] = useState(true)
10
+ const [hasCollapsed, setHasCollapsed] = useState(false)
11
 
12
  // Combine logs, activities, and (in local mode) session events into a unified feed
13
  const sessionItems = isLocal
 
71
  }
72
 
73
  return (
74
+ <div className={`w-72 h-full bg-card border-l border-border flex flex-col shrink-0${hasCollapsed ? ' slide-in-right' : ''}`}>
75
  {/* Header */}
76
  <div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
77
  <div className="flex items-center gap-2">
 
81
  </div>
82
  <div className="flex items-center gap-0.5">
83
  <button
84
+ onClick={() => { setExpanded(false); setHasCollapsed(true) }}
85
  className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
86
  title="Collapse feed"
87
  >
src/components/panels/agent-detail-tabs.tsx CHANGED
@@ -517,7 +517,7 @@ export function MemoryTab({
517
  <div>
518
  <h4 className="text-lg font-medium text-foreground">Working Memory</h4>
519
  <p className="text-xs text-muted-foreground mt-1">
520
- Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files.
521
  </p>
522
  </div>
523
  <div className="flex gap-2">
@@ -543,6 +543,14 @@ export function MemoryTab({
543
  </div>
544
  </div>
545
 
 
 
 
 
 
 
 
 
546
  {/* Memory Content */}
547
  <div>
548
  <label className="block text-sm font-medium text-muted-foreground mb-1">
@@ -852,6 +860,7 @@ export function CreateAgentModal({
852
  dockerNetwork: 'none' as 'none' | 'bridge',
853
  session_key: '',
854
  write_to_gateway: true,
 
855
  })
856
  const [isCreating, setIsCreating] = useState(false)
857
  const [error, setError] = useState<string | null>(null)
@@ -916,10 +925,12 @@ export function CreateAgentModal({
916
  headers: { 'Content-Type': 'application/json' },
917
  body: JSON.stringify({
918
  name: formData.name,
 
919
  role: formData.role,
920
  session_key: formData.session_key || undefined,
921
  template: selectedTemplate || undefined,
922
  write_to_gateway: formData.write_to_gateway,
 
923
  gateway_config: {
924
  model: { primary: primaryModel },
925
  identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
@@ -1199,6 +1210,16 @@ export function CreateAgentModal({
1199
  />
1200
  <span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
1201
  </label>
 
 
 
 
 
 
 
 
 
 
1202
  </div>
1203
  )}
1204
  </div>
 
517
  <div>
518
  <h4 className="text-lg font-medium text-foreground">Working Memory</h4>
519
  <p className="text-xs text-muted-foreground mt-1">
520
+ This is <strong className="text-foreground">agent-level</strong> scratchpad memory (stored as WORKING.md in the database), not the workspace memory folder.
521
  </p>
522
  </div>
523
  <div className="flex gap-2">
 
543
  </div>
544
  </div>
545
 
546
+ {/* Info Banner */}
547
+ <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
548
+ <strong className="text-blue-200">Agent Memory vs Workspace Memory:</strong>{' '}
549
+ This tab edits only this agent&apos;s private working memory (a scratchpad stored in the database).
550
+ To browse or edit all workspace memory files (daily logs, knowledge base, MEMORY.md, etc.), visit the{' '}
551
+ <Link href="/memory" className="text-blue-400 underline hover:text-blue-300">Memory Browser</Link> page.
552
+ </div>
553
+
554
  {/* Memory Content */}
555
  <div>
556
  <label className="block text-sm font-medium text-muted-foreground mb-1">
 
860
  dockerNetwork: 'none' as 'none' | 'bridge',
861
  session_key: '',
862
  write_to_gateway: true,
863
+ provision_openclaw_workspace: true,
864
  })
865
  const [isCreating, setIsCreating] = useState(false)
866
  const [error, setError] = useState<string | null>(null)
 
925
  headers: { 'Content-Type': 'application/json' },
926
  body: JSON.stringify({
927
  name: formData.name,
928
+ openclaw_id: formData.id || undefined,
929
  role: formData.role,
930
  session_key: formData.session_key || undefined,
931
  template: selectedTemplate || undefined,
932
  write_to_gateway: formData.write_to_gateway,
933
+ provision_openclaw_workspace: formData.provision_openclaw_workspace,
934
  gateway_config: {
935
  model: { primary: primaryModel },
936
  identity: { name: formData.name, theme: formData.role, emoji: formData.emoji },
 
1210
  />
1211
  <span className="text-sm text-foreground">Add to gateway config (openclaw.json)</span>
1212
  </label>
1213
+
1214
+ <label className="flex items-center gap-2 cursor-pointer">
1215
+ <input
1216
+ type="checkbox"
1217
+ checked={formData.provision_openclaw_workspace}
1218
+ onChange={(e) => setFormData(prev => ({ ...prev, provision_openclaw_workspace: e.target.checked }))}
1219
+ className="w-4 h-4 rounded border-border"
1220
+ />
1221
+ <span className="text-sm text-foreground">Provision full OpenClaw workspace (`openclaw agents add`)</span>
1222
+ </label>
1223
  </div>
1224
  )}
1225
  </div>
src/components/panels/agent-squad-panel-phase3.tsx CHANGED
@@ -96,7 +96,14 @@ export function AgentSquadPanelPhase3() {
96
  setSyncToast(null)
97
  try {
98
  const response = await fetch('/api/agents/sync', { method: 'POST' })
 
 
 
 
99
  const data = await response.json()
 
 
 
100
  if (!response.ok) throw new Error(data.error || 'Sync failed')
101
  setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
102
  fetchAgents()
@@ -116,7 +123,17 @@ export function AgentSquadPanelPhase3() {
116
  if (agents.length === 0) setLoading(true)
117
 
118
  const response = await fetch('/api/agents')
119
- if (!response.ok) throw new Error('Failed to fetch agents')
 
 
 
 
 
 
 
 
 
 
120
 
121
  const data = await response.json()
122
  setAgents(data.agents || [])
 
96
  setSyncToast(null)
97
  try {
98
  const response = await fetch('/api/agents/sync', { method: 'POST' })
99
+ if (response.status === 401) {
100
+ window.location.assign('/login?next=%2Fagents')
101
+ return
102
+ }
103
  const data = await response.json()
104
+ if (response.status === 403) {
105
+ throw new Error('Admin access required for agent sync')
106
+ }
107
  if (!response.ok) throw new Error(data.error || 'Sync failed')
108
  setSyncToast(`Synced ${data.synced} agents (${data.created} new, ${data.updated} updated)`)
109
  fetchAgents()
 
123
  if (agents.length === 0) setLoading(true)
124
 
125
  const response = await fetch('/api/agents')
126
+ if (response.status === 401) {
127
+ window.location.assign('/login?next=%2Fagents')
128
+ return
129
+ }
130
+ if (response.status === 403) {
131
+ throw new Error('Access denied')
132
+ }
133
+ if (!response.ok) {
134
+ const data = await response.json().catch(() => ({}))
135
+ throw new Error(data.error || 'Failed to fetch agents')
136
+ }
137
 
138
  const data = await response.json()
139
  setAgents(data.agents || [])
src/components/panels/memory-browser-panel.tsx CHANGED
@@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
47
  setMemoryFiles(data.tree || [])
48
 
49
  // Auto-expand some common directories
50
- setExpandedFolders(new Set(['daily', 'knowledge']))
51
  } catch (error) {
52
  log.error('Failed to load file tree:', error)
53
  } finally {
@@ -61,15 +61,14 @@ export function MemoryBrowserPanel() {
61
 
62
  const getFilteredFiles = () => {
63
  if (activeTab === 'all') return memoryFiles
64
-
65
- return memoryFiles.filter(file => {
66
- if (activeTab === 'daily') {
67
- return file.name === 'daily' || file.path.includes('daily/')
68
- }
69
- if (activeTab === 'knowledge') {
70
- return file.name === 'knowledge' || file.path.includes('knowledge/')
71
- }
72
- return true
73
  })
74
  }
75
 
@@ -731,6 +730,8 @@ function CreateFileModal({
731
  onChange={(e) => setFilePath(e.target.value)}
732
  className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
733
  >
 
 
734
  <option value="knowledge/">knowledge/</option>
735
  <option value="daily/">daily/</option>
736
  <option value="logs/">logs/</option>
 
47
  setMemoryFiles(data.tree || [])
48
 
49
  // Auto-expand some common directories
50
+ setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
51
  } catch (error) {
52
  log.error('Failed to load file tree:', error)
53
  } finally {
 
61
 
62
  const getFilteredFiles = () => {
63
  if (activeTab === 'all') return memoryFiles
64
+
65
+ const tabPrefixes = activeTab === 'daily'
66
+ ? ['daily/', 'memory/']
67
+ : ['knowledge/', 'knowledge-base/']
68
+
69
+ return memoryFiles.filter((file) => {
70
+ const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
71
+ return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
 
72
  })
73
  }
74
 
 
730
  onChange={(e) => setFilePath(e.target.value)}
731
  className="w-full px-3 py-2 bg-surface-1 border border-border rounded-md text-foreground focus:outline-none focus:ring-1 focus:ring-primary/50"
732
  >
733
+ <option value="knowledge-base/">knowledge-base/</option>
734
+ <option value="memory/">memory/</option>
735
  <option value="knowledge/">knowledge/</option>
736
  <option value="daily/">daily/</option>
737
  <option value="logs/">logs/</option>
src/components/panels/office-panel.tsx CHANGED
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
4
  import { useMissionControl, Agent } from '@/store'
5
 
6
  type ViewMode = 'office' | 'org-chart'
 
7
 
8
  interface Desk {
9
  agent: Agent
@@ -75,6 +76,7 @@ export function OfficePanel() {
75
  const { agents } = useMissionControl()
76
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
77
  const [viewMode, setViewMode] = useState<ViewMode>('office')
 
78
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
79
  const [loading, setLoading] = useState(true)
80
 
@@ -123,6 +125,64 @@ export function OfficePanel() {
123
  return groups
124
  }, [displayAgents])
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  if (loading && displayAgents.length === 0) {
127
  return (
128
  <div className="flex items-center justify-center h-64">
@@ -237,11 +297,40 @@ export function OfficePanel() {
237
  </div>
238
  ) : (
239
  <div className="space-y-6">
240
- {[...roleGroups.entries()].map(([role, members]) => (
241
- <div key={role} className="bg-card border border-border rounded-xl p-5">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  <div className="flex items-center gap-2 mb-4">
243
  <div className="w-1 h-6 bg-primary rounded-full" />
244
- <h3 className="font-semibold text-foreground">{role}</h3>
245
  <span className="text-xs text-muted-foreground ml-1">({members.length})</span>
246
  </div>
247
  <div className="flex flex-wrap gap-3">
 
4
  import { useMissionControl, Agent } from '@/store'
5
 
6
  type ViewMode = 'office' | 'org-chart'
7
+ type OrgSegmentMode = 'category' | 'role' | 'status'
8
 
9
  interface Desk {
10
  agent: Agent
 
76
  const { agents } = useMissionControl()
77
  const [localAgents, setLocalAgents] = useState<Agent[]>([])
78
  const [viewMode, setViewMode] = useState<ViewMode>('office')
79
+ const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
80
  const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
81
  const [loading, setLoading] = useState(true)
82
 
 
125
  return groups
126
  }, [displayAgents])
127
 
128
+ const categoryGroups = useMemo(() => {
129
+ const groups = new Map<string, Agent[]>()
130
+ const getCategory = (agent: Agent): string => {
131
+ const name = (agent.name || '').toLowerCase()
132
+ if (name.startsWith('habi-')) return 'Habi Lanes'
133
+ if (name.startsWith('ops-')) return 'Ops Automation'
134
+ if (name.includes('canary')) return 'Canary'
135
+ if (name.startsWith('main')) return 'Core'
136
+ if (name.startsWith('remote-')) return 'Remote'
137
+ return 'Other'
138
+ }
139
+
140
+ for (const a of displayAgents) {
141
+ const category = getCategory(a)
142
+ if (!groups.has(category)) groups.set(category, [])
143
+ groups.get(category)!.push(a)
144
+ }
145
+
146
+ const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
147
+ return new Map(
148
+ [...groups.entries()].sort(([a], [b]) => {
149
+ const ai = order.indexOf(a)
150
+ const bi = order.indexOf(b)
151
+ const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
152
+ const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
153
+ if (av !== bv) return av - bv
154
+ return a.localeCompare(b)
155
+ })
156
+ )
157
+ }, [displayAgents])
158
+
159
+ const statusGroups = useMemo(() => {
160
+ const groups = new Map<string, Agent[]>()
161
+ for (const a of displayAgents) {
162
+ const key = statusLabel[a.status] || a.status
163
+ if (!groups.has(key)) groups.set(key, [])
164
+ groups.get(key)!.push(a)
165
+ }
166
+
167
+ const order = ['Working', 'Available', 'Error', 'Away']
168
+ return new Map(
169
+ [...groups.entries()].sort(([a], [b]) => {
170
+ const ai = order.indexOf(a)
171
+ const bi = order.indexOf(b)
172
+ const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
173
+ const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
174
+ if (av !== bv) return av - bv
175
+ return a.localeCompare(b)
176
+ })
177
+ )
178
+ }, [displayAgents])
179
+
180
+ const orgGroups = useMemo(() => {
181
+ if (orgSegmentMode === 'role') return roleGroups
182
+ if (orgSegmentMode === 'status') return statusGroups
183
+ return categoryGroups
184
+ }, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
185
+
186
  if (loading && displayAgents.length === 0) {
187
  return (
188
  <div className="flex items-center justify-center h-64">
 
297
  </div>
298
  ) : (
299
  <div className="space-y-6">
300
+ <div className="flex items-center justify-between">
301
+ <div className="text-sm text-muted-foreground">
302
+ Segmented by{' '}
303
+ <span className="font-medium text-foreground">
304
+ {orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
305
+ </span>
306
+ </div>
307
+ <div className="flex rounded-md overflow-hidden border border-border">
308
+ <button
309
+ onClick={() => setOrgSegmentMode('category')}
310
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'category' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
311
+ >
312
+ Category
313
+ </button>
314
+ <button
315
+ onClick={() => setOrgSegmentMode('role')}
316
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'role' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
317
+ >
318
+ Role
319
+ </button>
320
+ <button
321
+ onClick={() => setOrgSegmentMode('status')}
322
+ className={`px-3 py-1 text-sm transition-smooth ${orgSegmentMode === 'status' ? 'bg-primary text-primary-foreground' : 'bg-secondary text-muted-foreground hover:bg-surface-2'}`}
323
+ >
324
+ Status
325
+ </button>
326
+ </div>
327
+ </div>
328
+
329
+ {[...orgGroups.entries()].map(([segment, members]) => (
330
+ <div key={segment} className="bg-card border border-border rounded-xl p-5">
331
  <div className="flex items-center gap-2 mb-4">
332
  <div className="w-1 h-6 bg-primary rounded-full" />
333
+ <h3 className="font-semibold text-foreground">{segment}</h3>
334
  <span className="text-xs text-muted-foreground ml-1">({members.length})</span>
335
  </div>
336
  <div className="flex flex-wrap gap-3">
src/components/panels/settings-panel.tsx CHANGED
@@ -2,6 +2,7 @@
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useMissionControl } from '@/store'
 
5
 
6
  interface Setting {
7
  key: string
@@ -24,6 +25,7 @@ const categoryOrder = ['general', 'retention', 'gateway', 'custom']
24
 
25
  export function SettingsPanel() {
26
  const { currentUser } = useMissionControl()
 
27
  const [settings, setSettings] = useState<Setting[]>([])
28
  const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
29
  const [loading, setLoading] = useState(true)
@@ -43,12 +45,17 @@ export function SettingsPanel() {
43
  const fetchSettings = useCallback(async () => {
44
  try {
45
  const res = await fetch('/api/settings')
 
 
 
 
46
  if (res.status === 403) {
47
  setError('Admin access required')
48
  return
49
  }
50
  if (!res.ok) {
51
- setError('Failed to load settings')
 
52
  return
53
  }
54
  const data = await res.json()
@@ -180,6 +187,21 @@ export function SettingsPanel() {
180
  </div>
181
  </div>
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  {/* Feedback */}
184
  {feedback && (
185
  <div className={`rounded-lg p-3 text-xs font-medium ${
 
2
 
3
  import { useState, useEffect, useCallback } from 'react'
4
  import { useMissionControl } from '@/store'
5
+ import { useNavigateToPanel } from '@/lib/navigation'
6
 
7
  interface Setting {
8
  key: string
 
25
 
26
  export function SettingsPanel() {
27
  const { currentUser } = useMissionControl()
28
+ const navigateToPanel = useNavigateToPanel()
29
  const [settings, setSettings] = useState<Setting[]>([])
30
  const [grouped, setGrouped] = useState<Record<string, Setting[]>>({})
31
  const [loading, setLoading] = useState(true)
 
45
  const fetchSettings = useCallback(async () => {
46
  try {
47
  const res = await fetch('/api/settings')
48
+ if (res.status === 401) {
49
+ window.location.assign('/login?next=%2Fsettings')
50
+ return
51
+ }
52
  if (res.status === 403) {
53
  setError('Admin access required')
54
  return
55
  }
56
  if (!res.ok) {
57
+ const data = await res.json().catch(() => ({}))
58
+ setError(data.error || 'Failed to load settings')
59
  return
60
  }
61
  const data = await res.json()
 
187
  </div>
188
  </div>
189
 
190
+ {/* Workspace Info */}
191
+ {currentUser?.role === 'admin' && (
192
+ <div className="bg-blue-500/10 border border-blue-500/20 rounded-lg p-3 text-xs text-blue-300">
193
+ <strong className="text-blue-200">Workspace Management:</strong>{' '}
194
+ To create or manage workspaces (tenant instances), go to the{' '}
195
+ <button
196
+ onClick={() => navigateToPanel('super-admin')}
197
+ className="text-blue-400 underline hover:text-blue-300 cursor-pointer"
198
+ >
199
+ Super Admin
200
+ </button>{' '}
201
+ panel under Admin &gt; Super Admin in the sidebar. From there you can create new client instances, manage tenants, and monitor provisioning jobs.
202
+ </div>
203
+ )}
204
+
205
  {/* Feedback */}
206
  {feedback && (
207
  <div className={`rounded-lg p-3 text-xs font-medium ${
src/components/panels/super-admin-panel.tsx CHANGED
@@ -409,12 +409,20 @@ export function SuperAdminPanel() {
409
  Multi-tenant provisioning control plane with approval gates and safer destructive actions.
410
  </p>
411
  </div>
412
- <button
413
- onClick={load}
414
- className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
415
- >
416
- Refresh
417
- </button>
 
 
 
 
 
 
 
 
418
  </div>
419
 
420
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
@@ -452,17 +460,21 @@ export function SuperAdminPanel() {
452
  </div>
453
  )}
454
 
455
- <div className="rounded-lg border border-border bg-card overflow-hidden">
456
- <button
457
- onClick={() => setCreateExpanded((v) => !v)}
458
- className="w-full px-4 py-3 border-b border-border text-left text-sm font-medium text-foreground hover:bg-secondary/20"
459
- >
460
- {createExpanded ? 'Hide' : 'Show'} Create Client Instance
461
- </button>
462
- {createExpanded && (
 
 
 
 
463
  <div className="p-4 space-y-3">
464
  <div className="text-xs text-muted-foreground">
465
- Add a new workspace/client instance here. Fill the form below and click <span className="text-foreground font-medium">Create + Queue</span>.
466
  </div>
467
  {gatewayLoadError && (
468
  <div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
@@ -540,8 +552,8 @@ export function SuperAdminPanel() {
540
  </button>
541
  </div>
542
  </div>
543
- )}
544
  </div>
 
545
 
546
  <div className="rounded-lg border border-border bg-card overflow-hidden">
547
  <div className="px-3 py-2 border-b border-border flex items-center gap-2">
 
409
  Multi-tenant provisioning control plane with approval gates and safer destructive actions.
410
  </p>
411
  </div>
412
+ <div className="flex items-center gap-2">
413
+ <button
414
+ onClick={() => setCreateExpanded(true)}
415
+ className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-smooth"
416
+ >
417
+ + Add Workspace
418
+ </button>
419
+ <button
420
+ onClick={load}
421
+ className="h-8 px-3 rounded-md border border-border text-sm text-foreground hover:bg-secondary/60 transition-smooth"
422
+ >
423
+ Refresh
424
+ </button>
425
+ </div>
426
  </div>
427
 
428
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
 
460
  </div>
461
  )}
462
 
463
+ {createExpanded && (
464
+ <div className="rounded-lg border border-primary/30 bg-card overflow-hidden">
465
+ <div className="px-4 py-3 border-b border-border flex items-center justify-between">
466
+ <h3 className="text-sm font-medium text-foreground">Create New Workspace</h3>
467
+ <button
468
+ onClick={() => setCreateExpanded(false)}
469
+ className="text-muted-foreground hover:text-foreground text-lg leading-none transition-smooth"
470
+ aria-label="Close create form"
471
+ >
472
+ ×
473
+ </button>
474
+ </div>
475
  <div className="p-4 space-y-3">
476
  <div className="text-xs text-muted-foreground">
477
+ Fill in the workspace details below and click <span className="text-foreground font-medium">Create + Queue</span> to provision a new client instance.
478
  </div>
479
  {gatewayLoadError && (
480
  <div className="px-3 py-2 rounded-md text-xs border bg-amber-500/10 text-amber-300 border-amber-500/20">
 
552
  </button>
553
  </div>
554
  </div>
 
555
  </div>
556
+ )}
557
 
558
  <div className="rounded-lg border border-border bg-card overflow-hidden">
559
  <div className="px-3 py-2 border-b border-border flex items-center gap-2">
src/components/panels/task-board-panel.tsx CHANGED
@@ -217,7 +217,7 @@ function MentionTextarea({
217
  className={className}
218
  />
219
  {open && filtered.length > 0 && (
220
- <div className="absolute z-20 mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
221
  {filtered.map((option, index) => (
222
  <button
223
  key={`${option.type}-${option.handle}-${option.recipient}`}
@@ -770,13 +770,14 @@ function TaskDetailModal({
770
  onUpdate: () => void
771
  onEdit: (task: Task) => void
772
  }) {
 
 
773
  const resolvedProjectName =
774
  task.project_name ||
775
  projects.find((project) => project.id === task.project_id)?.name
776
  const [comments, setComments] = useState<Comment[]>([])
777
  const [loadingComments, setLoadingComments] = useState(false)
778
  const [commentText, setCommentText] = useState('')
779
- const [commentAuthor, setCommentAuthor] = useState('system')
780
  const [commentError, setCommentError] = useState<string | null>(null)
781
  const [broadcastMessage, setBroadcastMessage] = useState('')
782
  const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
@@ -1026,14 +1027,9 @@ function TaskDetailModal({
1026
  )}
1027
 
1028
  <form onSubmit={handleAddComment} className="mt-4 space-y-3">
1029
- <div>
1030
- <label className="block text-xs text-muted-foreground mb-1">Author</label>
1031
- <input
1032
- type="text"
1033
- value={commentAuthor}
1034
- onChange={(e) => setCommentAuthor(e.target.value)}
1035
- className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
1036
- />
1037
  </div>
1038
  <div>
1039
  <label className="block text-xs text-muted-foreground mb-1">New Comment</label>
@@ -1056,18 +1052,25 @@ function TaskDetailModal({
1056
  </div>
1057
  </form>
1058
 
 
 
 
 
 
 
1059
  <div className="mt-6 border-t border-border pt-4">
1060
  <h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
1061
  {broadcastStatus && (
1062
  <div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
1063
  )}
1064
  <form onSubmit={handleBroadcast} className="space-y-2">
1065
- <textarea
1066
  value={broadcastMessage}
1067
- onChange={(e) => setBroadcastMessage(e.target.value)}
1068
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
1069
  rows={2}
1070
- placeholder="Send a message to all task subscribers..."
 
1071
  />
1072
  <div className="flex justify-end">
1073
  <button
 
217
  className={className}
218
  />
219
  {open && filtered.length > 0 && (
220
+ <div className="absolute z-[60] mt-1 w-full bg-surface-1 border border-border rounded-md shadow-xl max-h-56 overflow-y-auto">
221
  {filtered.map((option, index) => (
222
  <button
223
  key={`${option.type}-${option.handle}-${option.recipient}`}
 
770
  onUpdate: () => void
771
  onEdit: (task: Task) => void
772
  }) {
773
+ const { currentUser } = useMissionControl()
774
+ const commentAuthor = currentUser?.username || 'system'
775
  const resolvedProjectName =
776
  task.project_name ||
777
  projects.find((project) => project.id === task.project_id)?.name
778
  const [comments, setComments] = useState<Comment[]>([])
779
  const [loadingComments, setLoadingComments] = useState(false)
780
  const [commentText, setCommentText] = useState('')
 
781
  const [commentError, setCommentError] = useState<string | null>(null)
782
  const [broadcastMessage, setBroadcastMessage] = useState('')
783
  const [broadcastStatus, setBroadcastStatus] = useState<string | null>(null)
 
1027
  )}
1028
 
1029
  <form onSubmit={handleAddComment} className="mt-4 space-y-3">
1030
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
1031
+ <span>Posting as</span>
1032
+ <span className="font-medium text-foreground">{commentAuthor}</span>
 
 
 
 
 
1033
  </div>
1034
  <div>
1035
  <label className="block text-xs text-muted-foreground mb-1">New Comment</label>
 
1052
  </div>
1053
  </form>
1054
 
1055
+ <div className="mt-5 bg-blue-500/5 border border-blue-500/15 rounded-lg p-3 text-xs text-muted-foreground space-y-1">
1056
+ <div className="font-medium text-blue-300">How notifications work</div>
1057
+ <div><strong className="text-foreground">Comments</strong> are persisted on the task and notify all subscribers. Subscribers are auto-added when they: create the task, are assigned to it, comment on it, or are @mentioned.</div>
1058
+ <div><strong className="text-foreground">Broadcasts</strong> send a one-time notification to all current subscribers without creating a comment record.</div>
1059
+ </div>
1060
+
1061
  <div className="mt-6 border-t border-border pt-4">
1062
  <h5 className="text-sm font-medium text-foreground mb-2">Broadcast to Subscribers</h5>
1063
  {broadcastStatus && (
1064
  <div className="text-xs text-muted-foreground mb-2">{broadcastStatus}</div>
1065
  )}
1066
  <form onSubmit={handleBroadcast} className="space-y-2">
1067
+ <MentionTextarea
1068
  value={broadcastMessage}
1069
+ onChange={setBroadcastMessage}
1070
  className="w-full bg-surface-1 text-foreground border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50"
1071
  rows={2}
1072
+ placeholder="Send a message to all task subscribers... (use @ to mention)"
1073
+ mentionTargets={mentionTargets}
1074
  />
1075
  <div className="flex justify-end">
1076
  <button
src/lib/__tests__/task-status.test.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import { normalizeTaskCreateStatus, normalizeTaskUpdateStatus } from '../task-status'
3
+
4
+ describe('task status normalization', () => {
5
+ it('sets assigned status on create when assignee is present', () => {
6
+ expect(normalizeTaskCreateStatus(undefined, 'main')).toBe('assigned')
7
+ expect(normalizeTaskCreateStatus('inbox', 'main')).toBe('assigned')
8
+ })
9
+
10
+ it('keeps explicit non-inbox status on create', () => {
11
+ expect(normalizeTaskCreateStatus('in_progress', 'main')).toBe('in_progress')
12
+ })
13
+
14
+ it('auto-promotes inbox to assigned when assignment is added via update', () => {
15
+ expect(
16
+ normalizeTaskUpdateStatus({
17
+ currentStatus: 'inbox',
18
+ requestedStatus: undefined,
19
+ assignedTo: 'main',
20
+ assignedToProvided: true,
21
+ })
22
+ ).toBe('assigned')
23
+ })
24
+
25
+ it('auto-demotes assigned to inbox when assignment is removed via update', () => {
26
+ expect(
27
+ normalizeTaskUpdateStatus({
28
+ currentStatus: 'assigned',
29
+ requestedStatus: undefined,
30
+ assignedTo: '',
31
+ assignedToProvided: true,
32
+ })
33
+ ).toBe('inbox')
34
+ })
35
+
36
+ it('does not override explicit status changes on update', () => {
37
+ expect(
38
+ normalizeTaskUpdateStatus({
39
+ currentStatus: 'inbox',
40
+ requestedStatus: 'in_progress',
41
+ assignedTo: 'main',
42
+ assignedToProvided: true,
43
+ })
44
+ ).toBe('in_progress')
45
+ })
46
+ })
47
+
src/lib/config.ts CHANGED
@@ -21,6 +21,23 @@ const openclawStateDir =
21
  const openclawConfigPath =
22
  explicitOpenClawConfigPath ||
23
  path.join(openclawStateDir, 'openclaw.json')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
  export const config = {
26
  claudeHome:
@@ -45,10 +62,11 @@ export const config = {
45
  process.env.OPENCLAW_LOG_DIR ||
46
  (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
47
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
48
- memoryDir:
49
- process.env.OPENCLAW_MEMORY_DIR ||
50
- (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
51
- path.join(defaultDataDir, 'memory'),
 
52
  soulTemplatesDir:
53
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
54
  (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
@@ -61,6 +79,7 @@ export const config = {
61
  notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
62
  pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
63
  tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
 
64
  },
65
  }
66
 
 
21
  const openclawConfigPath =
22
  explicitOpenClawConfigPath ||
23
  path.join(openclawStateDir, 'openclaw.json')
24
+ const openclawWorkspaceDir =
25
+ process.env.OPENCLAW_WORKSPACE_DIR ||
26
+ process.env.MISSION_CONTROL_WORKSPACE_DIR ||
27
+ (openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
28
+ const defaultMemoryDir = (() => {
29
+ if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
30
+ // Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
31
+ // when available; fallback to legacy sqlite memory path.
32
+ if (
33
+ openclawWorkspaceDir &&
34
+ (fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
35
+ fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
36
+ ) {
37
+ return openclawWorkspaceDir
38
+ }
39
+ return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
40
+ })()
41
 
42
  export const config = {
43
  claudeHome:
 
62
  process.env.OPENCLAW_LOG_DIR ||
63
  (openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
64
  tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
65
+ memoryDir: defaultMemoryDir,
66
+ memoryAllowedPrefixes:
67
+ defaultMemoryDir === openclawWorkspaceDir
68
+ ? ['memory/', 'knowledge-base/']
69
+ : [],
70
  soulTemplatesDir:
71
  process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
72
  (openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
 
79
  notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
80
  pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
81
  tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
82
+ gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
83
  },
84
  }
85
 
src/lib/scheduler.ts CHANGED
@@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
6
  import { logger } from './logger'
7
  import { processWebhookRetries } from './webhooks'
8
  import { syncClaudeSessions } from './claude-sessions'
 
9
 
10
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
11
 
@@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
130
  }
131
  }
132
 
 
 
 
 
 
133
  if (totalDeleted > 0) {
134
  logAuditEvent({
135
  action: 'auto_cleanup',
 
6
  import { logger } from './logger'
7
  import { processWebhookRetries } from './webhooks'
8
  import { syncClaudeSessions } from './claude-sessions'
9
+ import { pruneGatewaySessionsOlderThan } from './sessions'
10
 
11
  const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
12
 
 
131
  }
132
  }
133
 
134
+ if (ret.gatewaySessions > 0) {
135
+ const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
136
+ totalDeleted += sessionCleanup.deleted
137
+ }
138
+
139
  if (totalDeleted > 0) {
140
  logAuditEvent({
141
  action: 'auto_cleanup',
src/lib/sessions.ts CHANGED
@@ -19,25 +19,13 @@ export interface GatewaySession {
19
  active: boolean
20
  }
21
 
22
- /**
23
- * Read all sessions from OpenClaw agent session stores on disk.
24
- *
25
- * OpenClaw stores sessions per-agent at:
26
- * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
27
- *
28
- * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
29
- * with session metadata as values.
30
- */
31
- export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
32
  const openclawStateDir = config.openclawStateDir
33
  if (!openclawStateDir) return []
34
 
35
  const agentsDir = path.join(openclawStateDir, 'agents')
36
  if (!fs.existsSync(agentsDir)) return []
37
 
38
- const sessions: GatewaySession[] = []
39
- const now = Date.now()
40
-
41
  let agentDirs: string[]
42
  try {
43
  agentDirs = fs.readdirSync(agentsDir)
@@ -45,10 +33,33 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
45
  return []
46
  }
47
 
 
48
  for (const agentName of agentDirs) {
49
  const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
50
  try {
51
- if (!fs.statSync(sessionsFile).isFile()) continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  const raw = fs.readFileSync(sessionsFile, 'utf-8')
53
  const data = JSON.parse(raw)
54
 
@@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
80
  return sessions
81
  }
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  /**
84
  * Derive agent active/idle/offline status from their sessions.
85
  * Returns a map of agentName -> { status, lastActivity, channel }
 
19
  active: boolean
20
  }
21
 
22
+ function getGatewaySessionStoreFiles(): string[] {
 
 
 
 
 
 
 
 
 
23
  const openclawStateDir = config.openclawStateDir
24
  if (!openclawStateDir) return []
25
 
26
  const agentsDir = path.join(openclawStateDir, 'agents')
27
  if (!fs.existsSync(agentsDir)) return []
28
 
 
 
 
29
  let agentDirs: string[]
30
  try {
31
  agentDirs = fs.readdirSync(agentsDir)
 
33
  return []
34
  }
35
 
36
+ const files: string[] = []
37
  for (const agentName of agentDirs) {
38
  const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
39
  try {
40
+ if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
41
+ } catch {
42
+ // Skip missing or unreadable session stores.
43
+ }
44
+ }
45
+ return files
46
+ }
47
+
48
+ /**
49
+ * Read all sessions from OpenClaw agent session stores on disk.
50
+ *
51
+ * OpenClaw stores sessions per-agent at:
52
+ * {OPENCLAW_STATE_DIR}/agents/{agentName}/sessions/sessions.json
53
+ *
54
+ * Each file is a JSON object keyed by session key (e.g. "agent:<agent>:main")
55
+ * with session metadata as values.
56
+ */
57
+ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
58
+ const sessions: GatewaySession[] = []
59
+ const now = Date.now()
60
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
61
+ const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
62
+ try {
63
  const raw = fs.readFileSync(sessionsFile, 'utf-8')
64
  const data = JSON.parse(raw)
65
 
 
91
  return sessions
92
  }
93
 
94
+ export function countStaleGatewaySessions(retentionDays: number): number {
95
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
96
+ const cutoff = Date.now() - retentionDays * 86400000
97
+ let stale = 0
98
+
99
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
100
+ try {
101
+ const raw = fs.readFileSync(sessionsFile, 'utf-8')
102
+ const data = JSON.parse(raw) as Record<string, any>
103
+ for (const entry of Object.values(data)) {
104
+ const updatedAt = Number((entry as any)?.updatedAt || 0)
105
+ if (updatedAt > 0 && updatedAt < cutoff) stale += 1
106
+ }
107
+ } catch {
108
+ // Ignore malformed session stores.
109
+ }
110
+ }
111
+
112
+ return stale
113
+ }
114
+
115
+ export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
116
+ if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
117
+ const cutoff = Date.now() - retentionDays * 86400000
118
+ let deleted = 0
119
+ let filesTouched = 0
120
+
121
+ for (const sessionsFile of getGatewaySessionStoreFiles()) {
122
+ try {
123
+ const raw = fs.readFileSync(sessionsFile, 'utf-8')
124
+ const data = JSON.parse(raw) as Record<string, any>
125
+ const nextEntries: Record<string, any> = {}
126
+ let fileDeleted = 0
127
+
128
+ for (const [key, entry] of Object.entries(data)) {
129
+ const updatedAt = Number((entry as any)?.updatedAt || 0)
130
+ if (updatedAt > 0 && updatedAt < cutoff) {
131
+ fileDeleted += 1
132
+ continue
133
+ }
134
+ nextEntries[key] = entry
135
+ }
136
+
137
+ if (fileDeleted > 0) {
138
+ const tempPath = `${sessionsFile}.tmp`
139
+ fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
140
+ fs.renameSync(tempPath, sessionsFile)
141
+ deleted += fileDeleted
142
+ filesTouched += 1
143
+ }
144
+ } catch {
145
+ // Ignore malformed/unwritable session stores.
146
+ }
147
+ }
148
+
149
+ return { deleted, filesTouched }
150
+ }
151
+
152
  /**
153
  * Derive agent active/idle/offline status from their sessions.
154
  * Returns a map of agentName -> { status, lastActivity, channel }
src/lib/task-status.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Task } from './db'
2
+
3
+ export type TaskStatus = Task['status']
4
+
5
+ function hasAssignee(assignedTo: string | null | undefined): boolean {
6
+ return Boolean(assignedTo && assignedTo.trim())
7
+ }
8
+
9
+ /**
10
+ * Keep task state coherent when a task is created with an assignee.
11
+ * If caller asks for `inbox` but also sets `assigned_to`, normalize to `assigned`.
12
+ */
13
+ export function normalizeTaskCreateStatus(
14
+ requestedStatus: TaskStatus | undefined,
15
+ assignedTo: string | undefined
16
+ ): TaskStatus {
17
+ const status = requestedStatus ?? 'inbox'
18
+ if (status === 'inbox' && hasAssignee(assignedTo)) return 'assigned'
19
+ return status
20
+ }
21
+
22
+ /**
23
+ * Auto-adjust status for assignment-only updates when caller does not
24
+ * explicitly request a status transition.
25
+ */
26
+ export function normalizeTaskUpdateStatus(args: {
27
+ currentStatus: TaskStatus
28
+ requestedStatus: TaskStatus | undefined
29
+ assignedTo: string | null | undefined
30
+ assignedToProvided: boolean
31
+ }): TaskStatus | undefined {
32
+ const { currentStatus, requestedStatus, assignedTo, assignedToProvided } = args
33
+ if (requestedStatus !== undefined) return requestedStatus
34
+ if (!assignedToProvided) return undefined
35
+
36
+ if (hasAssignee(assignedTo) && currentStatus === 'inbox') return 'assigned'
37
+ if (!hasAssignee(assignedTo) && currentStatus === 'assigned') return 'inbox'
38
+ return undefined
39
+ }
40
+
src/lib/validation.ts CHANGED
@@ -45,6 +45,7 @@ export const updateTaskSchema = createTaskSchema.partial()
45
 
46
  export const createAgentSchema = z.object({
47
  name: z.string().min(1, 'Name is required').max(100),
 
48
  role: z.string().min(1, 'Role is required').max(100).optional(),
49
  session_key: z.string().max(200).optional(),
50
  soul_content: z.string().max(50000).optional(),
@@ -53,6 +54,8 @@ export const createAgentSchema = z.object({
53
  template: z.string().max(100).optional(),
54
  gateway_config: z.record(z.string(), z.unknown()).optional(),
55
  write_to_gateway: z.boolean().optional(),
 
 
56
  })
57
 
58
  export const bulkUpdateTaskStatusSchema = z.object({
 
45
 
46
  export const createAgentSchema = z.object({
47
  name: z.string().min(1, 'Name is required').max(100),
48
+ openclaw_id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, 'openclaw_id must be kebab-case').max(100).optional(),
49
  role: z.string().min(1, 'Role is required').max(100).optional(),
50
  session_key: z.string().max(200).optional(),
51
  soul_content: z.string().max(50000).optional(),
 
54
  template: z.string().max(100).optional(),
55
  gateway_config: z.record(z.string(), z.unknown()).optional(),
56
  write_to_gateway: z.boolean().optional(),
57
+ provision_openclaw_workspace: z.boolean().optional(),
58
+ openclaw_workspace_path: z.string().min(1).max(500).optional(),
59
  })
60
 
61
  export const bulkUpdateTaskStatusSchema = z.object({
src/lib/websocket.ts CHANGED
@@ -16,7 +16,7 @@ const log = createClientLogger('WebSocket')
16
 
17
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
18
  const PROTOCOL_VERSION = 3
19
- const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'control-ui'
20
 
21
  // Heartbeat configuration
22
  const PING_INTERVAL_MS = 30_000
@@ -59,6 +59,8 @@ export function useWebSocket() {
59
  const pingCounterRef = useRef<number>(0)
60
  const pingSentTimestamps = useRef<Map<string, number>>(new Map())
61
  const missedPongsRef = useRef<number>(0)
 
 
62
 
63
  const {
64
  connection,
@@ -116,6 +118,7 @@ export function useWebSocket() {
116
 
117
  pingIntervalRef.current = setInterval(() => {
118
  if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
 
119
 
120
  // Check missed pongs
121
  if (missedPongsRef.current >= MAX_MISSED_PONGS) {
@@ -358,6 +361,13 @@ export function useWebSocket() {
358
 
359
  // Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
360
  if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
 
 
 
 
 
 
 
361
  handlePong(frame.id)
362
  return
363
  }
 
16
 
17
  // Gateway protocol version (v3 required by OpenClaw 2026.x)
18
  const PROTOCOL_VERSION = 3
19
+ const DEFAULT_GATEWAY_CLIENT_ID = process.env.NEXT_PUBLIC_GATEWAY_CLIENT_ID || 'openclaw-control-ui'
20
 
21
  // Heartbeat configuration
22
  const PING_INTERVAL_MS = 30_000
 
59
  const pingCounterRef = useRef<number>(0)
60
  const pingSentTimestamps = useRef<Map<string, number>>(new Map())
61
  const missedPongsRef = useRef<number>(0)
62
+ // Compat flag for gateway versions that may not implement ping RPC.
63
+ const gatewaySupportsPingRef = useRef<boolean>(true)
64
 
65
  const {
66
  connection,
 
118
 
119
  pingIntervalRef.current = setInterval(() => {
120
  if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !handshakeCompleteRef.current) return
121
+ if (!gatewaySupportsPingRef.current) return
122
 
123
  // Check missed pongs
124
  if (missedPongsRef.current >= MAX_MISSED_PONGS) {
 
361
 
362
  // Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
363
  if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
364
+ const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
365
+ if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
366
+ gatewaySupportsPingRef.current = false
367
+ missedPongsRef.current = 0
368
+ pingSentTimestamps.current.clear()
369
+ log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
370
+ }
371
  handlePong(frame.id)
372
  return
373
  }
src/live-feed.tsx DELETED
@@ -1,161 +0,0 @@
1
- 'use client'
2
-
3
- import { useMissionControl } from '@/store'
4
- import { useEffect, useState } from 'react'
5
-
6
- export function LiveFeed() {
7
- const { logs, sessions, activities, connection, toggleLiveFeed } = useMissionControl()
8
- const [expanded, setExpanded] = useState(true)
9
-
10
- // Combine logs and activities into a unified feed
11
- const feedItems = [
12
- ...logs.slice(0, 30).map(log => ({
13
- id: log.id,
14
- type: 'log' as const,
15
- level: log.level,
16
- message: log.message,
17
- source: log.source,
18
- timestamp: log.timestamp,
19
- })),
20
- ...activities.slice(0, 20).map(act => ({
21
- id: `act-${act.id}`,
22
- type: 'activity' as const,
23
- level: 'info' as const,
24
- message: act.description,
25
- source: act.actor,
26
- timestamp: act.created_at * 1000,
27
- })),
28
- ].sort((a, b) => b.timestamp - a.timestamp).slice(0, 40)
29
-
30
- if (!expanded) {
31
- return (
32
- <div className="w-10 bg-card border-l border-border flex flex-col items-center py-3 shrink-0">
33
- <button
34
- onClick={() => setExpanded(true)}
35
- className="w-8 h-8 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
36
- title="Show live feed"
37
- >
38
- <svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
39
- <path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
40
- </svg>
41
- </button>
42
- {/* Mini indicators */}
43
- <div className="mt-4 flex flex-col gap-2 items-center">
44
- {feedItems.slice(0, 5).map((item) => (
45
- <div
46
- key={item.id}
47
- className={`w-1.5 h-1.5 rounded-full ${
48
- item.level === 'error' ? 'bg-red-500' :
49
- item.level === 'warn' ? 'bg-amber-500' :
50
- 'bg-blue-500/40'
51
- }`}
52
- />
53
- ))}
54
- </div>
55
- </div>
56
- )
57
- }
58
-
59
- return (
60
- <div className="w-72 h-full bg-card border-l border-border flex flex-col shrink-0 slide-in-right">
61
- {/* Header */}
62
- <div className="h-10 px-3 flex items-center justify-between border-b border-border shrink-0">
63
- <div className="flex items-center gap-2">
64
- <div className="w-1.5 h-1.5 rounded-full bg-green-500 pulse-dot" />
65
- <span className="text-xs font-semibold text-foreground">Live Feed</span>
66
- <span className="text-2xs text-muted-foreground font-mono-tight">{feedItems.length}</span>
67
- </div>
68
- <div className="flex items-center gap-0.5">
69
- <button
70
- onClick={() => setExpanded(false)}
71
- className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
72
- title="Collapse feed"
73
- >
74
- <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
75
- <path d="M6 3l5 5-5 5" strokeLinecap="round" strokeLinejoin="round" />
76
- </svg>
77
- </button>
78
- <button
79
- onClick={toggleLiveFeed}
80
- className="w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-secondary transition-smooth flex items-center justify-center"
81
- title="Close feed"
82
- >
83
- <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
84
- <path d="M4 4l8 8M12 4l-8 8" strokeLinecap="round" strokeLinejoin="round" />
85
- </svg>
86
- </button>
87
- </div>
88
- </div>
89
-
90
- {/* Feed items */}
91
- <div className="flex-1 overflow-y-auto">
92
- {feedItems.length === 0 ? (
93
- <div className="px-3 py-8 text-center text-xs text-muted-foreground">
94
- No activity yet
95
- </div>
96
- ) : (
97
- <div className="divide-y divide-border/50">
98
- {feedItems.map((item) => (
99
- <FeedItem key={item.id} item={item} />
100
- ))}
101
- </div>
102
- )}
103
- </div>
104
-
105
- {/* Active sessions mini-list */}
106
- <div className="border-t border-border px-3 py-2 shrink-0">
107
- <div className="text-2xs font-medium text-muted-foreground mb-1.5">Active Sessions</div>
108
- <div className="space-y-1">
109
- {sessions.filter(s => s.active).slice(0, 4).map(session => (
110
- <div key={session.id} className="flex items-center gap-1.5 text-2xs">
111
- <div className="w-1.5 h-1.5 rounded-full bg-green-500" />
112
- <span className="text-foreground truncate flex-1 font-mono-tight">{session.key || session.id}</span>
113
- <span className="text-muted-foreground">{session.model?.split('/').pop()?.slice(0, 8)}</span>
114
- </div>
115
- ))}
116
- {sessions.filter(s => s.active).length === 0 && (
117
- <div className="text-2xs text-muted-foreground">No active sessions</div>
118
- )}
119
- </div>
120
- </div>
121
- </div>
122
- )
123
- }
124
-
125
- function FeedItem({ item }: { item: { id: string; type: string; level: string; message: string; source: string; timestamp: number } }) {
126
- const levelIndicator = item.level === 'error'
127
- ? 'bg-red-500'
128
- : item.level === 'warn'
129
- ? 'bg-amber-500'
130
- : item.level === 'debug'
131
- ? 'bg-gray-500'
132
- : 'bg-blue-500/50'
133
-
134
- const timeStr = formatRelativeTime(item.timestamp)
135
-
136
- return (
137
- <div className="px-3 py-2 hover:bg-secondary/50 transition-smooth group">
138
- <div className="flex items-start gap-2">
139
- <div className={`w-1.5 h-1.5 rounded-full mt-1.5 shrink-0 ${levelIndicator}`} />
140
- <div className="flex-1 min-w-0">
141
- <p className="text-xs text-foreground/90 leading-relaxed break-words">
142
- {item.message.length > 120 ? item.message.slice(0, 120) + '...' : item.message}
143
- </p>
144
- <div className="flex items-center gap-1.5 mt-0.5">
145
- <span className="text-2xs text-muted-foreground font-mono-tight">{item.source}</span>
146
- <span className="text-2xs text-muted-foreground/50">·</span>
147
- <span className="text-2xs text-muted-foreground">{timeStr}</span>
148
- </div>
149
- </div>
150
- </div>
151
- </div>
152
- )
153
- }
154
-
155
- function formatRelativeTime(ts: number): string {
156
- const diff = Date.now() - ts
157
- if (diff < 60_000) return 'now'
158
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`
159
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`
160
- return `${Math.floor(diff / 86_400_000)}d`
161
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/page.tsx DELETED
@@ -1,166 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useState } from 'react'
4
- import { NavRail } from '@/components/layout/nav-rail'
5
- import { HeaderBar } from '@/components/layout/header-bar'
6
- import { LiveFeed } from '@/components/layout/live-feed'
7
- import { Dashboard } from '@/components/dashboard/dashboard'
8
- import { AgentSpawnPanel } from '@/components/panels/agent-spawn-panel'
9
- import { LogViewerPanel } from '@/components/panels/log-viewer-panel'
10
- import { CronManagementPanel } from '@/components/panels/cron-management-panel'
11
- import { MemoryBrowserPanel } from '@/components/panels/memory-browser-panel'
12
- import { TokenDashboardPanel } from '@/components/panels/token-dashboard-panel'
13
- import { SessionDetailsPanel } from '@/components/panels/session-details-panel'
14
- import { TaskBoardPanel } from '@/components/panels/task-board-panel'
15
- import { ActivityFeedPanel } from '@/components/panels/activity-feed-panel'
16
- import { AgentSquadPanelPhase3 } from '@/components/panels/agent-squad-panel-phase3'
17
- import { StandupPanel } from '@/components/panels/standup-panel'
18
- import { OrchestrationBar } from '@/components/panels/orchestration-bar'
19
- import { NotificationsPanel } from '@/components/panels/notifications-panel'
20
- import { UserManagementPanel } from '@/components/panels/user-management-panel'
21
- import { AuditTrailPanel } from '@/components/panels/audit-trail-panel'
22
- import { AgentHistoryPanel } from '@/components/panels/agent-history-panel'
23
- import { WebhookPanel } from '@/components/panels/webhook-panel'
24
- import { SettingsPanel } from '@/components/panels/settings-panel'
25
- import { GatewayConfigPanel } from '@/components/panels/gateway-config-panel'
26
- import { IntegrationsPanel } from '@/components/panels/integrations-panel'
27
- import { AlertRulesPanel } from '@/components/panels/alert-rules-panel'
28
- import { MultiGatewayPanel } from '@/components/panels/multi-gateway-panel'
29
- import { ChatPanel } from '@/components/chat/chat-panel'
30
- import { useWebSocket } from '@/lib/websocket'
31
- import { useServerEvents } from '@/lib/use-server-events'
32
- import { useMissionControl } from '@/store'
33
-
34
- export default function Home() {
35
- const { connect } = useWebSocket()
36
- const { activeTab, setCurrentUser, liveFeedOpen, toggleLiveFeed } = useMissionControl()
37
-
38
- // Connect to SSE for real-time local DB events (tasks, agents, chat, etc.)
39
- useServerEvents()
40
- const [isClient, setIsClient] = useState(false)
41
-
42
- useEffect(() => {
43
- setIsClient(true)
44
-
45
- // Fetch current user
46
- fetch('/api/auth/me')
47
- .then(res => res.ok ? res.json() : null)
48
- .then(data => { if (data?.user) setCurrentUser(data.user) })
49
- .catch(() => {})
50
-
51
- // Auto-connect to gateway on mount
52
- const wsToken = process.env.NEXT_PUBLIC_GATEWAY_TOKEN || process.env.NEXT_PUBLIC_WS_TOKEN || ''
53
- const gatewayPort = process.env.NEXT_PUBLIC_GATEWAY_PORT || '18789'
54
- const gatewayHost = window.location.hostname
55
- const wsUrl = `ws://${gatewayHost}:${gatewayPort}`
56
- connect(wsUrl, wsToken)
57
- }, [connect, setCurrentUser])
58
-
59
- if (!isClient) {
60
- return (
61
- <div className="flex items-center justify-center min-h-screen">
62
- <div className="flex flex-col items-center gap-3">
63
- <div className="w-10 h-10 rounded-xl bg-primary flex items-center justify-center">
64
- <span className="text-primary-foreground font-bold text-sm">MC</span>
65
- </div>
66
- <div className="flex items-center gap-2">
67
- <div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
68
- <span className="text-sm text-muted-foreground">Loading Mission Control...</span>
69
- </div>
70
- </div>
71
- </div>
72
- )
73
- }
74
-
75
- return (
76
- <div className="flex h-screen bg-background overflow-hidden">
77
- {/* Left: Icon rail navigation (hidden on mobile, shown as bottom bar instead) */}
78
- <NavRail />
79
-
80
- {/* Center: Header + Content */}
81
- <div className="flex-1 flex flex-col min-w-0">
82
- <HeaderBar />
83
- <main className="flex-1 overflow-auto pb-16 md:pb-0">
84
- <ContentRouter tab={activeTab} />
85
- </main>
86
- </div>
87
-
88
- {/* Right: Live feed (hidden on mobile) */}
89
- {liveFeedOpen && (
90
- <div className="hidden lg:flex h-full">
91
- <LiveFeed />
92
- </div>
93
- )}
94
-
95
- {/* Floating button to reopen LiveFeed when closed */}
96
- {!liveFeedOpen && (
97
- <button
98
- onClick={toggleLiveFeed}
99
- className="hidden lg:flex fixed right-0 top-1/2 -translate-y-1/2 z-30 w-6 h-12 items-center justify-center bg-card border border-r-0 border-border rounded-l-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-all duration-200"
100
- title="Show live feed"
101
- >
102
- <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
103
- <path d="M10 3l-5 5 5 5" strokeLinecap="round" strokeLinejoin="round" />
104
- </svg>
105
- </button>
106
- )}
107
-
108
- {/* Chat panel overlay */}
109
- <ChatPanel />
110
- </div>
111
- )
112
- }
113
-
114
- function ContentRouter({ tab }: { tab: string }) {
115
- switch (tab) {
116
- case 'overview':
117
- return <Dashboard />
118
- case 'tasks':
119
- return <TaskBoardPanel />
120
- case 'agents':
121
- return (
122
- <>
123
- <OrchestrationBar />
124
- <AgentSquadPanelPhase3 />
125
- </>
126
- )
127
- case 'activity':
128
- return <ActivityFeedPanel />
129
- case 'notifications':
130
- return <NotificationsPanel />
131
- case 'standup':
132
- return <StandupPanel />
133
- case 'spawn':
134
- return <AgentSpawnPanel />
135
- case 'sessions':
136
- return <SessionDetailsPanel />
137
- case 'logs':
138
- return <LogViewerPanel />
139
- case 'cron':
140
- return <CronManagementPanel />
141
- case 'memory':
142
- return <MemoryBrowserPanel />
143
- case 'tokens':
144
- return <TokenDashboardPanel />
145
- case 'users':
146
- return <UserManagementPanel />
147
- case 'history':
148
- return <AgentHistoryPanel />
149
- case 'audit':
150
- return <AuditTrailPanel />
151
- case 'webhooks':
152
- return <WebhookPanel />
153
- case 'alerts':
154
- return <AlertRulesPanel />
155
- case 'gateways':
156
- return <MultiGatewayPanel />
157
- case 'gateway-config':
158
- return <GatewayConfigPanel />
159
- case 'integrations':
160
- return <IntegrationsPanel />
161
- case 'settings':
162
- return <SettingsPanel />
163
- default:
164
- return <Dashboard />
165
- }
166
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/agent-diagnostics.spec.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from '@playwright/test'
2
+ import { API_KEY_HEADER, createTestAgent, deleteTestAgent } from './helpers'
3
+
4
+ test.describe('Agent Diagnostics API', () => {
5
+ const cleanup: number[] = []
6
+
7
+ test.afterEach(async ({ request }) => {
8
+ for (const id of cleanup) {
9
+ await deleteTestAgent(request, id).catch(() => {})
10
+ }
11
+ cleanup.length = 0
12
+ })
13
+
14
+ test('self access is allowed with x-agent-name', async ({ request }) => {
15
+ const { id, name } = await createTestAgent(request)
16
+ cleanup.push(id)
17
+
18
+ const res = await request.get(`/api/agents/${name}/diagnostics?section=summary`, {
19
+ headers: { ...API_KEY_HEADER, 'x-agent-name': name },
20
+ })
21
+
22
+ expect(res.status()).toBe(200)
23
+ const body = await res.json()
24
+ expect(body.agent.name).toBe(name)
25
+ expect(body.summary).toBeDefined()
26
+ })
27
+
28
+ test('cross-agent access is denied by default', async ({ request }) => {
29
+ const a = await createTestAgent(request)
30
+ const b = await createTestAgent(request)
31
+ cleanup.push(a.id, b.id)
32
+
33
+ const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary`, {
34
+ headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
35
+ })
36
+
37
+ expect(res.status()).toBe(403)
38
+ })
39
+
40
+ test('cross-agent access is allowed with privileged=1 for admin', async ({ request }) => {
41
+ const a = await createTestAgent(request)
42
+ const b = await createTestAgent(request)
43
+ cleanup.push(a.id, b.id)
44
+
45
+ const res = await request.get(`/api/agents/${a.name}/diagnostics?section=summary&privileged=1`, {
46
+ headers: { ...API_KEY_HEADER, 'x-agent-name': b.name },
47
+ })
48
+
49
+ expect(res.status()).toBe(200)
50
+ const body = await res.json()
51
+ expect(body.agent.name).toBe(a.name)
52
+ expect(body.summary).toBeDefined()
53
+ })
54
+
55
+ test('invalid section query is rejected', async ({ request }) => {
56
+ const { id, name } = await createTestAgent(request)
57
+ cleanup.push(id)
58
+
59
+ const res = await request.get(`/api/agents/${name}/diagnostics?section=summary,invalid`, {
60
+ headers: { ...API_KEY_HEADER, 'x-agent-name': name },
61
+ })
62
+
63
+ expect(res.status()).toBe(400)
64
+ })
65
+
66
+ test('invalid hours query is rejected', async ({ request }) => {
67
+ const { id, name } = await createTestAgent(request)
68
+ cleanup.push(id)
69
+
70
+ const res = await request.get(`/api/agents/${name}/diagnostics?hours=0`, {
71
+ headers: { ...API_KEY_HEADER, 'x-agent-name': name },
72
+ })
73
+
74
+ expect(res.status()).toBe(400)
75
+ })
76
+ })