nyk commited on
Commit
cd054b5
·
unverified ·
1 Parent(s): 1dce3d6

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 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
- // Use explicit session key from caller if provided, then DB, then on-disk lookup
419
- let sessionKey: string | null = typeof body.sessionKey === 'string' && body.sessionKey
420
  ? body.sessionKey
421
- : agent?.session_key || null
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) => s.agent.toLowerCase() === String(to).toLowerCase()
 
 
 
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 = 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 dropdownOptions = subscriptionDropdowns[setting.key]
 
 
 
 
 
 
 
 
 
 
 
 
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-center gap-2 shrink-0">
764
- {dropdownOptions ? (
765
- <select
766
- value={currentValue}
767
- onChange={e => handleEdit(setting.key, e.target.value)}
768
- className="w-48 px-2 py-1 text-sm bg-background border border-border rounded-md focus:border-primary focus:outline-none"
769
- >
770
- {dropdownOptions.map(opt => (
771
- <option key={opt.value} value={opt.value}>{opt.label}</option>
772
- ))}
773
- </select>
774
- ) : isBooleanish ? (
 
 
 
 
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
- {!setting.is_default && (
802
- <Button
803
- onClick={() => handleReset(setting.key)}
804
- title="Reset to default"
805
- variant="ghost"
806
- size="icon-xs"
807
- className="w-6 h-6"
808
- >
809
- <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
810
- <path d="M2 8a6 6 0 1111.3-2.8" strokeLinecap="round" />
811
- <path d="M14 2v3.5h-3.5" strokeLinecap="round" strokeLinejoin="round" />
812
- </svg>
813
- </Button>
 
 
 
 
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.replace(/\u001b\[[0-9;]*m/g, '').trim()
 
 
 
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 {