Spaces:
Runtime error
Runtime error
nyk commited on
fix: dynamic coordinator routing + boxed doctor parsing (#279)
Browse files* 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
- src/app/api/chat/messages/route.ts +39 -19
- src/app/api/settings/route.ts +7 -0
- src/components/panels/settings-panel.tsx +164 -27
- src/lib/__tests__/coordinator-routing.test.ts +124 -0
- src/lib/__tests__/openclaw-doctor.test.ts +22 -0
- src/lib/coordinator-routing.ts +155 -0
- src/lib/openclaw-doctor.ts +4 -1
src/app/api/chat/messages/route.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { requireRole } from '@/lib/auth'
|
|
| 7 |
import { logger } from '@/lib/logger'
|
| 8 |
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
|
| 9 |
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
|
|
|
|
| 10 |
|
| 11 |
type ForwardInfo = {
|
| 12 |
attempted: boolean
|
|
@@ -415,35 +416,54 @@ export async function POST(request: NextRequest) {
|
|
| 415 |
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
|
| 416 |
.get(to, workspaceId) as any
|
| 417 |
|
| 418 |
-
|
| 419 |
-
let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey
|
| 420 |
? body.sessionKey
|
| 421 |
-
:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
// Fallback: derive session from on-disk gateway session stores
|
| 424 |
if (!sessionKey) {
|
| 425 |
-
const sessions = getAllGatewaySessions()
|
| 426 |
const match = sessions.find(
|
| 427 |
-
(s) =>
|
|
|
|
|
|
|
|
|
|
| 428 |
)
|
| 429 |
sessionKey = match?.key || match?.sessionId || null
|
| 430 |
}
|
| 431 |
|
| 432 |
// Prefer configured openclawId when present, fallback to normalized name
|
| 433 |
-
let openclawAgentId: string | null =
|
| 434 |
-
if (agent?.config) {
|
| 435 |
-
try {
|
| 436 |
-
const cfg = JSON.parse(agent.config)
|
| 437 |
-
if (cfg?.openclawId && typeof cfg.openclawId === 'string') {
|
| 438 |
-
openclawAgentId = cfg.openclawId
|
| 439 |
-
}
|
| 440 |
-
} catch {
|
| 441 |
-
// ignore parse issues
|
| 442 |
-
}
|
| 443 |
-
}
|
| 444 |
-
if (!openclawAgentId && typeof to === 'string') {
|
| 445 |
-
openclawAgentId = to.toLowerCase().replace(/\s+/g, '-')
|
| 446 |
-
}
|
| 447 |
|
| 448 |
if (!sessionKey && !openclawAgentId) {
|
| 449 |
forwardInfo.reason = 'no_active_session'
|
|
|
|
| 7 |
import { logger } from '@/lib/logger'
|
| 8 |
import { scanForInjection, sanitizeForPrompt } from '@/lib/injection-guard'
|
| 9 |
import { callOpenClawGateway } from '@/lib/openclaw-gateway'
|
| 10 |
+
import { resolveCoordinatorDeliveryTarget } from '@/lib/coordinator-routing'
|
| 11 |
|
| 12 |
type ForwardInfo = {
|
| 13 |
attempted: boolean
|
|
|
|
| 416 |
.prepare('SELECT * FROM agents WHERE lower(name) = lower(?) AND workspace_id = ?')
|
| 417 |
.get(to, workspaceId) as any
|
| 418 |
|
| 419 |
+
const explicitSessionKey = typeof body.sessionKey === 'string' && body.sessionKey
|
|
|
|
| 420 |
? body.sessionKey
|
| 421 |
+
: null
|
| 422 |
+
const sessions = getAllGatewaySessions()
|
| 423 |
+
const isCoordinatorSend = String(to).toLowerCase() === COORDINATOR_AGENT.toLowerCase()
|
| 424 |
+
const allAgents = isCoordinatorSend
|
| 425 |
+
? (db
|
| 426 |
+
.prepare('SELECT name, session_key, config FROM agents WHERE workspace_id = ?')
|
| 427 |
+
.all(workspaceId) as Array<{ name: string; session_key?: string | null; config?: string | null }>)
|
| 428 |
+
: []
|
| 429 |
+
const configuredCoordinatorTarget = isCoordinatorSend
|
| 430 |
+
? (db
|
| 431 |
+
.prepare("SELECT value FROM settings WHERE key = 'chat.coordinator_target_agent'")
|
| 432 |
+
.get() as { value?: string } | undefined)?.value || null
|
| 433 |
+
: null
|
| 434 |
+
|
| 435 |
+
const coordinatorResolution = resolveCoordinatorDeliveryTarget({
|
| 436 |
+
to: String(to),
|
| 437 |
+
coordinatorAgent: COORDINATOR_AGENT,
|
| 438 |
+
directAgent: agent
|
| 439 |
+
? {
|
| 440 |
+
name: String(agent.name || to),
|
| 441 |
+
session_key: typeof agent.session_key === 'string' ? agent.session_key : null,
|
| 442 |
+
config: typeof agent.config === 'string' ? agent.config : null,
|
| 443 |
+
}
|
| 444 |
+
: null,
|
| 445 |
+
allAgents,
|
| 446 |
+
sessions,
|
| 447 |
+
explicitSessionKey,
|
| 448 |
+
configuredCoordinatorTarget,
|
| 449 |
+
})
|
| 450 |
+
|
| 451 |
+
// Use explicit session key from caller if provided, then DB, then on-disk lookup
|
| 452 |
+
let sessionKey: string | null = coordinatorResolution.sessionKey
|
| 453 |
|
| 454 |
// Fallback: derive session from on-disk gateway session stores
|
| 455 |
if (!sessionKey) {
|
|
|
|
| 456 |
const match = sessions.find(
|
| 457 |
+
(s) =>
|
| 458 |
+
s.agent.toLowerCase() === String(to).toLowerCase() ||
|
| 459 |
+
s.agent.toLowerCase() === coordinatorResolution.deliveryName.toLowerCase() ||
|
| 460 |
+
s.agent.toLowerCase() === String(coordinatorResolution.openclawAgentId || '').toLowerCase()
|
| 461 |
)
|
| 462 |
sessionKey = match?.key || match?.sessionId || null
|
| 463 |
}
|
| 464 |
|
| 465 |
// Prefer configured openclawId when present, fallback to normalized name
|
| 466 |
+
let openclawAgentId: string | null = coordinatorResolution.openclawAgentId
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
|
| 468 |
if (!sessionKey && !openclawAgentId) {
|
| 469 |
forwardInfo.reason = 'no_active_session'
|
src/app/api/settings/route.ts
CHANGED
|
@@ -29,6 +29,13 @@ const settingDefinitions: Record<string, { category: string; description: string
|
|
| 29 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
| 30 |
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
// General
|
| 33 |
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
|
| 34 |
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },
|
|
|
|
| 29 |
'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost },
|
| 30 |
'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) },
|
| 31 |
|
| 32 |
+
// Chat
|
| 33 |
+
'chat.coordinator_target_agent': {
|
| 34 |
+
category: 'chat',
|
| 35 |
+
description: 'Optional coordinator routing target (agent name or openclawId). When set, coordinator inbox messages are forwarded to this agent before default/main-session fallback.',
|
| 36 |
+
default: '',
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
// General
|
| 40 |
'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' },
|
| 41 |
'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' },
|
src/components/panels/settings-panel.tsx
CHANGED
|
@@ -7,6 +7,8 @@ import { useNavigateToPanel } from '@/lib/navigation'
|
|
| 7 |
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
|
| 8 |
import { Loader } from '@/components/ui/loader'
|
| 9 |
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
|
|
|
|
|
|
|
| 10 |
|
| 11 |
interface Setting {
|
| 12 |
key: string
|
|
@@ -25,16 +27,60 @@ interface ApiKeyInfo {
|
|
| 25 |
last_rotated_by: string | null
|
| 26 |
}
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
|
| 29 |
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
|
| 30 |
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
|
| 31 |
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
|
|
|
|
| 32 |
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
|
| 33 |
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
|
| 34 |
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
|
| 35 |
}
|
| 36 |
|
| 37 |
-
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'gateway', 'custom']
|
| 38 |
|
| 39 |
// Dropdown options for subscription plan settings
|
| 40 |
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
|
|
@@ -79,6 +125,8 @@ export function SettingsPanel() {
|
|
| 79 |
const [showSecurityScan, setShowSecurityScan] = useState(false)
|
| 80 |
const [hookProfile, setHookProfile] = useState<string>('standard')
|
| 81 |
const [hookProfileSaving, setHookProfileSaving] = useState(false)
|
|
|
|
|
|
|
| 82 |
|
| 83 |
// Replay onboarding state
|
| 84 |
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
|
|
@@ -104,6 +152,36 @@ export function SettingsPanel() {
|
|
| 104 |
setTimeout(() => setFeedback(null), 3000)
|
| 105 |
}
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
const fetchSettings = useCallback(async () => {
|
| 108 |
try {
|
| 109 |
const res = await fetch('/api/settings')
|
|
@@ -126,6 +204,45 @@ export function SettingsPanel() {
|
|
| 126 |
// Load hook profile from settings
|
| 127 |
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
|
| 128 |
if (hpSetting) setHookProfile(hpSetting.value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
} catch {
|
| 130 |
setError('Failed to load settings')
|
| 131 |
} finally {
|
|
@@ -735,7 +852,19 @@ export function SettingsPanel() {
|
|
| 735 |
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
|
| 736 |
const isBooleanish = setting.value === 'true' || setting.value === 'false'
|
| 737 |
const isNumeric = /^\d+$/.test(setting.value)
|
| 738 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 739 |
const shortKey = setting.key.split('.').pop() || setting.key
|
| 740 |
|
| 741 |
return (
|
|
@@ -760,18 +889,22 @@ export function SettingsPanel() {
|
|
| 760 |
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
|
| 761 |
</div>
|
| 762 |
|
| 763 |
-
<div className="flex items-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
<button
|
| 776 |
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
|
| 777 |
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
|
|
@@ -798,19 +931,23 @@ export function SettingsPanel() {
|
|
| 798 |
/>
|
| 799 |
)}
|
| 800 |
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
)}
|
| 815 |
</div>
|
| 816 |
</div>
|
|
|
|
| 7 |
import { SecurityScanCard } from '@/components/onboarding/security-scan-card'
|
| 8 |
import { Loader } from '@/components/ui/loader'
|
| 9 |
import { clearOnboardingDismissedThisSession, clearOnboardingReplayFromStart } from '@/lib/onboarding-session'
|
| 10 |
+
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
| 11 |
+
import type { GatewaySession } from '@/lib/sessions'
|
| 12 |
|
| 13 |
interface Setting {
|
| 14 |
key: string
|
|
|
|
| 27 |
last_rotated_by: string | null
|
| 28 |
}
|
| 29 |
|
| 30 |
+
interface CoordinatorTargetAgent {
|
| 31 |
+
name: string
|
| 32 |
+
openclawId: string
|
| 33 |
+
isDefault: boolean
|
| 34 |
+
sessionKey: string | null
|
| 35 |
+
configRaw: string
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
type CoordinatorSession = GatewaySession & { source?: string }
|
| 39 |
+
|
| 40 |
+
const COORDINATOR_AGENT = (process.env.NEXT_PUBLIC_COORDINATOR_AGENT || 'coordinator').toLowerCase()
|
| 41 |
+
|
| 42 |
+
function parseCoordinatorTargetAgents(rawAgents: any[]): CoordinatorTargetAgent[] {
|
| 43 |
+
const out: CoordinatorTargetAgent[] = []
|
| 44 |
+
for (const raw of rawAgents || []) {
|
| 45 |
+
const name = typeof raw?.name === 'string' ? raw.name.trim() : ''
|
| 46 |
+
if (!name) continue
|
| 47 |
+
const config = raw?.config && typeof raw.config === 'object' ? raw.config : {}
|
| 48 |
+
const openclawIdRaw = typeof config.openclawId === 'string' && config.openclawId.trim()
|
| 49 |
+
? config.openclawId.trim()
|
| 50 |
+
: name
|
| 51 |
+
const openclawId = openclawIdRaw.toLowerCase().replace(/\s+/g, '-')
|
| 52 |
+
out.push({
|
| 53 |
+
name,
|
| 54 |
+
openclawId,
|
| 55 |
+
isDefault: config.isDefault === true,
|
| 56 |
+
sessionKey: typeof raw?.session_key === 'string' && raw.session_key.trim() ? raw.session_key.trim() : null,
|
| 57 |
+
configRaw: JSON.stringify(config),
|
| 58 |
+
})
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const unique = new Map<string, CoordinatorTargetAgent>()
|
| 62 |
+
for (const agent of out) {
|
| 63 |
+
const key = agent.openclawId || agent.name.toLowerCase()
|
| 64 |
+
if (!unique.has(key)) unique.set(key, agent)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
return Array.from(unique.values()).sort((a, b) => {
|
| 68 |
+
if (a.isDefault !== b.isDefault) return a.isDefault ? -1 : 1
|
| 69 |
+
return a.name.localeCompare(b.name)
|
| 70 |
+
})
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
const categoryLabels: Record<string, { label: string; icon: string; description: string }> = {
|
| 74 |
general: { label: 'General', icon: '⚙', description: 'Core Mission Control settings' },
|
| 75 |
security: { label: 'Security', icon: '🔑', description: 'API key management and security settings' },
|
| 76 |
retention: { label: 'Data Retention', icon: '🗄', description: 'How long data is kept before cleanup' },
|
| 77 |
+
chat: { label: 'Chat', icon: '💬', description: 'Coordinator routing and chat behavior settings' },
|
| 78 |
gateway: { label: 'Gateway', icon: '🔌', description: 'OpenClaw gateway connection settings' },
|
| 79 |
profiles: { label: 'Security Profiles', icon: 'shield', description: 'Hook profile controls security scanning strictness' },
|
| 80 |
custom: { label: 'Custom', icon: '🔧', description: 'User-defined settings' },
|
| 81 |
}
|
| 82 |
|
| 83 |
+
const categoryOrder = ['general', 'security', 'profiles', 'retention', 'chat', 'gateway', 'custom']
|
| 84 |
|
| 85 |
// Dropdown options for subscription plan settings
|
| 86 |
const subscriptionDropdowns: Record<string, { label: string; value: string }[]> = {
|
|
|
|
| 125 |
const [showSecurityScan, setShowSecurityScan] = useState(false)
|
| 126 |
const [hookProfile, setHookProfile] = useState<string>('standard')
|
| 127 |
const [hookProfileSaving, setHookProfileSaving] = useState(false)
|
| 128 |
+
const [coordinatorTargetAgents, setCoordinatorTargetAgents] = useState<CoordinatorTargetAgent[]>([])
|
| 129 |
+
const [coordinatorSessions, setCoordinatorSessions] = useState<CoordinatorSession[]>([])
|
| 130 |
|
| 131 |
// Replay onboarding state
|
| 132 |
const [replayingOnboarding, setReplayingOnboarding] = useState(false)
|
|
|
|
| 152 |
setTimeout(() => setFeedback(null), 3000)
|
| 153 |
}
|
| 154 |
|
| 155 |
+
const getCoordinatorResolutionPreview = useCallback((configuredTarget: string) => {
|
| 156 |
+
const allAgents: CoordinatorAgentRecord[] = coordinatorTargetAgents.map(agent => ({
|
| 157 |
+
name: agent.name,
|
| 158 |
+
session_key: agent.sessionKey,
|
| 159 |
+
config: agent.configRaw,
|
| 160 |
+
}))
|
| 161 |
+
const directAgent = allAgents.find(agent => agent.name.toLowerCase() === COORDINATOR_AGENT) || null
|
| 162 |
+
const gatewaySessions = coordinatorSessions.filter(session => (session.source || 'gateway') === 'gateway')
|
| 163 |
+
|
| 164 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 165 |
+
to: COORDINATOR_AGENT,
|
| 166 |
+
coordinatorAgent: COORDINATOR_AGENT,
|
| 167 |
+
directAgent,
|
| 168 |
+
allAgents,
|
| 169 |
+
sessions: gatewaySessions,
|
| 170 |
+
configuredCoordinatorTarget: configuredTarget || null,
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
const viaLabel: Record<string, string> = {
|
| 174 |
+
configured: 'configured target',
|
| 175 |
+
default: 'default agent',
|
| 176 |
+
main_session: 'live :main session',
|
| 177 |
+
direct: 'coordinator record',
|
| 178 |
+
fallback: 'fallback',
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
const targetLabel = `${resolved.deliveryName}${resolved.openclawAgentId ? ` (${resolved.openclawAgentId})` : ''}`
|
| 182 |
+
return `Resolves now to ${targetLabel} via ${viaLabel[resolved.resolvedBy] || resolved.resolvedBy}.`
|
| 183 |
+
}, [coordinatorTargetAgents, coordinatorSessions])
|
| 184 |
+
|
| 185 |
const fetchSettings = useCallback(async () => {
|
| 186 |
try {
|
| 187 |
const res = await fetch('/api/settings')
|
|
|
|
| 204 |
// Load hook profile from settings
|
| 205 |
const hpSetting = (data.settings || []).find((s: Setting) => s.key === 'hook_profile')
|
| 206 |
if (hpSetting) setHookProfile(hpSetting.value)
|
| 207 |
+
|
| 208 |
+
// Load agent options for coordinator routing dropdown
|
| 209 |
+
try {
|
| 210 |
+
const agentsRes = await fetch('/api/agents?limit=200')
|
| 211 |
+
if (agentsRes.ok) {
|
| 212 |
+
const agentsData = await agentsRes.json()
|
| 213 |
+
setCoordinatorTargetAgents(parseCoordinatorTargetAgents(agentsData.agents || []))
|
| 214 |
+
}
|
| 215 |
+
} catch {
|
| 216 |
+
// non-critical
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
// Load live sessions to preview coordinator routing resolution
|
| 220 |
+
try {
|
| 221 |
+
const sessionsRes = await fetch('/api/sessions')
|
| 222 |
+
if (sessionsRes.ok) {
|
| 223 |
+
const sessionsData = await sessionsRes.json()
|
| 224 |
+
const mapped: CoordinatorSession[] = Array.isArray(sessionsData.sessions)
|
| 225 |
+
? sessionsData.sessions.map((session: any) => ({
|
| 226 |
+
key: String(session?.key || ''),
|
| 227 |
+
agent: String(session?.agent || ''),
|
| 228 |
+
source: typeof session?.source === 'string' ? session.source : undefined,
|
| 229 |
+
sessionId: String(session?.id || session?.key || ''),
|
| 230 |
+
updatedAt: Number(session?.lastActivity || session?.startTime || 0),
|
| 231 |
+
chatType: String(session?.kind || 'unknown'),
|
| 232 |
+
channel: String(session?.channel || ''),
|
| 233 |
+
model: String(session?.model || ''),
|
| 234 |
+
totalTokens: 0,
|
| 235 |
+
inputTokens: 0,
|
| 236 |
+
outputTokens: 0,
|
| 237 |
+
contextTokens: 0,
|
| 238 |
+
active: Boolean(session?.active),
|
| 239 |
+
})).filter((session: CoordinatorSession) => session.key && session.agent)
|
| 240 |
+
: []
|
| 241 |
+
setCoordinatorSessions(mapped)
|
| 242 |
+
}
|
| 243 |
+
} catch {
|
| 244 |
+
// non-critical
|
| 245 |
+
}
|
| 246 |
} catch {
|
| 247 |
setError('Failed to load settings')
|
| 248 |
} finally {
|
|
|
|
| 852 |
const isChanged = edits[setting.key] !== undefined && edits[setting.key] !== setting.value
|
| 853 |
const isBooleanish = setting.value === 'true' || setting.value === 'false'
|
| 854 |
const isNumeric = /^\d+$/.test(setting.value)
|
| 855 |
+
const coordinatorTargetOptions = setting.key === 'chat.coordinator_target_agent'
|
| 856 |
+
? [
|
| 857 |
+
{ label: 'Auto (default/main-session fallback)', value: '' },
|
| 858 |
+
...coordinatorTargetAgents.map(agent => ({
|
| 859 |
+
label: `${agent.name}${agent.isDefault ? ' (default)' : ''} — ${agent.openclawId}`,
|
| 860 |
+
value: agent.openclawId,
|
| 861 |
+
})),
|
| 862 |
+
]
|
| 863 |
+
: null
|
| 864 |
+
const dropdownOptions = coordinatorTargetOptions || subscriptionDropdowns[setting.key]
|
| 865 |
+
const coordinatorPreview = setting.key === 'chat.coordinator_target_agent'
|
| 866 |
+
? getCoordinatorResolutionPreview(currentValue)
|
| 867 |
+
: null
|
| 868 |
const shortKey = setting.key.split('.').pop() || setting.key
|
| 869 |
|
| 870 |
return (
|
|
|
|
| 889 |
<p className="text-2xs text-muted-foreground/60 mt-1 font-mono">{setting.key}</p>
|
| 890 |
</div>
|
| 891 |
|
| 892 |
+
<div className="flex flex-col items-end gap-1 shrink-0">
|
| 893 |
+
<div className="flex items-center gap-2">
|
| 894 |
+
{dropdownOptions ? (
|
| 895 |
+
<select
|
| 896 |
+
value={currentValue}
|
| 897 |
+
onChange={e => handleEdit(setting.key, e.target.value)}
|
| 898 |
+
className="w-64 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
|
| 899 |
+
>
|
| 900 |
+
{dropdownOptions.map(opt => (
|
| 901 |
+
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
| 902 |
+
))}
|
| 903 |
+
{currentValue && !dropdownOptions.some(opt => opt.value === currentValue) && (
|
| 904 |
+
<option value={currentValue}>Custom: {currentValue}</option>
|
| 905 |
+
)}
|
| 906 |
+
</select>
|
| 907 |
+
) : isBooleanish ? (
|
| 908 |
<button
|
| 909 |
onClick={() => handleEdit(setting.key, currentValue === 'true' ? 'false' : 'true')}
|
| 910 |
className={`w-10 h-5 rounded-full relative transition-colors select-none ${
|
|
|
|
| 931 |
/>
|
| 932 |
)}
|
| 933 |
|
| 934 |
+
{!setting.is_default && (
|
| 935 |
+
<Button
|
| 936 |
+
onClick={() => handleReset(setting.key)}
|
| 937 |
+
title="Reset to default"
|
| 938 |
+
variant="ghost"
|
| 939 |
+
size="icon-xs"
|
| 940 |
+
className="w-6 h-6"
|
| 941 |
+
>
|
| 942 |
+
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 943 |
+
<path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
|
| 944 |
+
<path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
|
| 945 |
+
</svg>
|
| 946 |
+
</Button>
|
| 947 |
+
)}
|
| 948 |
+
</div>
|
| 949 |
+
{coordinatorPreview && (
|
| 950 |
+
<p className="text-2xs text-muted-foreground max-w-72 text-right">{coordinatorPreview}</p>
|
| 951 |
)}
|
| 952 |
</div>
|
| 953 |
</div>
|
src/lib/__tests__/coordinator-routing.test.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, it } from 'vitest'
|
| 2 |
+
import { resolveCoordinatorDeliveryTarget, type CoordinatorAgentRecord } from '@/lib/coordinator-routing'
|
| 3 |
+
import type { GatewaySession } from '@/lib/sessions'
|
| 4 |
+
|
| 5 |
+
function mkSession(agent: string, key: string): GatewaySession {
|
| 6 |
+
return {
|
| 7 |
+
key,
|
| 8 |
+
agent,
|
| 9 |
+
sessionId: `${agent}-session`,
|
| 10 |
+
updatedAt: Date.now(),
|
| 11 |
+
chatType: 'direct',
|
| 12 |
+
channel: 'test',
|
| 13 |
+
model: 'test-model',
|
| 14 |
+
totalTokens: 0,
|
| 15 |
+
inputTokens: 0,
|
| 16 |
+
outputTokens: 0,
|
| 17 |
+
contextTokens: 0,
|
| 18 |
+
active: true,
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
describe('resolveCoordinatorDeliveryTarget', () => {
|
| 23 |
+
it('returns direct resolution when target agent exists', () => {
|
| 24 |
+
const directAgent: CoordinatorAgentRecord = {
|
| 25 |
+
name: 'dev',
|
| 26 |
+
session_key: 'agent:dev:main',
|
| 27 |
+
config: JSON.stringify({ openclawId: 'dev' }),
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 31 |
+
to: 'dev',
|
| 32 |
+
coordinatorAgent: 'Coordinator',
|
| 33 |
+
directAgent,
|
| 34 |
+
allAgents: [],
|
| 35 |
+
sessions: [mkSession('dev', 'agent:dev:main')],
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
expect(resolved).toEqual({
|
| 39 |
+
deliveryName: 'dev',
|
| 40 |
+
sessionKey: 'agent:dev:main',
|
| 41 |
+
openclawAgentId: 'dev',
|
| 42 |
+
resolvedBy: 'direct',
|
| 43 |
+
})
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
it('resolves coordinator to explicitly configured target when present', () => {
|
| 47 |
+
const allAgents: CoordinatorAgentRecord[] = [
|
| 48 |
+
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
| 49 |
+
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 53 |
+
to: 'Coordinator',
|
| 54 |
+
coordinatorAgent: 'Coordinator',
|
| 55 |
+
directAgent: null,
|
| 56 |
+
allAgents,
|
| 57 |
+
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
| 58 |
+
configuredCoordinatorTarget: 'jarv',
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
expect(resolved).toEqual({
|
| 62 |
+
deliveryName: 'jarv',
|
| 63 |
+
sessionKey: 'agent:jarv:main',
|
| 64 |
+
openclawAgentId: 'jarv',
|
| 65 |
+
resolvedBy: 'configured',
|
| 66 |
+
})
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
it('resolves coordinator to default agent when no explicit target is configured', () => {
|
| 70 |
+
const allAgents: CoordinatorAgentRecord[] = [
|
| 71 |
+
{ name: 'jarv', config: JSON.stringify({ openclawId: 'jarv' }) },
|
| 72 |
+
{ name: 'dev', config: JSON.stringify({ isDefault: true, openclawId: 'dev' }) },
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 76 |
+
to: 'Coordinator',
|
| 77 |
+
coordinatorAgent: 'Coordinator',
|
| 78 |
+
directAgent: null,
|
| 79 |
+
allAgents,
|
| 80 |
+
sessions: [mkSession('dev', 'agent:dev:main')],
|
| 81 |
+
})
|
| 82 |
+
|
| 83 |
+
expect(resolved).toEqual({
|
| 84 |
+
deliveryName: 'dev',
|
| 85 |
+
sessionKey: 'agent:dev:main',
|
| 86 |
+
openclawAgentId: 'dev',
|
| 87 |
+
resolvedBy: 'default',
|
| 88 |
+
})
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
it('resolves coordinator to first live main session when no default agent exists', () => {
|
| 92 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 93 |
+
to: 'Coordinator',
|
| 94 |
+
coordinatorAgent: 'Coordinator',
|
| 95 |
+
directAgent: null,
|
| 96 |
+
allAgents: [{ name: 'admin', config: JSON.stringify({ openclawId: 'admin' }) }],
|
| 97 |
+
sessions: [mkSession('jarv', 'agent:jarv:main')],
|
| 98 |
+
})
|
| 99 |
+
|
| 100 |
+
expect(resolved).toEqual({
|
| 101 |
+
deliveryName: 'jarv',
|
| 102 |
+
sessionKey: 'agent:jarv:main',
|
| 103 |
+
openclawAgentId: 'jarv',
|
| 104 |
+
resolvedBy: 'main_session',
|
| 105 |
+
})
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
it('falls back to normalized destination when nothing else matches', () => {
|
| 109 |
+
const resolved = resolveCoordinatorDeliveryTarget({
|
| 110 |
+
to: 'Coordinator Team',
|
| 111 |
+
coordinatorAgent: 'Coordinator',
|
| 112 |
+
directAgent: null,
|
| 113 |
+
allAgents: [],
|
| 114 |
+
sessions: [],
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
expect(resolved).toEqual({
|
| 118 |
+
deliveryName: 'Coordinator Team',
|
| 119 |
+
sessionKey: null,
|
| 120 |
+
openclawAgentId: 'coordinator-team',
|
| 121 |
+
resolvedBy: 'fallback',
|
| 122 |
+
})
|
| 123 |
+
})
|
| 124 |
+
})
|
src/lib/__tests__/openclaw-doctor.test.ts
CHANGED
|
@@ -89,6 +89,28 @@ Run "openclaw doctor --fix" to apply changes.
|
|
| 89 |
expect(result.raw).not.toContain('Multiple state directories detected')
|
| 90 |
})
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
it('marks clean output as healthy', () => {
|
| 93 |
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)
|
| 94 |
|
|
|
|
| 89 |
expect(result.raw).not.toContain('Multiple state directories detected')
|
| 90 |
})
|
| 91 |
|
| 92 |
+
it('parses state integrity blocks when lines are prefixed by box-drawing gutters', () => {
|
| 93 |
+
const result = parseOpenClawDoctorOutput(`
|
| 94 |
+
┌ OpenClaw doctor
|
| 95 |
+
│
|
| 96 |
+
◇ State integrity
|
| 97 |
+
│ - Multiple state directories detected. This can split session history.
|
| 98 |
+
│ - $OPENCLAW_HOME/.openclaw
|
| 99 |
+
│ - /home/nefes/.openclaw
|
| 100 |
+
│ Active state dir: $OPENCLAW_HOME
|
| 101 |
+
│ - Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.
|
| 102 |
+
Run "openclaw doctor --fix" to apply changes.
|
| 103 |
+
`, 0, { stateDir: '/home/openclaw/.openclaw' })
|
| 104 |
+
|
| 105 |
+
expect(result.level).toBe('warning')
|
| 106 |
+
expect(result.category).toBe('state')
|
| 107 |
+
expect(result.issues).toEqual([
|
| 108 |
+
'Found 11 orphan transcript file(s) in $OPENCLAW_HOME/agents/jarv/sessions.',
|
| 109 |
+
])
|
| 110 |
+
expect(result.raw).not.toContain('/home/nefes/.openclaw')
|
| 111 |
+
expect(result.raw).not.toContain('Multiple state directories detected')
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
it('marks clean output as healthy', () => {
|
| 115 |
const result = parseOpenClawDoctorOutput('OK: configuration valid', 0)
|
| 116 |
|
src/lib/coordinator-routing.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { GatewaySession } from './sessions'
|
| 2 |
+
|
| 3 |
+
export interface CoordinatorAgentRecord {
|
| 4 |
+
name: string
|
| 5 |
+
session_key?: string | null
|
| 6 |
+
config?: string | null
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface ResolvedCoordinatorTarget {
|
| 10 |
+
deliveryName: string
|
| 11 |
+
sessionKey: string | null
|
| 12 |
+
openclawAgentId: string | null
|
| 13 |
+
resolvedBy: 'direct' | 'configured' | 'default' | 'main_session' | 'fallback'
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function normalizeName(value: string | null | undefined): string {
|
| 17 |
+
return String(value || '').trim().toLowerCase()
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function normalizeOpenClawId(value: string | null | undefined): string {
|
| 21 |
+
return normalizeName(value).replace(/\s+/g, '-')
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function parseConfig(raw: string | null | undefined): Record<string, unknown> {
|
| 25 |
+
if (!raw) return {}
|
| 26 |
+
try {
|
| 27 |
+
const parsed = JSON.parse(raw)
|
| 28 |
+
return parsed && typeof parsed === 'object' ? parsed : {}
|
| 29 |
+
} catch {
|
| 30 |
+
return {}
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function getConfigOpenClawId(agent: CoordinatorAgentRecord): string | null {
|
| 35 |
+
const parsed = parseConfig(agent.config)
|
| 36 |
+
return typeof parsed.openclawId === 'string' && parsed.openclawId.trim()
|
| 37 |
+
? parsed.openclawId.trim()
|
| 38 |
+
: null
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function getConfigIsDefault(agent: CoordinatorAgentRecord): boolean {
|
| 42 |
+
const parsed = parseConfig(agent.config)
|
| 43 |
+
return parsed.isDefault === true
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function findSessionForAgent(
|
| 47 |
+
agent: CoordinatorAgentRecord,
|
| 48 |
+
sessions: GatewaySession[],
|
| 49 |
+
): GatewaySession | undefined {
|
| 50 |
+
const name = normalizeName(agent.name)
|
| 51 |
+
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
| 52 |
+
return sessions.find((session) => {
|
| 53 |
+
const sessionAgent = normalizeName(session.agent)
|
| 54 |
+
return sessionAgent === name || sessionAgent === openclawId
|
| 55 |
+
})
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function resolveConfiguredCoordinatorTarget(
|
| 59 |
+
preferredTarget: string,
|
| 60 |
+
allAgents: CoordinatorAgentRecord[],
|
| 61 |
+
sessions: GatewaySession[],
|
| 62 |
+
): CoordinatorAgentRecord | null {
|
| 63 |
+
const wanted = normalizeName(preferredTarget)
|
| 64 |
+
if (!wanted) return null
|
| 65 |
+
|
| 66 |
+
return allAgents.find((agent) => {
|
| 67 |
+
const byName = normalizeName(agent.name) === wanted
|
| 68 |
+
const byOpenClawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name) === wanted
|
| 69 |
+
const session = findSessionForAgent(agent, sessions)
|
| 70 |
+
const bySessionAgent = session ? normalizeName(session.agent) === wanted : false
|
| 71 |
+
return byName || byOpenClawId || bySessionAgent
|
| 72 |
+
}) || null
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function resolveCoordinatorDeliveryTarget(params: {
|
| 76 |
+
to: string
|
| 77 |
+
coordinatorAgent: string
|
| 78 |
+
directAgent: CoordinatorAgentRecord | null
|
| 79 |
+
allAgents: CoordinatorAgentRecord[]
|
| 80 |
+
sessions: GatewaySession[]
|
| 81 |
+
explicitSessionKey?: string | null
|
| 82 |
+
configuredCoordinatorTarget?: string | null
|
| 83 |
+
}): ResolvedCoordinatorTarget {
|
| 84 |
+
const normalizedTo = normalizeName(params.to)
|
| 85 |
+
const normalizedCoordinatorAgent = normalizeName(params.coordinatorAgent)
|
| 86 |
+
const explicitSessionKey = params.explicitSessionKey?.trim() || null
|
| 87 |
+
|
| 88 |
+
const buildResult = (
|
| 89 |
+
agent: CoordinatorAgentRecord,
|
| 90 |
+
resolvedBy: ResolvedCoordinatorTarget['resolvedBy'],
|
| 91 |
+
): ResolvedCoordinatorTarget => {
|
| 92 |
+
const openclawAgentId = getConfigOpenClawId(agent) || normalizeOpenClawId(agent.name)
|
| 93 |
+
const sessionKey =
|
| 94 |
+
explicitSessionKey ||
|
| 95 |
+
agent.session_key?.trim() ||
|
| 96 |
+
findSessionForAgent(agent, params.sessions)?.key ||
|
| 97 |
+
null
|
| 98 |
+
|
| 99 |
+
return {
|
| 100 |
+
deliveryName: agent.name,
|
| 101 |
+
sessionKey,
|
| 102 |
+
openclawAgentId,
|
| 103 |
+
resolvedBy,
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (normalizedTo === normalizedCoordinatorAgent) {
|
| 108 |
+
const configuredTarget = (params.configuredCoordinatorTarget || '').trim()
|
| 109 |
+
if (configuredTarget) {
|
| 110 |
+
const configuredAgent = resolveConfiguredCoordinatorTarget(configuredTarget, params.allAgents, params.sessions)
|
| 111 |
+
if (configuredAgent) {
|
| 112 |
+
return buildResult(configuredAgent, 'configured')
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const defaultAgent = params.allAgents.find(getConfigIsDefault)
|
| 117 |
+
if (defaultAgent) {
|
| 118 |
+
return buildResult(defaultAgent, 'default')
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
const mainSession = params.sessions.find((session) => /:main$/i.test(session.key))
|
| 122 |
+
if (mainSession) {
|
| 123 |
+
const matchingAgent = params.allAgents.find((agent) => {
|
| 124 |
+
const openclawId = normalizeOpenClawId(getConfigOpenClawId(agent) || agent.name)
|
| 125 |
+
const agentName = normalizeName(agent.name)
|
| 126 |
+
const sessionAgent = normalizeName(mainSession.agent)
|
| 127 |
+
return sessionAgent === agentName || sessionAgent === openclawId
|
| 128 |
+
})
|
| 129 |
+
|
| 130 |
+
return {
|
| 131 |
+
deliveryName: matchingAgent?.name || mainSession.agent,
|
| 132 |
+
sessionKey: explicitSessionKey || mainSession.key || null,
|
| 133 |
+
openclawAgentId:
|
| 134 |
+
getConfigOpenClawId(matchingAgent || { name: mainSession.agent }) ||
|
| 135 |
+
normalizeOpenClawId(mainSession.agent),
|
| 136 |
+
resolvedBy: 'main_session',
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
if (params.directAgent) {
|
| 141 |
+
return buildResult(params.directAgent, 'direct')
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (params.directAgent) {
|
| 146 |
+
return buildResult(params.directAgent, 'direct')
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
return {
|
| 150 |
+
deliveryName: params.to,
|
| 151 |
+
sessionKey: explicitSessionKey,
|
| 152 |
+
openclawAgentId: normalizeOpenClawId(params.to),
|
| 153 |
+
resolvedBy: 'fallback',
|
| 154 |
+
}
|
| 155 |
+
}
|
src/lib/openclaw-doctor.ts
CHANGED
|
@@ -14,7 +14,10 @@ export interface OpenClawDoctorStatus {
|
|
| 14 |
}
|
| 15 |
|
| 16 |
function normalizeLine(line: string): string {
|
| 17 |
-
return line
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
function isSessionAgingLine(line: string): boolean {
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
function normalizeLine(line: string): string {
|
| 17 |
+
return line
|
| 18 |
+
.replace(/\u001b\[[0-9;]*m/g, '')
|
| 19 |
+
.replace(/^[\s│┃║┆┊╎╏]+/, '')
|
| 20 |
+
.trim()
|
| 21 |
}
|
| 22 |
|
| 23 |
function isSessionAgingLine(line: string): boolean {
|