diff --git a/.env.example b/.env.example index f58878d..9da5f96 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,9 @@ OPENCLAW_GATEWAY_HOST=127.0.0.1 OPENCLAW_GATEWAY_PORT=18789 # Optional: token used by server-side gateway calls OPENCLAW_GATEWAY_TOKEN= +# Tools profile used when Mission Control spawns sessions via sessions_spawn. +# OpenClaw 2026.3.2+ defaults to "messaging" if omitted. +OPENCLAW_TOOLS_PROFILE=coding # Frontend env vars (NEXT_PUBLIC_ prefix = available in browser) NEXT_PUBLIC_GATEWAY_HOST= @@ -61,6 +64,8 @@ NEXT_PUBLIC_GATEWAY_PORT=18789 NEXT_PUBLIC_GATEWAY_PROTOCOL= NEXT_PUBLIC_GATEWAY_URL= # NEXT_PUBLIC_GATEWAY_TOKEN= # Optional, set if gateway requires auth token +# Gateway client id used in websocket handshake (role=operator UI client). +NEXT_PUBLIC_GATEWAY_CLIENT_ID=control-ui # === Data Paths (all optional, defaults to .data/ in project root) === # MISSION_CONTROL_DATA_DIR=.data diff --git a/README.md b/README.md index ca9cec1..5b3cdeb 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,9 @@ See [`.env.example`](.env.example) for the complete list. Key variables: | `OPENCLAW_GATEWAY_HOST` | No | Gateway host (default: `127.0.0.1`) | | `OPENCLAW_GATEWAY_PORT` | No | Gateway WebSocket port (default: `18789`) | | `OPENCLAW_GATEWAY_TOKEN` | No | Server-side gateway auth token | +| `OPENCLAW_TOOLS_PROFILE` | No | Tools profile for `sessions_spawn` (recommended: `coding`) | | `NEXT_PUBLIC_GATEWAY_TOKEN` | No | Browser-side gateway auth token (must use `NEXT_PUBLIC_` prefix) | +| `NEXT_PUBLIC_GATEWAY_CLIENT_ID` | No | Gateway UI client ID for websocket handshake (default: `control-ui`) | | `OPENCLAW_MEMORY_DIR` | No | Memory browser root (see note below) | | `MC_CLAUDE_HOME` | No | Path to `~/.claude` directory (default: `~/.claude`) | | `MC_TRUSTED_PROXIES` | No | Comma-separated trusted proxy IPs for XFF parsing | diff --git a/src/app/api/gateways/health/route.ts b/src/app/api/gateways/health/route.ts index 6297183..1025b36 100644 --- a/src/app/api/gateways/health/route.ts +++ b/src/app/api/gateways/health/route.ts @@ -19,9 +19,33 @@ interface HealthResult { latency: number | null agents: string[] sessions_count: number + gateway_version?: string | null + compatibility_warning?: string error?: string } +function parseGatewayVersion(res: Response): string | null { + const direct = res.headers.get('x-openclaw-version') || res.headers.get('x-clawdbot-version') + if (direct) return direct.trim() + const server = res.headers.get('server') || '' + const m = server.match(/(\d{4}\.\d+\.\d+)/) + return m?.[1] || null +} + +function hasOpenClaw32ToolsProfileRisk(version: string | null): boolean { + if (!version) return false + const m = version.match(/^(\d{4})\.(\d+)\.(\d+)/) + if (!m) return false + const year = Number(m[1]) + const major = Number(m[2]) + const minor = Number(m[3]) + if (year > 2026) return true + if (year < 2026) return false + if (major > 3) return true + if (major < 3) return false + return minor >= 2 +} + function isBlockedUrl(urlStr: string): boolean { try { const url = new URL(urlStr) @@ -77,6 +101,10 @@ export async function POST(request: NextRequest) { const latency = Date.now() - start const status = res.ok ? "online" : "error" + const gatewayVersion = parseGatewayVersion(res) + const compatibilityWarning = hasOpenClaw32ToolsProfileRisk(gatewayVersion) + ? 'OpenClaw 2026.3.2+ defaults tools.profile=messaging; Mission Control should enforce coding profile when spawning.' + : undefined updateOnlineStmt.run(status, latency, gw.id) @@ -87,6 +115,8 @@ export async function POST(request: NextRequest) { latency, agents: [], sessions_count: 0, + gateway_version: gatewayVersion, + compatibility_warning: compatibilityWarning, }) } catch (err: any) { updateOfflineStmt.run("offline", gw.id) diff --git a/src/app/api/spawn/route.ts b/src/app/api/spawn/route.ts index 1119d12..cfe2951 100644 --- a/src/app/api/spawn/route.ts +++ b/src/app/api/spawn/route.ts @@ -8,6 +8,15 @@ import { heavyLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' import { validateBody, spawnAgentSchema } from '@/lib/validation' +function getPreferredToolsProfile(): string { + return String(process.env.OPENCLAW_TOOLS_PROFILE || 'coding').trim() || 'coding' +} + +async function runSpawnWithCompatibility(spawnPayload: Record) { + const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})` + return runClawdbot(['-c', commandArg], { timeoutMs: 10000 }) +} + export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) @@ -31,15 +40,38 @@ export async function POST(request: NextRequest) { task, model, label, - runTimeoutSeconds: timeout + runTimeoutSeconds: timeout, + tools: { + profile: getPreferredToolsProfile(), + }, } - const commandArg = `sessions_spawn(${JSON.stringify(spawnPayload)})` try { - // Execute the spawn command - const { stdout, stderr } = await runClawdbot(['-c', commandArg], { - timeoutMs: 10000 - }) + // Execute the spawn command (OpenClaw 2026.3.2+ defaults tools.profile to messaging). + let stdout = '' + let stderr = '' + let compatibilityFallbackUsed = false + try { + const result = await runSpawnWithCompatibility(spawnPayload) + stdout = result.stdout + stderr = result.stderr + } catch (firstError: any) { + const rawErr = String(firstError?.stderr || firstError?.message || '').toLowerCase() + const likelySchemaMismatch = + rawErr.includes('unknown field') || + rawErr.includes('unknown key') || + rawErr.includes('invalid argument') || + rawErr.includes('tools') || + rawErr.includes('profile') + if (!likelySchemaMismatch) throw firstError + + const fallbackPayload = { ...spawnPayload } + delete (fallbackPayload as any).tools + const fallback = await runSpawnWithCompatibility(fallbackPayload) + stdout = fallback.stdout + stderr = fallback.stderr + compatibilityFallbackUsed = true + } // Parse the response to extract session info let sessionInfo = null @@ -63,7 +95,11 @@ export async function POST(request: NextRequest) { timeoutSeconds: timeout, createdAt: Date.now(), stdout: stdout.trim(), - stderr: stderr.trim() + stderr: stderr.trim(), + compatibility: { + toolsProfile: getPreferredToolsProfile(), + fallbackUsed: compatibilityFallbackUsed, + }, }) } catch (execError: any) { diff --git a/src/components/panels/agent-detail-tabs.tsx b/src/components/panels/agent-detail-tabs.tsx index 02b2950..d975f50 100644 --- a/src/components/panels/agent-detail-tabs.tsx +++ b/src/components/panels/agent-detail-tabs.tsx @@ -2,8 +2,9 @@ import { useState, useEffect } from 'react' import { createClientLogger } from '@/lib/client-logger' - const log = createClientLogger('AgentDetailTabs') +import Link from 'next/link' + interface Agent { id: number @@ -513,7 +514,12 @@ export function MemoryTab({ return (
-

Working Memory

+
+

Working Memory

+

+ Agent-level scratchpad only. Use the global Memory page to browse all workspace memory files. +

+
{!editing && ( <> @@ -663,7 +669,10 @@ export function TasksTab({ agent }: { agent: Agent }) {
-
{task.title}
+ + {task.title} + +
Task #{task.id}
{task.description && (

{task.description}

)} diff --git a/src/components/panels/cron-management-panel.tsx b/src/components/panels/cron-management-panel.tsx index 4356a15..7090834 100644 --- a/src/components/panels/cron-management-panel.tsx +++ b/src/components/panels/cron-management-panel.tsx @@ -1,10 +1,10 @@ 'use client' -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useMissionControl, CronJob } from '@/store' import { createClientLogger } from '@/lib/client-logger' - const log = createClientLogger('CronManagement') +import { buildDayKey, getCronOccurrences } from '@/lib/cron-occurrences' interface NewJobForm { name: string @@ -59,6 +59,7 @@ export function CronManagementPanel() { const [availableModels, setAvailableModels] = useState([]) const [calendarView, setCalendarView] = useState('week') const [calendarDate, setCalendarDate] = useState(startOfDay(new Date())) + const [selectedCalendarDate, setSelectedCalendarDate] = useState(startOfDay(new Date())) const [searchQuery, setSearchQuery] = useState('') const [agentFilter, setAgentFilter] = useState('all') const [stateFilter, setStateFilter] = useState<'all' | 'enabled' | 'disabled'>('all') @@ -310,39 +311,69 @@ export function CronManagementPanel() { return matchesQuery && matchesAgent && matchesState }) - const agendaJobs = [...filteredJobs].sort((a, b) => { - const aRun = typeof a.nextRun === 'number' ? a.nextRun : Number.POSITIVE_INFINITY - const bRun = typeof b.nextRun === 'number' ? b.nextRun : Number.POSITIVE_INFINITY - return aRun - bRun - }) - const dayStart = startOfDay(calendarDate) const dayEnd = addDays(dayStart, 1) - const dayJobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= dayStart.getTime() && job.nextRun < dayEnd.getTime()) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) const weekStart = getWeekStart(calendarDate) const weekDays = Array.from({ length: 7 }, (_, idx) => addDays(weekStart, idx)) - const jobsByWeekDay = weekDays.map((date) => { - const start = startOfDay(date).getTime() - const end = addDays(date, 1).getTime() - const jobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) - return { date, jobs } - }) const monthGridStart = getMonthStartGrid(calendarDate) const monthDays = Array.from({ length: 42 }, (_, idx) => addDays(monthGridStart, idx)) - const jobsByMonthDay = monthDays.map((date) => { - const start = startOfDay(date).getTime() - const end = addDays(date, 1).getTime() - const jobs = filteredJobs - .filter((job) => typeof job.nextRun === 'number' && job.nextRun >= start && job.nextRun < end) - .sort((a, b) => (a.nextRun || 0) - (b.nextRun || 0)) - return { date, jobs } - }) + + const calendarBounds = useMemo(() => { + if (calendarView === 'day') { + return { startMs: dayStart.getTime(), endMs: dayEnd.getTime() } + } + if (calendarView === 'week') { + return { startMs: weekStart.getTime(), endMs: addDays(weekStart, 7).getTime() } + } + if (calendarView === 'month') { + return { startMs: monthGridStart.getTime(), endMs: addDays(monthGridStart, 42).getTime() } + } + const agendaStart = Date.now() + return { startMs: agendaStart, endMs: addDays(startOfDay(new Date()), 30).getTime() } + }, [calendarView, dayEnd, dayStart, monthGridStart, weekStart]) + + const calendarOccurrences = useMemo(() => { + const rows: Array<{ job: CronJob; atMs: number; dayKey: string }> = [] + for (const job of filteredJobs) { + const occurrences = getCronOccurrences(job.schedule, calendarBounds.startMs, calendarBounds.endMs, 1000) + for (const occurrence of occurrences) { + rows.push({ job, atMs: occurrence.atMs, dayKey: occurrence.dayKey }) + } + + if (occurrences.length === 0 && typeof job.nextRun === 'number' && job.nextRun >= calendarBounds.startMs && job.nextRun < calendarBounds.endMs) { + rows.push({ job, atMs: job.nextRun, dayKey: buildDayKey(new Date(job.nextRun)) }) + } + } + + rows.sort((a, b) => a.atMs - b.atMs) + return rows + }, [calendarBounds.endMs, calendarBounds.startMs, filteredJobs]) + + const occurrencesByDay = useMemo(() => { + const dayMap = new Map>() + for (const row of calendarOccurrences) { + const existing = dayMap.get(row.dayKey) || [] + existing.push({ job: row.job, atMs: row.atMs }) + dayMap.set(row.dayKey, existing) + } + return dayMap + }, [calendarOccurrences]) + + const dayJobs = occurrencesByDay.get(buildDayKey(dayStart)) || [] + + const jobsByWeekDay = weekDays.map((date) => ({ + date, + jobs: occurrencesByDay.get(buildDayKey(date)) || [], + })) + + const jobsByMonthDay = monthDays.map((date) => ({ + date, + jobs: occurrencesByDay.get(buildDayKey(date)) || [], + })) + + const selectedDayJobs = occurrencesByDay.get(buildDayKey(selectedCalendarDate)) || [] const moveCalendar = (direction: -1 | 1) => { setCalendarDate((prev) => { @@ -395,7 +426,7 @@ export function CronManagementPanel() {

Calendar View

-

Read-only schedule visibility across all cron jobs

+

Interactive schedule across all matching cron jobs

+ )) - )} + )}
)} @@ -496,13 +531,17 @@ export function CronManagementPanel() {
No scheduled jobs for this day.
) : (
- {dayJobs.map((job) => ( -
-
{job.name}
+ {dayJobs.map((row) => ( +
+ ))}
)} @@ -512,21 +551,25 @@ export function CronManagementPanel() { {calendarView === 'week' && (
{jobsByWeekDay.map(({ date, jobs }) => ( -
+ ))}
)} @@ -538,15 +581,16 @@ export function CronManagementPanel() { return (
setSelectedCalendarDate(startOfDay(date))} + className={`border border-border rounded-lg p-2 min-h-24 cursor-pointer ${inCurrentMonth ? 'bg-transparent' : 'bg-secondary/30'} ${isSameDay(date, selectedCalendarDate) ? 'border-primary/40 bg-primary/10' : 'hover:bg-secondary/50'}`} >
{date.getDate()}
- {jobs.slice(0, 2).map((job) => ( -
- {job.name} + {jobs.slice(0, 2).map((row) => ( +
+ {row.job.name}
))} {jobs.length > 2 &&
+{jobs.length - 2}
} @@ -556,6 +600,35 @@ export function CronManagementPanel() { })}
)} + + {calendarView !== 'agenda' && ( +
+
+

+ {selectedCalendarDate.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric', year: 'numeric' })} +

+ {selectedDayJobs.length} jobs +
+ {selectedDayJobs.length === 0 ? ( +
No jobs scheduled on this date.
+ ) : ( +
+ {selectedDayJobs.map((row) => ( + + ))} +
+ )} +
+ )}
diff --git a/src/components/panels/memory-browser-panel.tsx b/src/components/panels/memory-browser-panel.tsx index aac4db7..7ec0d6e 100644 --- a/src/components/panels/memory-browser-panel.tsx +++ b/src/components/panels/memory-browser-panel.tsx @@ -356,6 +356,9 @@ export function MemoryBrowserPanel() { ? 'Browse and manage local knowledge files and memory' : 'Explore knowledge files and memory structure'}

+

+ This page shows all workspace memory files. The agent profile Memory tab only edits that single agent's working memory. +

{/* Tab Navigation */}
diff --git a/src/components/panels/multi-gateway-panel.tsx b/src/components/panels/multi-gateway-panel.tsx index 9218663..b3a9bfa 100644 --- a/src/components/panels/multi-gateway-panel.tsx +++ b/src/components/panels/multi-gateway-panel.tsx @@ -35,12 +35,23 @@ interface DirectConnection { agent_role: string } +interface GatewayHealthProbe { + id: number + name: string + status: 'online' | 'offline' | 'error' + latency: number | null + gateway_version?: string | null + compatibility_warning?: string + error?: string +} + export function MultiGatewayPanel() { const [gateways, setGateways] = useState([]) const [directConnections, setDirectConnections] = useState([]) const [loading, setLoading] = useState(true) const [showAdd, setShowAdd] = useState(false) const [probing, setProbing] = useState(null) + const [healthByGatewayId, setHealthByGatewayId] = useState>(new Map()) const { connection } = useMissionControl() const { connect } = useWebSocket() @@ -89,7 +100,14 @@ export function MultiGatewayPanel() { const probeAll = async () => { try { - await fetch("/api/gateways/health", { method: "POST" }) + const res = await fetch("/api/gateways/health", { method: "POST" }) + const data = await res.json().catch(() => ({})) + const rows = Array.isArray(data?.results) ? data.results as GatewayHealthProbe[] : [] + const mapped = new Map() + for (const row of rows) { + if (typeof row?.id === 'number') mapped.set(row.id, row) + } + setHealthByGatewayId(mapped) } catch { /* ignore */ } fetchGateways() } @@ -172,6 +190,7 @@ export function MultiGatewayPanel() { setPrimary(gw)} @@ -250,8 +269,9 @@ export function MultiGatewayPanel() { ) } -function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { +function GatewayCard({ gateway, health, isProbing, isCurrentlyConnected, onSetPrimary, onDelete, onConnect, onProbe }: { gateway: Gateway + health?: GatewayHealthProbe isProbing: boolean isCurrentlyConnected: boolean onSetPrimary: () => void @@ -269,6 +289,7 @@ function GatewayCard({ gateway, isProbing, isCurrentlyConnected, onSetPrimary, o const lastSeen = gateway.last_seen ? new Date(gateway.last_seen * 1000).toLocaleString() : 'Never probed' + const compatibilityWarning = health?.compatibility_warning return (
Latency: {gateway.latency}ms} Last: {lastSeen}
+ {health?.gateway_version && ( +
+ Gateway version: {health.gateway_version} +
+ )} + {compatibilityWarning && ( +
+ {compatibilityWarning} +
+ )}