clawport-ui 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -111,6 +111,25 @@ function scanSubAgentDir(dirPath: string): { fileName: string; content: string }
111
111
  }
112
112
  }
113
113
 
114
+ /**
115
+ * Scan a parent agent directory for child subdirectories containing SOUL.md.
116
+ * Skips sub-agents/ and members/ (already handled by scanSubAgentDir).
117
+ */
118
+ function scanSubdirAgents(parentDir: string): { dirName: string; content: string }[] {
119
+ if (!existsSync(parentDir)) return []
120
+ try {
121
+ return readdirSync(parentDir, { withFileTypes: true })
122
+ .filter(e => e.isDirectory() && e.name !== 'sub-agents' && e.name !== 'members')
123
+ .map(e => {
124
+ const content = safeRead(join(parentDir, e.name, 'SOUL.md'))
125
+ return content ? { dirName: e.name, content } : null
126
+ })
127
+ .filter((x): x is { dirName: string; content: string } => x !== null)
128
+ } catch {
129
+ return []
130
+ }
131
+ }
132
+
114
133
  // ---------------------------------------------------------------------------
115
134
  // Auto-discovery
116
135
  // ---------------------------------------------------------------------------
@@ -147,6 +166,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
147
166
 
148
167
  const discovered: AgentEntry[] = []
149
168
  let colorIndex = 0
169
+ const directReportIds: string[] = []
150
170
 
151
171
  // --- Root agent ---
152
172
  let rootId = 'main'
@@ -177,9 +197,6 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
177
197
  }
178
198
  }
179
199
 
180
- // Collect all agent IDs that report to root (including those without SOUL.md but with sub-agents)
181
- const directReportIds: string[] = []
182
-
183
200
  discovered.push({
184
201
  id: rootId,
185
202
  name: rootName,
@@ -197,6 +214,7 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
197
214
 
198
215
  // We'll fill directReportIds as we discover agents below
199
216
  for (const dirName of allAgentDirs) {
217
+ if (dirName === rootId) continue // root-matching dir scanned in main loop
200
218
  const hasSoul = existsSync(join(agentsDir, dirName, 'SOUL.md'))
201
219
  const hasSubAgents = existsSync(join(agentsDir, dirName, 'sub-agents'))
202
220
  const hasMembers = existsSync(join(agentsDir, dirName, 'members'))
@@ -208,8 +226,61 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
208
226
 
209
227
  // --- Top-level agents ---
210
228
  for (const dirName of allAgentDirs) {
211
- // Skip if directory name matches the root agent ID (already added above)
212
- if (hasRoot && dirName === rootId) continue
229
+ // Root-matching directory: scan for sub-agents but don't create a duplicate root entry
230
+ if (hasRoot && dirName === rootId) {
231
+ const rootAgentDir = join(agentsDir, dirName)
232
+ const rootSubFiles = [
233
+ ...scanSubAgentDir(join(rootAgentDir, 'sub-agents')),
234
+ ...scanSubAgentDir(join(rootAgentDir, 'members')),
235
+ ]
236
+ const rootSubIds = new Set<string>()
237
+ for (const sub of rootSubFiles) {
238
+ const subId = `${rootId}-${basename(sub.fileName, '.md').toLowerCase()}`
239
+ rootSubIds.add(subId)
240
+ directReportIds.push(subId)
241
+ const subParsed = parseSoulHeading(sub.content)
242
+ const subName = subParsed.name || slugToName(basename(sub.fileName, '.md'))
243
+ const subTitle = subParsed.title || 'Agent'
244
+ discovered.push({
245
+ id: subId,
246
+ name: subName,
247
+ title: subTitle,
248
+ reportsTo: rootId,
249
+ directReports: [],
250
+ soulPath: null,
251
+ voiceId: null,
252
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
253
+ emoji: subName.charAt(0).toUpperCase(),
254
+ tools: ['read', 'write'],
255
+ memoryPath: null,
256
+ description: `${subName} agent.`,
257
+ })
258
+ }
259
+
260
+ for (const sub of scanSubdirAgents(rootAgentDir)) {
261
+ const subId = `${rootId}-${sub.dirName}`
262
+ if (rootSubIds.has(subId)) continue
263
+ directReportIds.push(subId)
264
+ const subParsed = parseSoulHeading(sub.content)
265
+ const subName = subParsed.name || slugToName(sub.dirName)
266
+ const subTitle = subParsed.title || 'Agent'
267
+ discovered.push({
268
+ id: subId,
269
+ name: subName,
270
+ title: subTitle,
271
+ reportsTo: rootId,
272
+ directReports: [],
273
+ soulPath: `agents/${rootId}/${sub.dirName}/SOUL.md`,
274
+ voiceId: null,
275
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
276
+ emoji: subName.charAt(0).toUpperCase(),
277
+ tools: ['read', 'write'],
278
+ memoryPath: null,
279
+ description: `${subName} agent.`,
280
+ })
281
+ }
282
+ continue
283
+ }
213
284
 
214
285
  const soulFile = join(agentsDir, dirName, 'SOUL.md')
215
286
  const hasSoul = existsSync(soulFile)
@@ -277,6 +348,32 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
277
348
  })
278
349
  }
279
350
 
351
+ // Discover subdirectory agents (child dirs with SOUL.md)
352
+ const subdirAgents = scanSubdirAgents(join(agentsDir, dirName))
353
+ const existingSubIds = new Set(subAgentIds)
354
+ for (const sub of subdirAgents) {
355
+ const subId = `${dirName}-${sub.dirName}`
356
+ if (existingSubIds.has(subId)) continue
357
+ subAgentIds.push(subId)
358
+ const subParsed = parseSoulHeading(sub.content)
359
+ const subName = subParsed.name || slugToName(sub.dirName)
360
+ const subTitle = subParsed.title || 'Agent'
361
+ discovered.push({
362
+ id: subId,
363
+ name: subName,
364
+ title: subTitle,
365
+ reportsTo: dirName,
366
+ directReports: [],
367
+ soulPath: `agents/${dirName}/${sub.dirName}/SOUL.md`,
368
+ voiceId: null,
369
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
370
+ emoji: subName.charAt(0).toUpperCase(),
371
+ tools: ['read', 'write'],
372
+ memoryPath: null,
373
+ description: `${subName} agent.`,
374
+ })
375
+ }
376
+
280
377
  discovered.push({
281
378
  id: dirName,
282
379
  name,
@@ -297,80 +394,98 @@ function discoverAgents(workspacePath: string): AgentEntry[] | null {
297
394
  }
298
395
 
299
396
  // ---------------------------------------------------------------------------
300
- // CLI-based discovery (openclaw agents list)
397
+ // CLI-based multi-workspace discovery (openclaw agents list)
301
398
  // ---------------------------------------------------------------------------
302
399
 
303
400
  /**
304
- * Discover agents via `openclaw agents list --json`.
305
- * Returns AgentEntry[] for any agents found, or null on failure.
306
- * This catches agents defined in openclaw.json that don't have
307
- * filesystem directories under agents/.
401
+ * Raw entry from `openclaw agents list --json`.
402
+ *
403
+ * Real CLI output shape (verified against OpenClaw docs + live CLI):
404
+ * { id, identityName, identityEmoji, identitySource,
405
+ * workspace, agentDir, model, bindings, isDefault, routes }
406
+ */
407
+ interface CliAgentEntry {
408
+ id: string
409
+ identityName?: string
410
+ identityEmoji?: string
411
+ workspace?: string
412
+ isDefault?: boolean
413
+ }
414
+
415
+ /**
416
+ * Call `openclaw agents list --json` and return parsed entries.
417
+ * Returns null on any failure (CLI not found, bad JSON, timeout).
308
418
  */
309
- function discoverAgentsViaCli(openclawBin: string): AgentEntry[] | null {
419
+ export function listCliAgents(openclawBin: string): CliAgentEntry[] | null {
310
420
  try {
311
421
  const raw = execSync(`${openclawBin} agents list --json`, {
312
422
  encoding: 'utf-8',
313
423
  timeout: 10000,
314
424
  })
315
425
  const parsed = JSON.parse(raw)
316
- const agents: unknown[] = Array.isArray(parsed)
317
- ? parsed
318
- : parsed.agents ?? parsed.data ?? []
319
-
426
+ const agents: unknown[] = Array.isArray(parsed) ? parsed : []
320
427
  if (agents.length === 0) return null
321
-
322
- let colorIndex = 0
323
- return agents.map((entry) => {
324
- const a = entry as Record<string, unknown>
325
- const id = String(a.id ?? a.name ?? a.slug ?? '')
326
- const name = String(a.name ?? a.displayName ?? slugToName(id))
327
- const title = String(a.title ?? a.role ?? 'Agent')
328
- const reportsTo = a.reportsTo != null ? String(a.reportsTo) : null
329
- const directReports = Array.isArray(a.directReports)
330
- ? a.directReports.map(String)
331
- : []
332
- const tools = Array.isArray(a.tools) ? a.tools.map(String) : ['read', 'write']
333
-
334
- return {
335
- id,
336
- name,
337
- title,
338
- reportsTo,
339
- directReports,
340
- soulPath: typeof a.soulPath === 'string' ? a.soulPath : null,
341
- voiceId: typeof a.voiceId === 'string' ? a.voiceId : null,
342
- color: typeof a.color === 'string' ? a.color : DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
343
- emoji: typeof a.emoji === 'string' ? a.emoji : name.charAt(0).toUpperCase(),
344
- tools,
345
- memoryPath: typeof a.memoryPath === 'string' ? a.memoryPath : null,
346
- description: typeof a.description === 'string' ? a.description : `${name} agent.`,
347
- }
348
- })
428
+ return agents as CliAgentEntry[]
349
429
  } catch {
350
430
  return null
351
431
  }
352
432
  }
353
433
 
354
434
  /**
355
- * Merge CLI-discovered agents into an existing registry.
356
- * Only adds agents whose IDs are not already present.
357
- * Also patches directReports on existing agents to include new CLI agents.
435
+ * Scan additional workspaces found via CLI that differ from the primary
436
+ * WORKSPACE_PATH. For each extra workspace, run discoverAgents() and
437
+ * merge results into the existing registry.
438
+ *
439
+ * Agents from other workspaces are independent (no cross-workspace hierarchy).
440
+ * If the other workspace has a root orchestrator, it becomes a top-level peer
441
+ * in the primary workspace's registry.
358
442
  */
359
- function mergeCliAgents(existing: AgentEntry[], cliAgents: AgentEntry[]): AgentEntry[] {
443
+ function mergeExtraWorkspaces(
444
+ existing: AgentEntry[],
445
+ cliAgents: CliAgentEntry[],
446
+ primaryWorkspace: string,
447
+ ): AgentEntry[] {
360
448
  const existingIds = new Set(existing.map(a => a.id))
361
449
  const added: AgentEntry[] = []
362
-
363
- for (const ca of cliAgents) {
364
- if (existingIds.has(ca.id)) continue
365
- added.push(ca)
366
- existingIds.add(ca.id)
367
-
368
- // If the CLI agent reports to an existing agent, add it to that agent's directReports
369
- if (ca.reportsTo) {
370
- const parent = existing.find(a => a.id === ca.reportsTo)
371
- if (parent && !parent.directReports.includes(ca.id)) {
372
- parent.directReports.push(ca.id)
450
+ let colorIndex = existing.length
451
+
452
+ for (const cli of cliAgents) {
453
+ const ws = cli.workspace
454
+ // Skip agents whose workspace matches the primary (already discovered)
455
+ if (!ws || ws === primaryWorkspace) continue
456
+
457
+ // Try discovering agents from this workspace's filesystem
458
+ const discovered = discoverAgents(ws)
459
+ if (discovered) {
460
+ for (const agent of discovered) {
461
+ if (existingIds.has(agent.id)) continue
462
+ // Agents from other workspaces are top-level peers (no cross-workspace hierarchy)
463
+ if (agent.reportsTo && !existingIds.has(agent.reportsTo)) {
464
+ agent.reportsTo = null
465
+ }
466
+ added.push(agent)
467
+ existingIds.add(agent.id)
373
468
  }
469
+ } else {
470
+ // Workspace has no agents/ dir — create a minimal entry from CLI identity
471
+ const id = cli.id
472
+ if (existingIds.has(id)) continue
473
+ const name = cli.identityName || slugToName(id)
474
+ added.push({
475
+ id,
476
+ name,
477
+ title: 'Agent',
478
+ reportsTo: null,
479
+ directReports: [],
480
+ soulPath: null,
481
+ voiceId: null,
482
+ color: DISCOVER_COLORS[colorIndex++ % DISCOVER_COLORS.length],
483
+ emoji: cli.identityEmoji || name.charAt(0).toUpperCase(),
484
+ tools: ['read', 'write'],
485
+ memoryPath: null,
486
+ description: `${name} agent.`,
487
+ })
488
+ existingIds.add(id)
374
489
  }
375
490
  }
376
491
 
@@ -383,8 +498,8 @@ function mergeCliAgents(existing: AgentEntry[], cliAgents: AgentEntry[]): AgentE
383
498
  * Resolution order:
384
499
  * 1. $WORKSPACE_PATH/clawport/agents.json (user's own config)
385
500
  * 2. Auto-discovered from $WORKSPACE_PATH (agents/ directory scan)
386
- * + merged with `openclaw agents list --json` (catches config-only agents)
387
- * 3. CLI-only discovery via `openclaw agents list --json`
501
+ * + merged with other workspaces from `openclaw agents list --json`
502
+ * 3. CLI-only discovery (scans each agent's workspace)
388
503
  * 4. Bundled lib/agents.json (default example registry)
389
504
  */
390
505
  export function loadRegistry(): AgentEntry[] {
@@ -403,21 +518,25 @@ export function loadRegistry(): AgentEntry[] {
403
518
  }
404
519
  }
405
520
 
406
- // 2. Auto-discover from workspace filesystem
521
+ // 2. Auto-discover from primary workspace filesystem
407
522
  const discovered = discoverAgents(workspacePath)
408
523
 
409
- // 2b. Merge in any agents known to OpenClaw CLI but missing from filesystem
524
+ // 2b. Merge agents from other workspaces known to the CLI
410
525
  if (discovered && openclawBin) {
411
- const cliAgents = discoverAgentsViaCli(openclawBin)
412
- if (cliAgents) return mergeCliAgents(discovered, cliAgents)
526
+ const cliAgents = listCliAgents(openclawBin)
527
+ if (cliAgents && cliAgents.length > 1) {
528
+ return mergeExtraWorkspaces(discovered, cliAgents, workspacePath)
529
+ }
413
530
  return discovered
414
531
  }
415
532
  if (discovered) return discovered
416
533
 
417
- // 3. CLI-only discovery (no workspace agents/ dir, but CLI knows about agents)
534
+ // 3. CLI-only: no primary workspace agents, scan each CLI agent's workspace
418
535
  if (openclawBin) {
419
- const cliAgents = discoverAgentsViaCli(openclawBin)
420
- if (cliAgents) return cliAgents
536
+ const cliAgents = listCliAgents(openclawBin)
537
+ if (cliAgents) {
538
+ return mergeExtraWorkspaces([], cliAgents, '')
539
+ }
421
540
  }
422
541
  }
423
542
 
@@ -1,10 +1,11 @@
1
1
  // @vitest-environment node
2
2
  import { describe, it, expect, vi, beforeEach } from 'vitest'
3
3
 
4
- const { mockReadFileSync, mockExistsSync, mockReaddirSync, bundledAgents } = vi.hoisted(() => ({
4
+ const { mockReadFileSync, mockExistsSync, mockReaddirSync, mockExecSync, bundledAgents } = vi.hoisted(() => ({
5
5
  mockReadFileSync: vi.fn(),
6
6
  mockExistsSync: vi.fn(),
7
7
  mockReaddirSync: vi.fn(),
8
+ mockExecSync: vi.fn(),
8
9
  bundledAgents: [
9
10
  {
10
11
  id: 'jarvis',
@@ -115,6 +116,12 @@ vi.mock('fs', () => ({
115
116
  default: { readFileSync: mockReadFileSync, existsSync: mockExistsSync, readdirSync: mockReaddirSync },
116
117
  }))
117
118
 
119
+ // Mock child_process for CLI discovery
120
+ vi.mock('child_process', () => ({
121
+ execSync: mockExecSync,
122
+ default: { execSync: mockExecSync },
123
+ }))
124
+
118
125
  // Mock the bundled agents.json
119
126
  vi.mock('@/lib/agents.json', () => ({
120
127
  default: bundledAgents,
@@ -705,6 +712,209 @@ describe('auto-discovery from workspace', () => {
705
712
  expect(kaze.name).toBe('KAZE')
706
713
  expect(kaze.title).toBe('Flight Research Agent')
707
714
  })
715
+
716
+ it('discovers subdirectory agents with SOUL.md (outpost pattern)', async () => {
717
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
718
+
719
+ mockExistsSync.mockImplementation((p: string) => {
720
+ if (p === '/tmp/ws/clawport/agents.json') return false
721
+ if (p === '/tmp/ws/SOUL.md') return false
722
+ if (p === '/tmp/ws/agents') return true
723
+ if (p === '/tmp/ws/agents/outpost/SOUL.md') return true
724
+ if (p === '/tmp/ws/agents/outpost') return true
725
+ if (p === '/tmp/ws/agents/outpost/sub-agents') return false
726
+ if (p === '/tmp/ws/agents/outpost/members') return false
727
+ if (p === '/tmp/ws/agents/outpost/scout/SOUL.md') return true
728
+ if (p === '/tmp/ws/agents/outpost/mirror/SOUL.md') return true
729
+ return false
730
+ })
731
+
732
+ mockReaddirSync.mockImplementation((p: string) => {
733
+ if (String(p) === '/tmp/ws/agents') {
734
+ return [{ name: 'outpost', isDirectory: () => true }]
735
+ }
736
+ if (String(p) === '/tmp/ws/agents/outpost') {
737
+ return [
738
+ { name: 'scout', isDirectory: () => true },
739
+ { name: 'mirror', isDirectory: () => true },
740
+ ]
741
+ }
742
+ return []
743
+ })
744
+
745
+ mockReadFileSync.mockImplementation((p: string) => {
746
+ if (p === '/tmp/ws/agents/outpost/SOUL.md') return '# OUTPOST — Forward Operations'
747
+ if (p === '/tmp/ws/agents/outpost/scout/SOUL.md') return '# SCOUT — Reconnaissance Agent'
748
+ if (p === '/tmp/ws/agents/outpost/mirror/SOUL.md') return '# MIRROR — Reflection Agent'
749
+ throw new Error('ENOENT')
750
+ })
751
+
752
+ const agents = await getAgents()
753
+ const outpost = agents.find(a => a.id === 'outpost')!
754
+ expect(outpost.name).toBe('OUTPOST')
755
+ expect(outpost.directReports).toContain('outpost-scout')
756
+ expect(outpost.directReports).toContain('outpost-mirror')
757
+
758
+ const scout = agents.find(a => a.id === 'outpost-scout')!
759
+ expect(scout.name).toBe('SCOUT')
760
+ expect(scout.title).toBe('Reconnaissance Agent')
761
+ expect(scout.reportsTo).toBe('outpost')
762
+ expect(scout.soulPath).toBe('agents/outpost/scout/SOUL.md')
763
+
764
+ const mirror = agents.find(a => a.id === 'outpost-mirror')!
765
+ expect(mirror.name).toBe('MIRROR')
766
+ expect(mirror.title).toBe('Reflection Agent')
767
+ expect(mirror.reportsTo).toBe('outpost')
768
+ expect(mirror.soulPath).toBe('agents/outpost/mirror/SOUL.md')
769
+ })
770
+
771
+ it('scans sub-agents from root-matching directory', async () => {
772
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
773
+
774
+ mockExistsSync.mockImplementation((p: string) => {
775
+ if (p === '/tmp/ws/clawport/agents.json') return false
776
+ if (p === '/tmp/ws/SOUL.md') return true
777
+ if (p === '/tmp/ws/IDENTITY.md') return true
778
+ if (p === '/tmp/ws/agents') return true
779
+ if (p === '/tmp/ws/agents/jarvis/SOUL.md') return true
780
+ if (p === '/tmp/ws/agents/jarvis') return true
781
+ if (p === '/tmp/ws/agents/jarvis/sub-agents') return true
782
+ if (p === '/tmp/ws/agents/jarvis/sub-agents/SCRIBE.md') return true
783
+ if (p === '/tmp/ws/agents/jarvis/members') return false
784
+ if (p === '/tmp/ws/agents/vera/SOUL.md') return true
785
+ if (p === '/tmp/ws/agents/vera/sub-agents') return false
786
+ if (p === '/tmp/ws/agents/vera/members') return false
787
+ return false
788
+ })
789
+
790
+ mockReaddirSync.mockImplementation((p: string) => {
791
+ if (String(p) === '/tmp/ws/agents') {
792
+ return [
793
+ { name: 'jarvis', isDirectory: () => true },
794
+ { name: 'vera', isDirectory: () => true },
795
+ ]
796
+ }
797
+ if (String(p) === '/tmp/ws/agents/jarvis/sub-agents') {
798
+ return ['SCRIBE.md']
799
+ }
800
+ return []
801
+ })
802
+
803
+ mockReadFileSync.mockImplementation((p: string) => {
804
+ if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
805
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are'
806
+ if (p === '/tmp/ws/agents/jarvis/SOUL.md') return '# SOUL.md — Jarvis'
807
+ if (p === '/tmp/ws/agents/jarvis/sub-agents/SCRIBE.md') return '# SCRIBE — Documentation Writer'
808
+ if (p === '/tmp/ws/agents/vera/SOUL.md') return '# SOUL.md — VERA'
809
+ throw new Error('ENOENT')
810
+ })
811
+
812
+ const agents = await getAgents()
813
+ const root = agents.find(a => a.id === 'jarvis')!
814
+ expect(root.reportsTo).toBeNull()
815
+ expect(root.directReports).toContain('jarvis-scribe')
816
+ expect(root.directReports).toContain('vera')
817
+
818
+ const scribe = agents.find(a => a.id === 'jarvis-scribe')!
819
+ expect(scribe.name).toBe('SCRIBE')
820
+ expect(scribe.title).toBe('Documentation Writer')
821
+ expect(scribe.reportsTo).toBe('jarvis')
822
+
823
+ expect(agents.filter(a => a.id === 'jarvis')).toHaveLength(1)
824
+ })
825
+
826
+ it('ignores data directories without SOUL.md', async () => {
827
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
828
+
829
+ mockExistsSync.mockImplementation((p: string) => {
830
+ if (p === '/tmp/ws/clawport/agents.json') return false
831
+ if (p === '/tmp/ws/SOUL.md') return false
832
+ if (p === '/tmp/ws/agents') return true
833
+ if (p === '/tmp/ws/agents/robin/SOUL.md') return true
834
+ if (p === '/tmp/ws/agents/robin/sub-agents') return false
835
+ if (p === '/tmp/ws/agents/robin/members') return false
836
+ return false
837
+ })
838
+
839
+ mockReaddirSync.mockImplementation((p: string) => {
840
+ if (String(p) === '/tmp/ws/agents') {
841
+ return [{ name: 'robin', isDirectory: () => true }]
842
+ }
843
+ if (String(p) === '/tmp/ws/agents/robin') {
844
+ return [
845
+ { name: 'briefs', isDirectory: () => true },
846
+ { name: 'state.json', isDirectory: () => false },
847
+ ]
848
+ }
849
+ return []
850
+ })
851
+
852
+ mockReadFileSync.mockImplementation((p: string) => {
853
+ if (p === '/tmp/ws/agents/robin/SOUL.md') return '# ROBIN — Field Intel Operator'
854
+ throw new Error('ENOENT')
855
+ })
856
+
857
+ const agents = await getAgents()
858
+ const robin = agents.find(a => a.id === 'robin')!
859
+ expect(robin.name).toBe('ROBIN')
860
+ expect(robin.directReports).toEqual([])
861
+ expect(agents.find(a => a.id === 'robin-briefs')).toBeUndefined()
862
+ })
863
+
864
+ it('discovers both flat sub-agents and subdirectory agents', async () => {
865
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
866
+
867
+ mockExistsSync.mockImplementation((p: string) => {
868
+ if (p === '/tmp/ws/clawport/agents.json') return false
869
+ if (p === '/tmp/ws/SOUL.md') return false
870
+ if (p === '/tmp/ws/agents') return true
871
+ if (p === '/tmp/ws/agents/herald/SOUL.md') return true
872
+ if (p === '/tmp/ws/agents/herald') return true
873
+ if (p === '/tmp/ws/agents/herald/sub-agents') return true
874
+ if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return true
875
+ if (p === '/tmp/ws/agents/herald/members') return false
876
+ if (p === '/tmp/ws/agents/herald/quill/SOUL.md') return true
877
+ return false
878
+ })
879
+
880
+ mockReaddirSync.mockImplementation((p: string) => {
881
+ if (String(p) === '/tmp/ws/agents') {
882
+ return [{ name: 'herald', isDirectory: () => true }]
883
+ }
884
+ if (String(p) === '/tmp/ws/agents/herald/sub-agents') {
885
+ return ['MAVEN.md']
886
+ }
887
+ if (String(p) === '/tmp/ws/agents/herald') {
888
+ return [
889
+ { name: 'sub-agents', isDirectory: () => true },
890
+ { name: 'quill', isDirectory: () => true },
891
+ ]
892
+ }
893
+ return []
894
+ })
895
+
896
+ mockReadFileSync.mockImplementation((p: string) => {
897
+ if (p === '/tmp/ws/agents/herald/SOUL.md') return '# SOUL.md — HERALD'
898
+ if (p === '/tmp/ws/agents/herald/sub-agents/MAVEN.md') return '# MAVEN — LinkedIn Strategist'
899
+ if (p === '/tmp/ws/agents/herald/quill/SOUL.md') return '# QUILL — Content Writer'
900
+ throw new Error('ENOENT')
901
+ })
902
+
903
+ const agents = await getAgents()
904
+ const herald = agents.find(a => a.id === 'herald')!
905
+ expect(herald.directReports).toContain('herald-maven')
906
+ expect(herald.directReports).toContain('herald-quill')
907
+
908
+ const maven = agents.find(a => a.id === 'herald-maven')!
909
+ expect(maven.name).toBe('MAVEN')
910
+ expect(maven.reportsTo).toBe('herald')
911
+ expect(maven.soulPath).toBeNull()
912
+
913
+ const quill = agents.find(a => a.id === 'herald-quill')!
914
+ expect(quill.name).toBe('QUILL')
915
+ expect(quill.reportsTo).toBe('herald')
916
+ expect(quill.soulPath).toBe('agents/herald/quill/SOUL.md')
917
+ })
708
918
  })
709
919
 
710
920
  // ---------------------------------------------------------------------------
@@ -855,3 +1065,346 @@ describe('getAgent', () => {
855
1065
  expect(jarvis!.reportsTo).toBeNull()
856
1066
  })
857
1067
  })
1068
+
1069
+ // ---------------------------------------------------------------------------
1070
+ // CLI-based multi-workspace discovery (openclaw agents list --json)
1071
+ //
1072
+ // Real CLI output format (verified against live `openclaw agents list --json`):
1073
+ // [{ id, identityName, identityEmoji, identitySource,
1074
+ // workspace, agentDir, model, bindings, isDefault, routes }]
1075
+ //
1076
+ // Key insight: OpenClaw "agents" are isolated workspaces, not hierarchical
1077
+ // team members. Each has its own workspace path. ClawPort scans each
1078
+ // workspace for sub-agent hierarchies via discoverAgents().
1079
+ // ---------------------------------------------------------------------------
1080
+
1081
+ describe('CLI agent discovery (multi-workspace)', () => {
1082
+ /**
1083
+ * Primary workspace at /tmp/ws with root Jarvis + echo agent.
1084
+ * OPENCLAW_BIN set so CLI calls are attempted.
1085
+ */
1086
+ function setupPrimaryWorkspace() {
1087
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
1088
+ vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
1089
+
1090
+ mockExistsSync.mockImplementation((p: string) => {
1091
+ if (p === '/tmp/ws/clawport/agents.json') return false
1092
+ if (p === '/tmp/ws/SOUL.md') return true
1093
+ if (p === '/tmp/ws/IDENTITY.md') return true
1094
+ if (p === '/tmp/ws/agents') return true
1095
+ if (p === '/tmp/ws/agents/echo/SOUL.md') return true
1096
+ if (p === '/tmp/ws/agents/echo/sub-agents') return false
1097
+ if (p === '/tmp/ws/agents/echo/members') return false
1098
+ return false
1099
+ })
1100
+
1101
+ mockReaddirSync.mockReturnValue([
1102
+ { name: 'echo', isDirectory: () => true },
1103
+ ])
1104
+
1105
+ mockReadFileSync.mockImplementation((p: string) => {
1106
+ if (p === '/tmp/ws/IDENTITY.md') return '- **Name:** Jarvis\n- **Emoji:** 🤖'
1107
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md - Who You Are'
1108
+ if (p === '/tmp/ws/agents/echo/SOUL.md') return '# ECHO — Community Voice Monitor'
1109
+ throw new Error('ENOENT')
1110
+ })
1111
+ }
1112
+
1113
+ /** CLI output for a single agent (matches the primary workspace) */
1114
+ function cliOutput(entries: Array<{
1115
+ id: string
1116
+ identityName?: string
1117
+ identityEmoji?: string
1118
+ workspace?: string
1119
+ isDefault?: boolean
1120
+ }>) {
1121
+ return JSON.stringify(entries.map(e => ({
1122
+ id: e.id,
1123
+ identityName: e.identityName ?? null,
1124
+ identityEmoji: e.identityEmoji ?? null,
1125
+ identitySource: 'identity',
1126
+ workspace: e.workspace ?? '/tmp/ws',
1127
+ agentDir: `/home/.openclaw/agents/${e.id}/agent`,
1128
+ model: 'anthropic/claude-sonnet-4-6',
1129
+ bindings: 0,
1130
+ isDefault: e.isDefault ?? false,
1131
+ routes: ['default (no explicit rules)'],
1132
+ })))
1133
+ }
1134
+
1135
+ it('skips CLI merge when only one agent exists (same workspace)', async () => {
1136
+ setupPrimaryWorkspace()
1137
+
1138
+ // CLI returns one agent pointing at the same workspace — no extra workspaces to scan
1139
+ mockExecSync.mockReturnValue(cliOutput([
1140
+ { id: 'main', identityName: 'Jarvis', identityEmoji: '🤖', workspace: '/tmp/ws', isDefault: true },
1141
+ ]))
1142
+
1143
+ const agents = await getAgents()
1144
+ const ids = agents.map(a => a.id)
1145
+ expect(ids).toContain('jarvis')
1146
+ expect(ids).toContain('echo')
1147
+ // Should NOT have a duplicate 'main' entry since only 1 CLI agent (no merge triggered)
1148
+ })
1149
+
1150
+ it('discovers agents from a second workspace via CLI', async () => {
1151
+ // Primary workspace has Jarvis + echo
1152
+ // CLI shows a second agent "work" with a different workspace at /tmp/ws-work
1153
+ // That workspace has a SOUL.md + a "helper" agent
1154
+ setupPrimaryWorkspace()
1155
+
1156
+ // Extend existsSync to handle the second workspace
1157
+ const origExists = mockExistsSync.getMockImplementation()!
1158
+ mockExistsSync.mockImplementation((p: string) => {
1159
+ // Second workspace paths
1160
+ if (p === '/tmp/ws-work/SOUL.md') return true
1161
+ if (p === '/tmp/ws-work/IDENTITY.md') return true
1162
+ if (p === '/tmp/ws-work/agents') return true
1163
+ if (p === '/tmp/ws-work/agents/helper/SOUL.md') return true
1164
+ if (p === '/tmp/ws-work/agents/helper/sub-agents') return false
1165
+ if (p === '/tmp/ws-work/agents/helper/members') return false
1166
+ if (p === '/tmp/ws-work/clawport/agents.json') return false
1167
+ return origExists(p)
1168
+ })
1169
+
1170
+ // Extend readdirSync to handle the second workspace agents/ dir
1171
+ mockReaddirSync.mockImplementation((p: string) => {
1172
+ if (String(p) === '/tmp/ws-work/agents') {
1173
+ return [{ name: 'helper', isDirectory: () => true }]
1174
+ }
1175
+ return [{ name: 'echo', isDirectory: () => true }]
1176
+ })
1177
+
1178
+ const origRead = mockReadFileSync.getMockImplementation()!
1179
+ mockReadFileSync.mockImplementation((p: string) => {
1180
+ if (p === '/tmp/ws-work/IDENTITY.md') return '- **Name:** WorkBot\n- **Emoji:** 🔧'
1181
+ if (p === '/tmp/ws-work/SOUL.md') return '# SOUL.md - Who You Are'
1182
+ if (p === '/tmp/ws-work/agents/helper/SOUL.md') return '# HELPER — Task Assistant'
1183
+ return origRead(p)
1184
+ })
1185
+
1186
+ mockExecSync.mockReturnValue(cliOutput([
1187
+ { id: 'main', identityName: 'Jarvis', identityEmoji: '🤖', workspace: '/tmp/ws', isDefault: true },
1188
+ { id: 'work', identityName: 'WorkBot', identityEmoji: '🔧', workspace: '/tmp/ws-work' },
1189
+ ]))
1190
+
1191
+ const agents = await getAgents()
1192
+ const ids = agents.map(a => a.id)
1193
+ // Primary workspace agents
1194
+ expect(ids).toContain('jarvis')
1195
+ expect(ids).toContain('echo')
1196
+ // Second workspace agents discovered via filesystem scan
1197
+ expect(ids).toContain('workbot') // root of second workspace (from IDENTITY.md)
1198
+ expect(ids).toContain('helper')
1199
+ })
1200
+
1201
+ it('second workspace root becomes top-level peer (reportsTo=null)', async () => {
1202
+ setupPrimaryWorkspace()
1203
+
1204
+ const origExists = mockExistsSync.getMockImplementation()!
1205
+ mockExistsSync.mockImplementation((p: string) => {
1206
+ if (p === '/tmp/ws2/SOUL.md') return true
1207
+ if (p === '/tmp/ws2/IDENTITY.md') return false
1208
+ if (p === '/tmp/ws2/agents') return false
1209
+ if (p === '/tmp/ws2/clawport/agents.json') return false
1210
+ return origExists(p)
1211
+ })
1212
+
1213
+ const origRead = mockReadFileSync.getMockImplementation()!
1214
+ mockReadFileSync.mockImplementation((p: string) => {
1215
+ if (p === '/tmp/ws2/SOUL.md') return '# SOUL.md — SecondBot'
1216
+ return origRead(p)
1217
+ })
1218
+
1219
+ mockExecSync.mockReturnValue(cliOutput([
1220
+ { id: 'main', workspace: '/tmp/ws', isDefault: true },
1221
+ { id: 'second', workspace: '/tmp/ws2' },
1222
+ ]))
1223
+
1224
+ const agents = await getAgents()
1225
+ const secondBot = agents.find(a => a.id === 'secondbot')
1226
+ expect(secondBot).toBeDefined()
1227
+ expect(secondBot!.reportsTo).toBeNull() // independent, not under Jarvis
1228
+ })
1229
+
1230
+ it('creates minimal entry when extra workspace has no discoverable agents', async () => {
1231
+ setupPrimaryWorkspace()
1232
+
1233
+ // Second workspace has nothing discoverable (no SOUL.md, no agents/ dir)
1234
+ const origExists = mockExistsSync.getMockImplementation()!
1235
+ mockExistsSync.mockImplementation((p: string) => {
1236
+ if (p.startsWith('/tmp/ws-empty')) return false
1237
+ return origExists(p)
1238
+ })
1239
+
1240
+ mockExecSync.mockReturnValue(cliOutput([
1241
+ { id: 'main', workspace: '/tmp/ws', isDefault: true },
1242
+ { id: 'empty-bot', identityName: 'EmptyBot', identityEmoji: '🤷', workspace: '/tmp/ws-empty' },
1243
+ ]))
1244
+
1245
+ const agents = await getAgents()
1246
+ const emptyBot = agents.find(a => a.id === 'empty-bot')
1247
+ expect(emptyBot).toBeDefined()
1248
+ expect(emptyBot!.name).toBe('EmptyBot')
1249
+ expect(emptyBot!.emoji).toBe('🤷')
1250
+ expect(emptyBot!.reportsTo).toBeNull()
1251
+ expect(emptyBot!.tools).toEqual(['read', 'write'])
1252
+ })
1253
+
1254
+ it('does not duplicate agents already found in primary workspace', async () => {
1255
+ setupPrimaryWorkspace()
1256
+
1257
+ // CLI returns two agents both pointing at the SAME workspace
1258
+ mockExecSync.mockReturnValue(cliOutput([
1259
+ { id: 'main', workspace: '/tmp/ws', isDefault: true },
1260
+ { id: 'alias', workspace: '/tmp/ws' }, // same workspace, should be skipped
1261
+ ]))
1262
+
1263
+ const agents = await getAgents()
1264
+ // No extra agents added since both workspaces are the same
1265
+ const ids = agents.map(a => a.id)
1266
+ expect(ids).toContain('jarvis')
1267
+ expect(ids).toContain('echo')
1268
+ expect(ids).not.toContain('alias')
1269
+ })
1270
+
1271
+ it('gracefully handles CLI failure (falls back to filesystem only)', async () => {
1272
+ setupPrimaryWorkspace()
1273
+
1274
+ mockExecSync.mockImplementation(() => {
1275
+ throw new Error('command not found: openclaw')
1276
+ })
1277
+
1278
+ const agents = await getAgents()
1279
+ const ids = agents.map(a => a.id)
1280
+ expect(ids).toContain('jarvis')
1281
+ expect(ids).toContain('echo')
1282
+ })
1283
+
1284
+ it('gracefully handles CLI returning invalid JSON', async () => {
1285
+ setupPrimaryWorkspace()
1286
+ mockExecSync.mockReturnValue('not valid json {{{}')
1287
+
1288
+ const agents = await getAgents()
1289
+ expect(agents.map(a => a.id)).toContain('jarvis')
1290
+ expect(agents.map(a => a.id)).toContain('echo')
1291
+ })
1292
+
1293
+ it('gracefully handles CLI returning empty array', async () => {
1294
+ setupPrimaryWorkspace()
1295
+ mockExecSync.mockReturnValue('[]')
1296
+
1297
+ const agents = await getAgents()
1298
+ expect(agents.map(a => a.id)).toContain('jarvis')
1299
+ expect(agents.map(a => a.id)).toContain('echo')
1300
+ })
1301
+
1302
+ it('gracefully handles non-array CLI output', async () => {
1303
+ setupPrimaryWorkspace()
1304
+ mockExecSync.mockReturnValue('{"status":"ok"}')
1305
+
1306
+ const agents = await getAgents()
1307
+ expect(agents.map(a => a.id)).toContain('jarvis')
1308
+ })
1309
+
1310
+ it('does not call CLI when OPENCLAW_BIN is not set', async () => {
1311
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
1312
+ vi.stubEnv('OPENCLAW_BIN', '')
1313
+
1314
+ mockExistsSync.mockImplementation((p: string) => {
1315
+ if (p === '/tmp/ws/clawport/agents.json') return false
1316
+ if (p === '/tmp/ws/SOUL.md') return true
1317
+ if (p === '/tmp/ws/IDENTITY.md') return false
1318
+ if (p === '/tmp/ws/agents') return false
1319
+ return false
1320
+ })
1321
+ mockReadFileSync.mockImplementation((p: string) => {
1322
+ if (p === '/tmp/ws/SOUL.md') return '# SOUL.md — Root'
1323
+ throw new Error('ENOENT')
1324
+ })
1325
+
1326
+ await getAgents()
1327
+ expect(mockExecSync).not.toHaveBeenCalled()
1328
+ })
1329
+
1330
+ it('falls back to bundled when CLI fails and no filesystem agents', async () => {
1331
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
1332
+ vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
1333
+ mockExistsSync.mockReturnValue(false)
1334
+ mockReaddirSync.mockReturnValue([])
1335
+ mockExecSync.mockImplementation(() => { throw new Error('timeout') })
1336
+
1337
+ const agents = await getAgents()
1338
+ expect(agents.length).toBe(bundledAgents.length)
1339
+ })
1340
+
1341
+ it('CLI-only: scans each workspace when primary has no agents', async () => {
1342
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/ws')
1343
+ vi.stubEnv('OPENCLAW_BIN', '/usr/local/bin/openclaw')
1344
+
1345
+ // Primary workspace has nothing
1346
+ mockExistsSync.mockImplementation((p: string) => {
1347
+ if (p === '/tmp/ws/clawport/agents.json') return false
1348
+ if (p === '/tmp/ws/SOUL.md') return false
1349
+ if (p === '/tmp/ws/agents') return false
1350
+ // Remote workspace
1351
+ if (p === '/tmp/remote/SOUL.md') return true
1352
+ if (p === '/tmp/remote/IDENTITY.md') return false
1353
+ if (p === '/tmp/remote/agents') return false
1354
+ if (p === '/tmp/remote/clawport/agents.json') return false
1355
+ return false
1356
+ })
1357
+
1358
+ mockReadFileSync.mockImplementation((p: string) => {
1359
+ if (p === '/tmp/remote/SOUL.md') return '# SOUL.md — RemoteBot'
1360
+ throw new Error('ENOENT')
1361
+ })
1362
+
1363
+ mockExecSync.mockReturnValue(cliOutput([
1364
+ { id: 'remote', identityName: 'RemoteBot', workspace: '/tmp/remote' },
1365
+ ]))
1366
+
1367
+ const agents = await getAgents()
1368
+ expect(agents.map(a => a.id)).toContain('remotebot')
1369
+ })
1370
+
1371
+ it('handles three workspaces with independent hierarchies', async () => {
1372
+ setupPrimaryWorkspace()
1373
+
1374
+ // Extend filesystem mocks for two extra workspaces
1375
+ const origExists = mockExistsSync.getMockImplementation()!
1376
+ mockExistsSync.mockImplementation((p: string) => {
1377
+ // Workspace B
1378
+ if (p === '/tmp/ws-b/SOUL.md') return true
1379
+ if (p === '/tmp/ws-b/IDENTITY.md') return false
1380
+ if (p === '/tmp/ws-b/agents') return false
1381
+ if (p === '/tmp/ws-b/clawport/agents.json') return false
1382
+ // Workspace C — empty
1383
+ if (p.startsWith('/tmp/ws-c')) return false
1384
+ return origExists(p)
1385
+ })
1386
+
1387
+ const origRead = mockReadFileSync.getMockImplementation()!
1388
+ mockReadFileSync.mockImplementation((p: string) => {
1389
+ if (p === '/tmp/ws-b/SOUL.md') return '# SOUL.md — BotB'
1390
+ return origRead(p)
1391
+ })
1392
+
1393
+ mockExecSync.mockReturnValue(cliOutput([
1394
+ { id: 'main', workspace: '/tmp/ws', isDefault: true },
1395
+ { id: 'b', identityName: 'BotB', workspace: '/tmp/ws-b' },
1396
+ { id: 'c', identityName: 'BotC', identityEmoji: '🎯', workspace: '/tmp/ws-c' },
1397
+ ]))
1398
+
1399
+ const agents = await getAgents()
1400
+ const ids = agents.map(a => a.id)
1401
+ expect(ids).toContain('jarvis') // primary root
1402
+ expect(ids).toContain('echo') // primary sub-agent
1403
+ expect(ids).toContain('botb') // workspace B root (from SOUL.md heading)
1404
+ expect(ids).toContain('c') // workspace C (minimal entry, no SOUL.md)
1405
+
1406
+ const botC = agents.find(a => a.id === 'c')!
1407
+ expect(botC.name).toBe('BotC')
1408
+ expect(botC.emoji).toBe('🎯')
1409
+ })
1410
+ })
@@ -0,0 +1,305 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect } from 'vitest'
3
+ import { buildTeams, type Team } from './teams'
4
+ import type { Agent } from './types'
5
+
6
+ /** Helper to create a minimal agent */
7
+ function agent(overrides: Partial<Agent> & { id: string }): Agent {
8
+ return {
9
+ name: overrides.id.toUpperCase(),
10
+ title: 'Agent',
11
+ reportsTo: null,
12
+ directReports: [],
13
+ soulPath: null,
14
+ soul: null,
15
+ voiceId: null,
16
+ color: '#000',
17
+ emoji: 'A',
18
+ tools: [],
19
+ crons: [],
20
+ memoryPath: null,
21
+ description: '',
22
+ ...overrides,
23
+ }
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Basic team grouping
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe('buildTeams', () => {
31
+ it('returns null root and empty arrays for empty input', () => {
32
+ const result = buildTeams([])
33
+ expect(result.root).toBeNull()
34
+ expect(result.teams).toEqual([])
35
+ expect(result.soloOps).toEqual([])
36
+ })
37
+
38
+ it('returns null root when no agent has reportsTo=null', () => {
39
+ const agents = [
40
+ agent({ id: 'a', reportsTo: 'b' }),
41
+ agent({ id: 'b', reportsTo: 'a' }),
42
+ ]
43
+ const result = buildTeams(agents)
44
+ expect(result.root).toBeNull()
45
+ })
46
+
47
+ it('identifies root agent (reportsTo=null)', () => {
48
+ const agents = [
49
+ agent({ id: 'root', reportsTo: null, directReports: [] }),
50
+ ]
51
+ const result = buildTeams(agents)
52
+ expect(result.root).not.toBeNull()
53
+ expect(result.root!.id).toBe('root')
54
+ expect(result.teams).toEqual([])
55
+ expect(result.soloOps).toEqual([])
56
+ })
57
+
58
+ it('classifies direct reports with children as team managers', () => {
59
+ const agents = [
60
+ agent({ id: 'root', directReports: ['mgr'] }),
61
+ agent({ id: 'mgr', reportsTo: 'root', directReports: ['worker'] }),
62
+ agent({ id: 'worker', reportsTo: 'mgr' }),
63
+ ]
64
+ const result = buildTeams(agents)
65
+ expect(result.teams).toHaveLength(1)
66
+ expect(result.teams[0].manager.id).toBe('mgr')
67
+ expect(result.teams[0].members).toHaveLength(1)
68
+ expect(result.teams[0].members[0].id).toBe('worker')
69
+ expect(result.soloOps).toHaveLength(0)
70
+ })
71
+
72
+ it('classifies direct reports without children as solo ops', () => {
73
+ const agents = [
74
+ agent({ id: 'root', directReports: ['solo1', 'solo2'] }),
75
+ agent({ id: 'solo1', reportsTo: 'root' }),
76
+ agent({ id: 'solo2', reportsTo: 'root' }),
77
+ ]
78
+ const result = buildTeams(agents)
79
+ expect(result.teams).toHaveLength(0)
80
+ expect(result.soloOps).toHaveLength(2)
81
+ expect(result.soloOps.map(a => a.id)).toContain('solo1')
82
+ expect(result.soloOps.map(a => a.id)).toContain('solo2')
83
+ })
84
+
85
+ it('separates managers from solo ops correctly', () => {
86
+ const agents = [
87
+ agent({ id: 'root', directReports: ['mgr', 'solo'] }),
88
+ agent({ id: 'mgr', reportsTo: 'root', directReports: ['worker'] }),
89
+ agent({ id: 'worker', reportsTo: 'mgr' }),
90
+ agent({ id: 'solo', reportsTo: 'root' }),
91
+ ]
92
+ const result = buildTeams(agents)
93
+ expect(result.teams).toHaveLength(1)
94
+ expect(result.teams[0].manager.id).toBe('mgr')
95
+ expect(result.soloOps).toHaveLength(1)
96
+ expect(result.soloOps[0].id).toBe('solo')
97
+ })
98
+ })
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Deep hierarchy traversal
102
+ // ---------------------------------------------------------------------------
103
+
104
+ describe('buildTeams — deep hierarchy', () => {
105
+ it('collects all nested members via BFS', () => {
106
+ const agents = [
107
+ agent({ id: 'root', directReports: ['mgr'] }),
108
+ agent({ id: 'mgr', reportsTo: 'root', directReports: ['mid'] }),
109
+ agent({ id: 'mid', reportsTo: 'mgr', directReports: ['leaf'] }),
110
+ agent({ id: 'leaf', reportsTo: 'mid' }),
111
+ ]
112
+ const result = buildTeams(agents)
113
+ expect(result.teams).toHaveLength(1)
114
+ const members = result.teams[0].members.map(m => m.id)
115
+ expect(members).toContain('mid')
116
+ expect(members).toContain('leaf')
117
+ expect(members).toHaveLength(2)
118
+ })
119
+
120
+ it('handles wide teams (manager with many direct members)', () => {
121
+ const memberIds = Array.from({ length: 10 }, (_, i) => `m${i}`)
122
+ const agents = [
123
+ agent({ id: 'root', directReports: ['mgr'] }),
124
+ agent({ id: 'mgr', reportsTo: 'root', directReports: memberIds }),
125
+ ...memberIds.map(id => agent({ id, reportsTo: 'mgr' })),
126
+ ]
127
+ const result = buildTeams(agents)
128
+ expect(result.teams[0].members).toHaveLength(10)
129
+ })
130
+
131
+ it('handles multiple teams with nested members', () => {
132
+ const agents = [
133
+ agent({ id: 'root', directReports: ['mgr-a', 'mgr-b'] }),
134
+ agent({ id: 'mgr-a', reportsTo: 'root', directReports: ['a1', 'a2'] }),
135
+ agent({ id: 'a1', reportsTo: 'mgr-a' }),
136
+ agent({ id: 'a2', reportsTo: 'mgr-a' }),
137
+ agent({ id: 'mgr-b', reportsTo: 'root', directReports: ['b1'] }),
138
+ agent({ id: 'b1', reportsTo: 'mgr-b', directReports: ['b1-sub'] }),
139
+ agent({ id: 'b1-sub', reportsTo: 'b1' }),
140
+ ]
141
+ const result = buildTeams(agents)
142
+ expect(result.teams).toHaveLength(2)
143
+
144
+ const teamA = result.teams.find(t => t.manager.id === 'mgr-a')!
145
+ expect(teamA.members.map(m => m.id)).toEqual(['a1', 'a2'])
146
+
147
+ const teamB = result.teams.find(t => t.manager.id === 'mgr-b')!
148
+ expect(teamB.members.map(m => m.id)).toContain('b1')
149
+ expect(teamB.members.map(m => m.id)).toContain('b1-sub')
150
+ })
151
+ })
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Cycle protection
155
+ // ---------------------------------------------------------------------------
156
+
157
+ describe('buildTeams — cycle protection', () => {
158
+ it('does not infinite loop on self-referencing agent', () => {
159
+ const agents = [
160
+ agent({ id: 'root', directReports: ['mgr'] }),
161
+ agent({ id: 'mgr', reportsTo: 'root', directReports: ['mgr'] }), // self-ref
162
+ ]
163
+ const result = buildTeams(agents)
164
+ expect(result.teams).toHaveLength(1)
165
+ // mgr references itself, but visited set prevents infinite loop
166
+ // mgr is already the manager so it's in visited — members should be empty
167
+ expect(result.teams[0].members).toHaveLength(0)
168
+ })
169
+
170
+ it('does not infinite loop on circular directReports', () => {
171
+ const agents = [
172
+ agent({ id: 'root', directReports: ['a'] }),
173
+ agent({ id: 'a', reportsTo: 'root', directReports: ['b'] }),
174
+ agent({ id: 'b', reportsTo: 'a', directReports: ['a'] }), // cycle back to a
175
+ ]
176
+ const result = buildTeams(agents)
177
+ expect(result.teams).toHaveLength(1)
178
+ const members = result.teams[0].members.map(m => m.id)
179
+ expect(members).toContain('b')
180
+ // 'a' should NOT appear as a member (it's the manager, and visited prevents re-add)
181
+ expect(members).not.toContain('a')
182
+ })
183
+
184
+ it('handles mutual cycle between two agents', () => {
185
+ const agents = [
186
+ agent({ id: 'root', directReports: ['x'] }),
187
+ agent({ id: 'x', reportsTo: 'root', directReports: ['y'] }),
188
+ agent({ id: 'y', reportsTo: 'x', directReports: ['x'] }),
189
+ ]
190
+ // Should not throw or hang
191
+ const result = buildTeams(agents)
192
+ expect(result.teams).toHaveLength(1)
193
+ expect(result.teams[0].members).toHaveLength(1)
194
+ expect(result.teams[0].members[0].id).toBe('y')
195
+ })
196
+
197
+ it('handles longer cycle (a → b → c → a)', () => {
198
+ const agents = [
199
+ agent({ id: 'root', directReports: ['a'] }),
200
+ agent({ id: 'a', reportsTo: 'root', directReports: ['b'] }),
201
+ agent({ id: 'b', reportsTo: 'a', directReports: ['c'] }),
202
+ agent({ id: 'c', reportsTo: 'b', directReports: ['a'] }),
203
+ ]
204
+ const result = buildTeams(agents)
205
+ expect(result.teams).toHaveLength(1)
206
+ const memberIds = result.teams[0].members.map(m => m.id)
207
+ expect(memberIds).toContain('b')
208
+ expect(memberIds).toContain('c')
209
+ // 'a' is the manager, should not be in members even with cycle
210
+ expect(memberIds).not.toContain('a')
211
+ })
212
+ })
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Edge cases
216
+ // ---------------------------------------------------------------------------
217
+
218
+ describe('buildTeams — edge cases', () => {
219
+ it('skips directReports that reference nonexistent agent IDs', () => {
220
+ const agents = [
221
+ agent({ id: 'root', directReports: ['real', 'ghost'] }),
222
+ agent({ id: 'real', reportsTo: 'root' }),
223
+ // 'ghost' does not exist in agents array
224
+ ]
225
+ const result = buildTeams(agents)
226
+ expect(result.soloOps).toHaveLength(1)
227
+ expect(result.soloOps[0].id).toBe('real')
228
+ // ghost is silently skipped
229
+ })
230
+
231
+ it('manager with only nonexistent children is classified as solo op', () => {
232
+ const agents = [
233
+ agent({ id: 'root', directReports: ['mgr'] }),
234
+ agent({ id: 'mgr', reportsTo: 'root', directReports: ['ghost1', 'ghost2'] }),
235
+ ]
236
+ // mgr has directReports.length > 0 so it's treated as a manager
237
+ const result = buildTeams(agents)
238
+ expect(result.teams).toHaveLength(1)
239
+ // But the team has no real members (ghosts not found in byId map)
240
+ expect(result.teams[0].members).toHaveLength(0)
241
+ })
242
+
243
+ it('root with no directReports results in empty teams and soloOps', () => {
244
+ const agents = [agent({ id: 'root' })]
245
+ const result = buildTeams(agents)
246
+ expect(result.root!.id).toBe('root')
247
+ expect(result.teams).toHaveLength(0)
248
+ expect(result.soloOps).toHaveLength(0)
249
+ })
250
+
251
+ it('uses first agent with reportsTo=null when multiple roots exist', () => {
252
+ const agents = [
253
+ agent({ id: 'root1' }),
254
+ agent({ id: 'root2' }),
255
+ ]
256
+ const result = buildTeams(agents)
257
+ // Array.find returns the first match
258
+ expect(result.root!.id).toBe('root1')
259
+ })
260
+
261
+ it('handles the full bundled agents.json structure', () => {
262
+ // Simulate the real Jarvis org: root + 3 managers + 6 solo ops
263
+ const agents = [
264
+ agent({ id: 'jarvis', directReports: ['vera', 'lumen', 'herald', 'pulse', 'echo', 'sage'] }),
265
+ agent({ id: 'vera', reportsTo: 'jarvis', directReports: ['robin'] }),
266
+ agent({ id: 'robin', reportsTo: 'vera', directReports: ['trace', 'proof'] }),
267
+ agent({ id: 'trace', reportsTo: 'robin' }),
268
+ agent({ id: 'proof', reportsTo: 'robin' }),
269
+ agent({ id: 'lumen', reportsTo: 'jarvis', directReports: ['scout', 'writer'] }),
270
+ agent({ id: 'scout', reportsTo: 'lumen' }),
271
+ agent({ id: 'writer', reportsTo: 'lumen' }),
272
+ agent({ id: 'herald', reportsTo: 'jarvis', directReports: ['quill'] }),
273
+ agent({ id: 'quill', reportsTo: 'herald' }),
274
+ agent({ id: 'pulse', reportsTo: 'jarvis' }),
275
+ agent({ id: 'echo', reportsTo: 'jarvis' }),
276
+ agent({ id: 'sage', reportsTo: 'jarvis' }),
277
+ ]
278
+ const result = buildTeams(agents)
279
+
280
+ expect(result.root!.id).toBe('jarvis')
281
+ expect(result.teams).toHaveLength(3)
282
+ expect(result.soloOps).toHaveLength(3)
283
+
284
+ // Team VERA includes robin, trace, proof (nested)
285
+ const teamVera = result.teams.find(t => t.manager.id === 'vera')!
286
+ expect(teamVera.members.map(m => m.id)).toEqual(
287
+ expect.arrayContaining(['robin', 'trace', 'proof']),
288
+ )
289
+
290
+ // Team LUMEN includes scout, writer
291
+ const teamLumen = result.teams.find(t => t.manager.id === 'lumen')!
292
+ expect(teamLumen.members.map(m => m.id)).toEqual(
293
+ expect.arrayContaining(['scout', 'writer']),
294
+ )
295
+
296
+ // Team HERALD includes quill
297
+ const teamHerald = result.teams.find(t => t.manager.id === 'herald')!
298
+ expect(teamHerald.members.map(m => m.id)).toEqual(['quill'])
299
+
300
+ // Solo ops
301
+ expect(result.soloOps.map(a => a.id)).toEqual(
302
+ expect.arrayContaining(['pulse', 'echo', 'sage']),
303
+ )
304
+ })
305
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
5
5
  "homepage": "https://clawport.dev",
6
6
  "repository": {