Mission Control: Habi readiness wiring + office segmentation (#187)

* fix mission control wiring for habi memory/orchestration/retention

* feat office org-chart segmentation controls

---------

Co-authored-by: Jeremy Phelps <kokoro@Kokoro.local>
This commit is contained in:
ResistanceDown 2026-03-04 20:00:54 -08:00 committed by GitHub
parent 9d39e51f56
commit 41bfff8f79
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 304 additions and 36 deletions

View File

@ -3,6 +3,7 @@ import { requireRole } from '@/lib/auth'
import { getDatabase, logAuditEvent } from '@/lib/db'
import { config } from '@/lib/config'
import { heavyLimiter } from '@/lib/rate-limit'
import { countStaleGatewaySessions, pruneGatewaySessionsOlderThan } from '@/lib/sessions'
interface CleanupResult {
table: string
@ -59,6 +60,17 @@ export async function GET(request: NextRequest) {
preview.push({ table: 'Token Usage (file)', retention_days: ret.tokenUsage, stale_count: 0, note: 'No token data file' })
}
if (ret.gatewaySessions > 0) {
preview.push({
table: 'Gateway Session Store',
retention_days: ret.gatewaySessions,
stale_count: countStaleGatewaySessions(ret.gatewaySessions),
note: 'Stored under ~/.openclaw/agents/*/sessions/sessions.json',
})
} else {
preview.push({ table: 'Gateway Session Store', retention_days: 0, stale_count: 0, note: 'Retention disabled (keep forever)' })
}
return NextResponse.json({ retention: config.retention, preview })
}
@ -137,6 +149,19 @@ export async function POST(request: NextRequest) {
}
}
if (ret.gatewaySessions > 0) {
const sessionPrune = dryRun
? { deleted: countStaleGatewaySessions(ret.gatewaySessions), filesTouched: 0 }
: pruneGatewaySessionsOlderThan(ret.gatewaySessions)
results.push({
table: 'Gateway Session Store',
deleted: sessionPrune.deleted,
cutoff_date: new Date(Date.now() - ret.gatewaySessions * 86400000).toISOString().split('T')[0],
retention_days: ret.gatewaySessions,
})
totalDeleted += sessionPrune.deleted
}
if (!dryRun && totalDeleted > 0) {
const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'
logAuditEvent({

View File

@ -9,6 +9,7 @@ import { readLimiter, mutationLimiter } from '@/lib/rate-limit'
import { logger } from '@/lib/logger'
const MEMORY_PATH = config.memoryDir
const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/'))
// Ensure memory directory exists on startup
if (MEMORY_PATH && !existsSync(MEMORY_PATH)) {
@ -24,6 +25,16 @@ interface MemoryFile {
children?: MemoryFile[]
}
function normalizeRelativePath(value: string): string {
return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '')
}
function isPathAllowed(relativePath: string): boolean {
if (!MEMORY_ALLOWED_PREFIXES.length) return true
const normalized = normalizeRelativePath(relativePath)
return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix))
}
function isWithinBase(base: string, candidate: string): boolean {
if (candidate === base) return true
return candidate.startsWith(base + sep)
@ -137,12 +148,37 @@ export async function GET(request: NextRequest) {
if (!MEMORY_PATH) {
return NextResponse.json({ tree: [] })
}
if (MEMORY_ALLOWED_PREFIXES.length) {
const tree: MemoryFile[] = []
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
const folder = prefix.replace(/\/$/, '')
const fullPath = join(MEMORY_PATH, folder)
if (!existsSync(fullPath)) continue
try {
const stats = await stat(fullPath)
if (!stats.isDirectory()) continue
tree.push({
path: folder,
name: folder,
type: 'directory',
modified: stats.mtime.getTime(),
children: await buildFileTree(fullPath, folder),
})
} catch {
// Skip unreadable roots
}
}
return NextResponse.json({ tree })
}
const tree = await buildFileTree(MEMORY_PATH)
return NextResponse.json({ tree })
}
if (action === 'content' && path) {
// Return file content
if (!isPathAllowed(path)) {
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
}
if (!MEMORY_PATH) {
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
}
@ -227,7 +263,16 @@ export async function GET(request: NextRequest) {
}
}
await searchDirectory(MEMORY_PATH)
if (MEMORY_ALLOWED_PREFIXES.length) {
for (const prefix of MEMORY_ALLOWED_PREFIXES) {
const folder = prefix.replace(/\/$/, '')
const fullPath = join(MEMORY_PATH, folder)
if (!existsSync(fullPath)) continue
await searchDirectory(fullPath, folder)
}
} else {
await searchDirectory(MEMORY_PATH)
}
return NextResponse.json({
query,
@ -256,6 +301,9 @@ export async function POST(request: NextRequest) {
if (!path) {
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
}
if (!isPathAllowed(path)) {
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
}
if (!MEMORY_PATH) {
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })
@ -316,6 +364,9 @@ export async function DELETE(request: NextRequest) {
if (!path) {
return NextResponse.json({ error: 'Path is required' }, { status: 400 })
}
if (!isPathAllowed(path)) {
return NextResponse.json({ error: 'Path not allowed' }, { status: 403 })
}
if (!MEMORY_PATH) {
return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 })

View File

@ -23,6 +23,7 @@ const settingDefinitions: Record<string, { category: string; description: string
'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) },
'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) },
'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) },
'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) },
// Gateway
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },

View File

@ -522,7 +522,7 @@ export function Dashboard() {
{isLocal ? (
<QuickAction label="Sessions" desc="Claude Code sessions" tab="sessions" icon={<SessionIcon />} onNavigate={navigateToPanel} />
) : (
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="orchestration" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
<QuickAction label="Orchestration" desc="Workflows & pipelines" tab="agents" icon={<PipelineActionIcon />} onNavigate={navigateToPanel} />
)}
</div>
</div>

View File

@ -47,7 +47,7 @@ export function MemoryBrowserPanel() {
setMemoryFiles(data.tree || [])
// Auto-expand some common directories
setExpandedFolders(new Set(['daily', 'knowledge']))
setExpandedFolders(new Set(['daily', 'knowledge', 'memory', 'knowledge-base']))
} catch (error) {
log.error('Failed to load file tree:', error)
} finally {
@ -62,14 +62,13 @@ export function MemoryBrowserPanel() {
const getFilteredFiles = () => {
if (activeTab === 'all') return memoryFiles
return memoryFiles.filter(file => {
if (activeTab === 'daily') {
return file.name === 'daily' || file.path.includes('daily/')
}
if (activeTab === 'knowledge') {
return file.name === 'knowledge' || file.path.includes('knowledge/')
}
return true
const tabPrefixes = activeTab === 'daily'
? ['daily/', 'memory/']
: ['knowledge/', 'knowledge-base/']
return memoryFiles.filter((file) => {
const normalizedPath = `${file.path.replace(/\\/g, '/')}/`
return tabPrefixes.some((prefix) => normalizedPath.startsWith(prefix))
})
}
@ -731,6 +730,8 @@ function CreateFileModal({
onChange={(e) => setFilePath(e.target.value)}
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"
>
<option value="knowledge-base/">knowledge-base/</option>
<option value="memory/">memory/</option>
<option value="knowledge/">knowledge/</option>
<option value="daily/">daily/</option>
<option value="logs/">logs/</option>

View File

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { useMissionControl, Agent } from '@/store'
type ViewMode = 'office' | 'org-chart'
type OrgSegmentMode = 'category' | 'role' | 'status'
interface Desk {
agent: Agent
@ -75,6 +76,7 @@ export function OfficePanel() {
const { agents } = useMissionControl()
const [localAgents, setLocalAgents] = useState<Agent[]>([])
const [viewMode, setViewMode] = useState<ViewMode>('office')
const [orgSegmentMode, setOrgSegmentMode] = useState<OrgSegmentMode>('category')
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null)
const [loading, setLoading] = useState(true)
@ -123,6 +125,64 @@ export function OfficePanel() {
return groups
}, [displayAgents])
const categoryGroups = useMemo(() => {
const groups = new Map<string, Agent[]>()
const getCategory = (agent: Agent): string => {
const name = (agent.name || '').toLowerCase()
if (name.startsWith('habi-')) return 'Habi Lanes'
if (name.startsWith('ops-')) return 'Ops Automation'
if (name.includes('canary')) return 'Canary'
if (name.startsWith('main')) return 'Core'
if (name.startsWith('remote-')) return 'Remote'
return 'Other'
}
for (const a of displayAgents) {
const category = getCategory(a)
if (!groups.has(category)) groups.set(category, [])
groups.get(category)!.push(a)
}
const order = ['Habi Lanes', 'Ops Automation', 'Core', 'Canary', 'Remote', 'Other']
return new Map(
[...groups.entries()].sort(([a], [b]) => {
const ai = order.indexOf(a)
const bi = order.indexOf(b)
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
if (av !== bv) return av - bv
return a.localeCompare(b)
})
)
}, [displayAgents])
const statusGroups = useMemo(() => {
const groups = new Map<string, Agent[]>()
for (const a of displayAgents) {
const key = statusLabel[a.status] || a.status
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(a)
}
const order = ['Working', 'Available', 'Error', 'Away']
return new Map(
[...groups.entries()].sort(([a], [b]) => {
const ai = order.indexOf(a)
const bi = order.indexOf(b)
const av = ai === -1 ? Number.MAX_SAFE_INTEGER : ai
const bv = bi === -1 ? Number.MAX_SAFE_INTEGER : bi
if (av !== bv) return av - bv
return a.localeCompare(b)
})
)
}, [displayAgents])
const orgGroups = useMemo(() => {
if (orgSegmentMode === 'role') return roleGroups
if (orgSegmentMode === 'status') return statusGroups
return categoryGroups
}, [categoryGroups, orgSegmentMode, roleGroups, statusGroups])
if (loading && displayAgents.length === 0) {
return (
<div className="flex items-center justify-center h-64">
@ -237,11 +297,40 @@ export function OfficePanel() {
</div>
) : (
<div className="space-y-6">
{[...roleGroups.entries()].map(([role, members]) => (
<div key={role} className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Segmented by{' '}
<span className="font-medium text-foreground">
{orgSegmentMode === 'category' ? 'category' : orgSegmentMode}
</span>
</div>
<div className="flex rounded-md overflow-hidden border border-border">
<button
onClick={() => setOrgSegmentMode('category')}
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'}`}
>
Category
</button>
<button
onClick={() => setOrgSegmentMode('role')}
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'}`}
>
Role
</button>
<button
onClick={() => setOrgSegmentMode('status')}
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'}`}
>
Status
</button>
</div>
</div>
{[...orgGroups.entries()].map(([segment, members]) => (
<div key={segment} className="bg-card border border-border rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-6 bg-primary rounded-full" />
<h3 className="font-semibold text-foreground">{role}</h3>
<h3 className="font-semibold text-foreground">{segment}</h3>
<span className="text-xs text-muted-foreground ml-1">({members.length})</span>
</div>
<div className="flex flex-wrap gap-3">

View File

@ -21,6 +21,23 @@ const openclawStateDir =
const openclawConfigPath =
explicitOpenClawConfigPath ||
path.join(openclawStateDir, 'openclaw.json')
const openclawWorkspaceDir =
process.env.OPENCLAW_WORKSPACE_DIR ||
process.env.MISSION_CONTROL_WORKSPACE_DIR ||
(openclawStateDir ? path.join(openclawStateDir, 'workspace') : '')
const defaultMemoryDir = (() => {
if (process.env.OPENCLAW_MEMORY_DIR) return process.env.OPENCLAW_MEMORY_DIR
// Prefer OpenClaw workspace memory context (daily notes + knowledge-base)
// when available; fallback to legacy sqlite memory path.
if (
openclawWorkspaceDir &&
(fs.existsSync(path.join(openclawWorkspaceDir, 'memory')) ||
fs.existsSync(path.join(openclawWorkspaceDir, 'knowledge-base')))
) {
return openclawWorkspaceDir
}
return (openclawStateDir ? path.join(openclawStateDir, 'memory') : '') || path.join(defaultDataDir, 'memory')
})()
export const config = {
claudeHome:
@ -45,10 +62,11 @@ export const config = {
process.env.OPENCLAW_LOG_DIR ||
(openclawStateDir ? path.join(openclawStateDir, 'logs') : ''),
tempLogsDir: process.env.CLAWDBOT_TMP_LOG_DIR || '',
memoryDir:
process.env.OPENCLAW_MEMORY_DIR ||
(openclawStateDir ? path.join(openclawStateDir, 'memory') : '') ||
path.join(defaultDataDir, 'memory'),
memoryDir: defaultMemoryDir,
memoryAllowedPrefixes:
defaultMemoryDir === openclawWorkspaceDir
? ['memory/', 'knowledge-base/']
: [],
soulTemplatesDir:
process.env.OPENCLAW_SOUL_TEMPLATES_DIR ||
(openclawStateDir ? path.join(openclawStateDir, 'templates', 'souls') : ''),
@ -61,6 +79,7 @@ export const config = {
notifications: Number(process.env.MC_RETAIN_NOTIFICATIONS_DAYS || '60'),
pipelineRuns: Number(process.env.MC_RETAIN_PIPELINE_RUNS_DAYS || '90'),
tokenUsage: Number(process.env.MC_RETAIN_TOKEN_USAGE_DAYS || '90'),
gatewaySessions: Number(process.env.MC_RETAIN_GATEWAY_SESSIONS_DAYS || '90'),
},
}

View File

@ -6,6 +6,7 @@ import { readdirSync, statSync, unlinkSync } from 'fs'
import { logger } from './logger'
import { processWebhookRetries } from './webhooks'
import { syncClaudeSessions } from './claude-sessions'
import { pruneGatewaySessionsOlderThan } from './sessions'
const BACKUP_DIR = join(dirname(config.dbPath), 'backups')
@ -130,6 +131,11 @@ async function runCleanup(): Promise<{ ok: boolean; message: string }> {
}
}
if (ret.gatewaySessions > 0) {
const sessionCleanup = pruneGatewaySessionsOlderThan(ret.gatewaySessions)
totalDeleted += sessionCleanup.deleted
}
if (totalDeleted > 0) {
logAuditEvent({
action: 'auto_cleanup',

View File

@ -19,6 +19,32 @@ export interface GatewaySession {
active: boolean
}
function getGatewaySessionStoreFiles(): string[] {
const openclawStateDir = config.openclawStateDir
if (!openclawStateDir) return []
const agentsDir = path.join(openclawStateDir, 'agents')
if (!fs.existsSync(agentsDir)) return []
let agentDirs: string[]
try {
agentDirs = fs.readdirSync(agentsDir)
} catch {
return []
}
const files: string[] = []
for (const agentName of agentDirs) {
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
try {
if (fs.statSync(sessionsFile).isFile()) files.push(sessionsFile)
} catch {
// Skip missing or unreadable session stores.
}
}
return files
}
/**
* Read all sessions from OpenClaw agent session stores on disk.
*
@ -29,26 +55,11 @@ export interface GatewaySession {
* with session metadata as values.
*/
export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewaySession[] {
const openclawStateDir = config.openclawStateDir
if (!openclawStateDir) return []
const agentsDir = path.join(openclawStateDir, 'agents')
if (!fs.existsSync(agentsDir)) return []
const sessions: GatewaySession[] = []
const now = Date.now()
let agentDirs: string[]
try {
agentDirs = fs.readdirSync(agentsDir)
} catch {
return []
}
for (const agentName of agentDirs) {
const sessionsFile = path.join(agentsDir, agentName, 'sessions', 'sessions.json')
for (const sessionsFile of getGatewaySessionStoreFiles()) {
const agentName = path.basename(path.dirname(path.dirname(sessionsFile)))
try {
if (!fs.statSync(sessionsFile).isFile()) continue
const raw = fs.readFileSync(sessionsFile, 'utf-8')
const data = JSON.parse(raw)
@ -80,6 +91,64 @@ export function getAllGatewaySessions(activeWithinMs = 60 * 60 * 1000): GatewayS
return sessions
}
export function countStaleGatewaySessions(retentionDays: number): number {
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0
const cutoff = Date.now() - retentionDays * 86400000
let stale = 0
for (const sessionsFile of getGatewaySessionStoreFiles()) {
try {
const raw = fs.readFileSync(sessionsFile, 'utf-8')
const data = JSON.parse(raw) as Record<string, any>
for (const entry of Object.values(data)) {
const updatedAt = Number((entry as any)?.updatedAt || 0)
if (updatedAt > 0 && updatedAt < cutoff) stale += 1
}
} catch {
// Ignore malformed session stores.
}
}
return stale
}
export function pruneGatewaySessionsOlderThan(retentionDays: number): { deleted: number; filesTouched: number } {
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return { deleted: 0, filesTouched: 0 }
const cutoff = Date.now() - retentionDays * 86400000
let deleted = 0
let filesTouched = 0
for (const sessionsFile of getGatewaySessionStoreFiles()) {
try {
const raw = fs.readFileSync(sessionsFile, 'utf-8')
const data = JSON.parse(raw) as Record<string, any>
const nextEntries: Record<string, any> = {}
let fileDeleted = 0
for (const [key, entry] of Object.entries(data)) {
const updatedAt = Number((entry as any)?.updatedAt || 0)
if (updatedAt > 0 && updatedAt < cutoff) {
fileDeleted += 1
continue
}
nextEntries[key] = entry
}
if (fileDeleted > 0) {
const tempPath = `${sessionsFile}.tmp`
fs.writeFileSync(tempPath, `${JSON.stringify(nextEntries, null, 2)}\n`, 'utf-8')
fs.renameSync(tempPath, sessionsFile)
deleted += fileDeleted
filesTouched += 1
}
} catch {
// Ignore malformed/unwritable session stores.
}
}
return { deleted, filesTouched }
}
/**
* Derive agent active/idle/offline status from their sessions.
* Returns a map of agentName -> { status, lastActivity, channel }

View File

@ -358,6 +358,13 @@ export function useWebSocket() {
// Handle pong responses (any response to a ping ID counts — even errors prove the connection is alive)
if (frame.type === 'res' && frame.id?.startsWith('ping-')) {
const rawPingError = frame.error?.message || JSON.stringify(frame.error || '')
if (!frame.ok && /unknown method:\s*ping/i.test(rawPingError)) {
gatewaySupportsPingRef.current = false
missedPongsRef.current = 0
pingSentTimestamps.current.clear()
log.info('Gateway ping RPC unavailable; using passive heartbeat mode')
}
handlePong(frame.id)
return
}