fix: dynamic coordinator routing + boxed doctor parsing (#279)

* fix: route coordinator sends to live sessions and parse boxed doctor output

* feat: make coordinator routing user-configurable and deployment-agnostic

* feat: add coordinator target dropdown in settings

* feat(settings): preview live coordinator routing resolution
This commit is contained in:
nyk 2026-03-11 22:06:28 +07:00 committed by GitHub
parent f5551a2c93
commit 84e197b3dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 515 additions and 47 deletions

View File

@ -7,6 +7,7 @@ import { requireRole } from '@/lib/auth'
import { logger } from '@/lib/logger'
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing'
type ForwardInfo = {
attempted: boolean
@ -415,35 +416,54 @@ export async function POST(request: NextRequest) {
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
.get(to, workspaceId) as any
// Use explicit session key from caller if provided, then DB, then on-disk lookup
let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey
const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey
? body.sessionKey
: agent?.session_key || null
: null
const sessions = getAllGatewaySessions()
const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase()
const allAgents = isCoordinatorSend
? (db
.prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?')
.all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>)
: []
const configuredCoordinatorTarget = isCoordinatorSend
? (db
.prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'")
.get() as { value?: string } | undefined)?.value || null
: null
const coordinatorResolution = resolveCoordinatorDeliveryTarget({
to: String(to),
coordinatorAgent: COORDINATOR_AGENT,
directAgent: agent
? {
name: String(agent.name || to),
session_key: typeof agent.session_key === 'string' ? agent.session_key : null,
config: typeof agent.config === 'string' ? agent.config : null,
}
: null,
allAgents,
sessions,
explicitSessionKey,
configuredCoordinatorTarget,
})
// Use explicit session key from caller if provided, then DB, then on-disk lookup
let sessionKey: string | null = coordinatorResolution.sessionKey
// Fallback: derive session from on-disk gateway session stores
if (!sessionKey) {
const sessions = getAllGatewaySessions()
const match = sessions.find(
(s) => s.agent.toLowerCase() === String(to).toLowerCase()
(s) =>
s.agent.toLowerCase() === String(to).toLowerCase() ||
s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() ||
s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase()
)
sessionKey = match?.key || match?.sessionId || null
}
// Prefer configured openclawId when present, fallback to normalized name
let openclawAgentId: string | null = null
if (agent?.config) {
try {
const cfg = JSON.parse(agent.config)
if (cfg?.openclawId && typeof cfg.openclawId === 'string') {
openclawAgentId = cfg.openclawId
}
} catch {
// ignore parse issues
}
}
if (!openclawAgentId && typeof to === 'string') {
openclawAgentId = to.toLowerCase().replace(/\s+/g, '-')
}
let openclawAgentId: string | null = coordinatorResolution.openclawAgentId
if (!sessionKey && !openclawAgentId) {
forwardInfo.reason = 'no_active_session'

View File

@ -29,6 +29,13 @@ const settingDefinitions: Record<string, { category: string; description: string
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
// Chat
'chat.coordinator_target_agent': {
category: 'chat',
description: 'Optional coordinator routing target (agent name or openclawId). When set, coordinator inbox messages are forwarded to this agent before default/main-session fallback.',
default: '',
},
// General
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },

View File

@ -7,6 +7,8 @@ import { useNavigateToPanel } from '@/lib/navigation'
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
import { Loader } from '@/components/ui/loader'
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
import type { GatewaySession } from '@/lib/sessions'
interface Setting {
key: string
@ -25,16 +27,60 @@ interface ApiKeyInfo {
last_rotated_by: string | null
}
interface CoordinatorTargetAgent {
name: string
openclawId: string
isDefault: boolean
sessionKey: string | null
configRaw: string
}
type CoordinatorSession = GatewaySession & { source?: string }
const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase()
function parseCoordinatorTargetAgents(rawAgents: any[]): CoordinatorTargetAgent[] {
const out: CoordinatorTargetAgent[] = []
for (const raw of rawAgents || []) {
const name = typeof raw?.name === 'string' ? raw.name.trim() : ''
if (!name) continue
const config = raw?.config && typeof raw.config === 'object' ? raw.config : {}
const openclawIdRaw = typeof config.openclawId === 'string' && config.openclawId.trim()
? config.openclawId.trim()
: name
const openclawId = openclawIdRaw.toLowerCase().replace(/\s+/g, '-')
out.push({
name,
openclawId,
isDefault: config.isDefault === true,
sessionKey: typeof raw?.session_key === 'string' && raw.session_key.trim() ? raw.session_key.trim() : null,
configRaw: JSON.stringify(config),
})
}
const unique = new Map<string, CoordinatorTargetAgent>()
for (const agent of out) {
const key = agent.openclawId || agent.name.toLowerCase()
if (!unique.has(key)) unique.set(key, agent)
}
return Array.from(unique.values()).sort((a, b) => {
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
return a.name.localeCompare(b.name)
})
}
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
chat: { label: 'Chat', icon: '💬', description: 'Coordinator routing and chat behavior settings' },
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
}
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'gateway', 'custom']
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom']
// Dropdown options for subscription plan settings
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
@ -79,6 +125,8 @@ export function SettingsPanel() {
const [showSecurityScan, setShowSecurityScan] = useState(false)
const [hookProfile, setHookProfile] = useState<string>('standard')
const [hookProfileSaving, setHookProfileSaving] = useState(false)
const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState<CoordinatorTargetAgent[]>([])
const [coordinatorSessions, setCoordinatorSessions] = useState<CoordinatorSession[]>([])
// Replay onboarding state
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
@ -104,6 +152,36 @@ export function SettingsPanel() {
setTimeout(() => setFeedback(null), 3000)
}
const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => {
const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({
name: agent.name,
session_key: agent.sessionKey,
config: agent.configRaw,
}))
const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null
const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway')
const resolved = resolveCoordinatorDeliveryTarget({
to: COORDINATOR_AGENT,
coordinatorAgent: COORDINATOR_AGENT,
directAgent,
allAgents,
sessions: gatewaySessions,
configuredCoordinatorTarget: configuredTarget || null,
})
const viaLabel: Record<string, string> = {
configured: 'configured target',
default: 'default agent',
main_session: 'live :main session',
direct: 'coordinator record',
fallback: 'fallback',
}
const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}`
return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.`
}, [coordinatorTargetAgents, coordinatorSessions])
const fetchSettings = useCallback(async () => {
try {
const res = await fetch('/api/settings')
@ -126,6 +204,45 @@ export function SettingsPanel() {
// Load hook profile from settings
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
if (hpSetting) setHookProfile(hpSetting.value)
// Load agent options for coordinator routing dropdown
try {
const agentsRes = await fetch('/api/agents?limit=200')
if (agentsRes.ok) {
const agentsData = await agentsRes.json()
setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || []))
}
} catch {
// non-critical
}
// Load live sessions to preview coordinator routing resolution
try {
const sessionsRes = await fetch('/api/sessions')
if (sessionsRes.ok) {
const sessionsData = await sessionsRes.json()
const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions)
? sessionsData.sessions.map((session: any) => ({
key: String(session?.key || ''),
agent: String(session?.agent || ''),
source: typeof session?.source === 'string' ? session.source : undefined,
sessionId: String(session?.id || session?.key || ''),
updatedAt: Number(session?.lastActivity || session?.startTime || 0),
chatType: String(session?.kind || 'unknown'),
channel: String(session?.channel || ''),
model: String(session?.model || ''),
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
contextTokens: 0,
active: Boolean(session?.active),
})).filter((session: CoordinatorSession) => session.key && session.agent)
: []
setCoordinatorSessions(mapped)
}
} catch {
// non-critical
}
} catch {
setError('Failed to load settings')
} finally {
@ -735,7 +852,19 @@ export function SettingsPanel() {
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
const isBooleanish = setting.value === 'true' || setting.value === 'false'
const isNumeric = /^\d+$/.test(setting.value)
const dropdownOptions = subscriptionDropdowns[setting.key]
const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent'
? [
{ label: 'Auto (default/main-session fallback)', value: '' },
...coordinatorTargetAgents.map(agent => ({
label: `${agent.name}${agent.isDefault ? ' (default)' : ''}${agent.openclawId}`,
value: agent.openclawId,
})),
]
: null
const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key]
const coordinatorPreview = setting.key === 'chat.coordinator_target_agent'
? getCoordinatorResolutionPreview(currentValue)
: null
const shortKey = setting.key.split('.').pop() || setting.key
return (
@ -760,18 +889,22 @@ export function SettingsPanel() {
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{dropdownOptions ? (
<select
value={currentValue}
onChange={e => handleEdit(setting.key, e.target.value)}
className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
>
{dropdownOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
) : isBooleanish ? (
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="flex items-center gap-2">
{dropdownOptions ? (
<select
value={currentValue}
onChange={e => handleEdit(setting.key, e.target.value)}
className="w-64 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
>
{dropdownOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
{currentValue && !dropdownOptions.some(opt => opt.value === currentValue) && (
<option value={currentValue}>Custom: {currentValue}</option>
)}
</select>
) : isBooleanish ? (
<button
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
@ -798,19 +931,23 @@ export function SettingsPanel() {
/>
)}
{!setting.is_default && (
<Button
onClick={() => handleReset(setting.key)}
title="Reset to default"
variant="ghost"
size="icon-xs"
className="w-6 h-6"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
{!setting.is_default && (
<Button
onClick={() => handleReset(setting.key)}
title="Reset to default"
variant="ghost"
size="icon-xs"
className="w-6 h-6"
>
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
)}
</div>
{coordinatorPreview && (
<p className="text-2xs text-muted-foreground max-w-72 text-right">{coordinatorPreview}</p>
)}
</div>
</div>

View File

@ -0,0 +1,124 @@
import { describe, expect, it } from 'vitest'
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
import type { GatewaySession } from '@/lib/sessions'
function mkSession(agent: string, key: string): GatewaySession {
return {
key,
agent,
sessionId: `${agent}-session`,
updatedAt: Date.now(),
chatType: 'direct',
channel: 'test',
model: 'test-model',
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
contextTokens: 0,
active: true,
}
}
describe('resolveCoordinatorDeliveryTarget', () => {
it('returns direct resolution when target agent exists', () => {
const directAgent: CoordinatorAgentRecord = {
name: 'dev',
session_key: 'agent:dev:main',
config: JSON.stringify({ openclawId: 'dev' }),
}
const resolved = resolveCoordinatorDeliveryTarget({
to: 'dev',
coordinatorAgent: 'Coordinator',
directAgent,
allAgents: [],
sessions: [mkSession('dev', 'agent:dev:main')],
})
expect(resolved).toEqual({
deliveryName: 'dev',
sessionKey: 'agent:dev:main',
openclawAgentId: 'dev',
resolvedBy: 'direct',
})
})
it('resolves coordinator to explicitly configured target when present', () => {
const allAgents: CoordinatorAgentRecord[] = [
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
]
const resolved = resolveCoordinatorDeliveryTarget({
to: 'Coordinator',
coordinatorAgent: 'Coordinator',
directAgent: null,
allAgents,
sessions: [mkSession('jarv', 'agent:jarv:main')],
configuredCoordinatorTarget: 'jarv',
})
expect(resolved).toEqual({
deliveryName: 'jarv',
sessionKey: 'agent:jarv:main',
openclawAgentId: 'jarv',
resolvedBy: 'configured',
})
})
it('resolves coordinator to default agent when no explicit target is configured', () => {
const allAgents: CoordinatorAgentRecord[] = [
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
]
const resolved = resolveCoordinatorDeliveryTarget({
to: 'Coordinator',
coordinatorAgent: 'Coordinator',
directAgent: null,
allAgents,
sessions: [mkSession('dev', 'agent:dev:main')],
})
expect(resolved).toEqual({
deliveryName: 'dev',
sessionKey: 'agent:dev:main',
openclawAgentId: 'dev',
resolvedBy: 'default',
})
})
it('resolves coordinator to first live main session when no default agent exists', () => {
const resolved = resolveCoordinatorDeliveryTarget({
to: 'Coordinator',
coordinatorAgent: 'Coordinator',
directAgent: null,
allAgents: [{ name: 'admin', config: JSON.stringify({ openclawId: 'admin' }) }],
sessions: [mkSession('jarv', 'agent:jarv:main')],
})
expect(resolved).toEqual({
deliveryName: 'jarv',
sessionKey: 'agent:jarv:main',
openclawAgentId: 'jarv',
resolvedBy: 'main_session',
})
})
it('falls back to normalized destination when nothing else matches', () => {
const resolved = resolveCoordinatorDeliveryTarget({
to: 'Coordinator Team',
coordinatorAgent: 'Coordinator',
directAgent: null,
allAgents: [],
sessions: [],
})
expect(resolved).toEqual({
deliveryName: 'Coordinator Team',
sessionKey: null,
openclawAgentId: 'coordinator-team',
resolvedBy: 'fallback',
})
})
})

View File

@ -89,6 +89,28 @@ Run "openclaw doctor --fix" to apply changes.
expect(result.raw).not.toContain('Multiple state directories detected')
})
it('parses state integrity blocks when lines are prefixed by box-drawing gutters', () => {
const result = parseOpenClawDoctorOutput(`
OpenClaw doctor
State integrity
- Multiple state directories detected. This can split session history.
- $OPENCLAW_HOME/.openclaw
- /home/nefes/.openclaw
Active state dir: $OPENCLAW_HOME
- Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.
Run "openclaw doctor --fix" to apply changes.
`, 0, { stateDir: '/home/openclaw/.openclaw' })
expect(result.level).toBe('warning')
expect(result.category).toBe('state')
expect(result.issues).toEqual([
'Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.',
])
expect(result.raw).not.toContain('/home/nefes/.openclaw')
expect(result.raw).not.toContain('Multiple state directories detected')
})
it('marks clean output as healthy', () => {
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)

View File

@ -0,0 +1,155 @@
import type { GatewaySession } from './sessions'
export interface CoordinatorAgentRecord {
name: string
session_key?: string | null
config?: string | null
}
export interface ResolvedCoordinatorTarget {
deliveryName: string
sessionKey: string | null
openclawAgentId: string | null
resolvedBy: 'direct' | 'configured' | 'default' | 'main_session' | 'fallback'
}
function normalizeName(value: string | null | undefined): string {
return String(value || '').trim().toLowerCase()
}
function normalizeOpenClawId(value: string | null | undefined): string {
return normalizeName(value).replace(/\s+/g, '-')
}
function parseConfig(raw: string | null | undefined): Record<string, unknown> {
if (!raw) return {}
try {
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function getConfigOpenClawId(agent: CoordinatorAgentRecord): string | null {
const parsed = parseConfig(agent.config)
return typeof parsed.openclawId === 'string' && parsed.openclawId.trim()
? parsed.openclawId.trim()
: null
}
function getConfigIsDefault(agent: CoordinatorAgentRecord): boolean {
const parsed = parseConfig(agent.config)
return parsed.isDefault === true
}
function findSessionForAgent(
agent: CoordinatorAgentRecord,
sessions: GatewaySession[],
): GatewaySession | undefined {
const name = normalizeName(agent.name)
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
return sessions.find((session) => {
const sessionAgent = normalizeName(session.agent)
return sessionAgent === name || sessionAgent === openclawId
})
}
function resolveConfiguredCoordinatorTarget(
preferredTarget: string,
allAgents: CoordinatorAgentRecord[],
sessions: GatewaySession[],
): CoordinatorAgentRecord | null {
const wanted = normalizeName(preferredTarget)
if (!wanted) return null
return allAgents.find((agent) => {
const byName = normalizeName(agent.name) === wanted
const byOpenClawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) === wanted
const session = findSessionForAgent(agent, sessions)
const bySessionAgent = session ? normalizeName(session.agent) === wanted : false
return byName || byOpenClawId || bySessionAgent
}) || null
}
export function resolveCoordinatorDeliveryTarget(params: {
to: string
coordinatorAgent: string
directAgent: CoordinatorAgentRecord | null
allAgents: CoordinatorAgentRecord[]
sessions: GatewaySession[]
explicitSessionKey?: string | null
configuredCoordinatorTarget?: string | null
}): ResolvedCoordinatorTarget {
const normalizedTo = normalizeName(params.to)
const normalizedCoordinatorAgent = normalizeName(params.coordinatorAgent)
const explicitSessionKey = params.explicitSessionKey?.trim() || null
const buildResult = (
agent: CoordinatorAgentRecord,
resolvedBy: ResolvedCoordinatorTarget['resolvedBy'],
): ResolvedCoordinatorTarget => {
const openclawAgentId = getConfigOpenClawId(agent) || normalizeOpenClawId(agent.name)
const sessionKey =
explicitSessionKey ||
agent.session_key?.trim() ||
findSessionForAgent(agent, params.sessions)?.key ||
null
return {
deliveryName: agent.name,
sessionKey,
openclawAgentId,
resolvedBy,
}
}
if (normalizedTo === normalizedCoordinatorAgent) {
const configuredTarget = (params.configuredCoordinatorTarget || '').trim()
if (configuredTarget) {
const configuredAgent = resolveConfiguredCoordinatorTarget(configuredTarget, params.allAgents, params.sessions)
if (configuredAgent) {
return buildResult(configuredAgent, 'configured')
}
}
const defaultAgent = params.allAgents.find(getConfigIsDefault)
if (defaultAgent) {
return buildResult(defaultAgent, 'default')
}
const mainSession = params.sessions.find((session) => /:main$/i.test(session.key))
if (mainSession) {
const matchingAgent = params.allAgents.find((agent) => {
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
const agentName = normalizeName(agent.name)
const sessionAgent = normalizeName(mainSession.agent)
return sessionAgent === agentName || sessionAgent === openclawId
})
return {
deliveryName: matchingAgent?.name || mainSession.agent,
sessionKey: explicitSessionKey || mainSession.key || null,
openclawAgentId:
getConfigOpenClawId(matchingAgent || { name: mainSession.agent }) ||
normalizeOpenClawId(mainSession.agent),
resolvedBy: 'main_session',
}
}
if (params.directAgent) {
return buildResult(params.directAgent, 'direct')
}
}
if (params.directAgent) {
return buildResult(params.directAgent, 'direct')
}
return {
deliveryName: params.to,
sessionKey: explicitSessionKey,
openclawAgentId: normalizeOpenClawId(params.to),
resolvedBy: 'fallback',
}
}

View File

@ -14,7 +14,10 @@ export interface OpenClawDoctorStatus {
}
function normalizeLine(line: string): string {
return line.replace(/\u001b\[[0-9;]*m/g, '').trim()
return line
.replace(/\u001b\[[0-9;]*m/g, '')
.replace(/^[\s│┃║┆┊╎╏]+/, '')
.trim()
}
function isSessionAgingLine(line: string): boolean {