+
+ {[...orgGroups.entries()].map(([segment, members]) => (
+
-
{role}
+
{segment}
({members.length})
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 54214c1..91a0775 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -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'),
},
}
diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts
index 2fe658d..3845111 100644
--- a/src/lib/scheduler.ts
+++ b/src/lib/scheduler.ts
@@ -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',
diff --git a/src/lib/sessions.ts b/src/lib/sessions.ts
index 17421fd..1fb440f 100644
--- a/src/lib/sessions.ts
+++ b/src/lib/sessions.ts
@@ -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
+ 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
+ const nextEntries: Record = {}
+ 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 }
diff --git a/src/lib/websocket.ts b/src/lib/websocket.ts
index 7c02e56..df6c9e9 100644
--- a/src/lib/websocket.ts
+++ b/src/lib/websocket.ts
@@ -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
}