Skip to content

Commit 2792cc4

Browse files
JustAGhosTclaude
andauthored
feat(ui): add CognitiveMeshPanel, SystemHealthBar, and cogmesh-poller (#3)
* feat(ui): add CognitiveMeshPanel, SystemHealthBar, and cogmesh-poller - Add CognitiveMeshPanel: workflow cards with stage progress, health badge, fallback indicator, and view-result links; hidden when unconfigured - Add SystemHealthBar: service health pills in AgentFleetPanel header; shows CM status (connected/degraded/unreachable/unconfigured), hidden when all services are unconfigured - Add cogmesh-poller: polls CM /health every 30 s, emits cogmesh:health:updated via WebSocket broadcast; reloads on .retortconfig change - Add parsers/retortconfig.ts: reads cogmesh.endpoint/secret from .retortconfig with ${VAR} env var substitution - Extend protocol.ts + bridge/types.ts: CognitiveMeshHealth discriminated union, 7 CM escalation fields on AgentTask, cogmesh:health:updated HostMessage - Add 'cogmesh' ActivePanel + CogmeshDot status indicator in App.tsx - Wire cogmeshHealth into useStore Closes retort-plugins#2 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: commit package-lock.json for CI dependency resolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7304461 commit 2792cc4

13 files changed

Lines changed: 4673 additions & 20 deletions

File tree

package-lock.json

Lines changed: 4117 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { parseCogmeshConfig } from './parsers/retortconfig.js'
2+
import type { CognitiveMeshHealth } from './protocol.js'
3+
4+
const HEALTH_INTERVAL_MS = 30_000
5+
const HEALTH_TIMEOUT_MS = 5_000
6+
7+
/**
8+
* Polls the cognitive-mesh `/health` endpoint on a 30s interval.
9+
* Emits `unconfigured` when no endpoint is set in `.retortconfig`.
10+
*
11+
* Returns `reload()` (call when .retortconfig changes) and `stop()`.
12+
*/
13+
export function createCogmeshPoller(
14+
root: string,
15+
onHealth: (h: CognitiveMeshHealth) => void,
16+
): { reload: () => void; stop: () => void } {
17+
let config = parseCogmeshConfig(root)
18+
let timer: ReturnType<typeof setInterval> | null = null
19+
20+
async function checkHealth(): Promise<void> {
21+
if (!config.endpoint) {
22+
onHealth({ status: 'unconfigured' })
23+
return
24+
}
25+
26+
const url = `${config.endpoint.replace(/\/$/, '')}/health`
27+
const headers: Record<string, string> = {}
28+
if (config.secret) headers['Authorization'] = `Bearer ${config.secret}`
29+
30+
const start = Date.now()
31+
try {
32+
const res = await fetch(url, {
33+
headers,
34+
signal: AbortSignal.timeout(HEALTH_TIMEOUT_MS),
35+
})
36+
const latencyMs = Date.now() - start
37+
onHealth(res.ok
38+
? { status: 'connected', latencyMs }
39+
: { status: 'degraded', latencyMs },
40+
)
41+
} catch {
42+
onHealth({ status: 'unreachable' })
43+
}
44+
}
45+
46+
function reload(): void {
47+
config = parseCogmeshConfig(root)
48+
void checkHealth()
49+
}
50+
51+
function stop(): void {
52+
if (timer) clearInterval(timer)
53+
timer = null
54+
}
55+
56+
// Start immediately, then on interval
57+
void checkHealth()
58+
timer = setInterval(() => { void checkHealth() }, HEALTH_INTERVAL_MS)
59+
60+
return { reload, stop }
61+
}

packages/state-watcher/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import { parseBacklog, parseTasks, parseTeams, parseSession } from './parsers/index.js'
1616
import { createWatcher, type ChangeKind } from './watcher.js'
1717
import { createServer } from './server.js'
18+
import { createCogmeshPoller } from './cogmesh-poller.js'
1819
import type { HostMessage } from './protocol.js'
1920

2021
const workspaceRoot = process.argv[2]
@@ -53,6 +54,10 @@ const { broadcast, close } = createServer(
5354
},
5455
)
5556

57+
const cogmeshPoller = createCogmeshPoller(workspaceRoot, (health) => {
58+
broadcast({ type: 'cogmesh:health:updated', health })
59+
})
60+
5661
// ---------------------------------------------------------------------------
5762
// File watcher — debounce and broadcast targeted updates
5863
// ---------------------------------------------------------------------------
@@ -70,6 +75,11 @@ const stopWatcher = createWatcher(workspaceRoot, (kind) => {
7075
})
7176

7277
function flushPending(): void {
78+
// .retortconfig change — reload CM config before sending snapshot
79+
if (pendingKinds.has('generic')) {
80+
cogmeshPoller.reload()
81+
}
82+
7383
// If multiple kinds changed, send a full snapshot rather than many patches —
7484
// it keeps the protocol simple and avoids ordering issues.
7585
if (pendingKinds.size > 1 || pendingKinds.has('generic') || pendingKinds.has('session')) {
@@ -105,6 +115,7 @@ function flushPending(): void {
105115

106116
async function shutdown(): Promise<void> {
107117
if (debounceTimer) clearTimeout(debounceTimer)
118+
cogmeshPoller.stop()
108119
await stopWatcher()
109120
close()
110121
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import * as fs from 'fs'
2+
import * as path from 'path'
3+
4+
export interface CogmeshConfig {
5+
endpoint: string | null
6+
secret: string | null
7+
}
8+
9+
/**
10+
* Reads .retortconfig and extracts the `cogmesh:` block values.
11+
* Substitutes ${VAR} references with process.env values.
12+
*/
13+
export function parseCogmeshConfig(root: string): CogmeshConfig {
14+
const configPath = path.join(root, '.retortconfig')
15+
let content: string
16+
try {
17+
content = fs.readFileSync(configPath, 'utf-8')
18+
} catch {
19+
return { endpoint: null, secret: null }
20+
}
21+
22+
// Extract the indented block under `cogmesh:`
23+
const blockMatch = /^cogmesh:\s*\n((?:[ \t]+[^\n]*\n?)*)/m.exec(content)
24+
if (!blockMatch) return { endpoint: null, secret: null }
25+
const block = blockMatch[1]
26+
27+
return {
28+
endpoint: resolveEnv(extractScalar(block, 'endpoint')),
29+
secret: resolveEnv(extractScalar(block, 'secret')),
30+
}
31+
}
32+
33+
function extractScalar(block: string, key: string): string | null {
34+
const m = new RegExp(`^[ \\t]+${key}:\\s*["']?([^"'\\n]+?)["']?\\s*$`, 'm').exec(block)
35+
return m?.[1]?.trim() ?? null
36+
}
37+
38+
/** Replaces ${VAR} with process.env.VAR (leaves intact if not set). */
39+
function resolveEnv(value: string | null): string | null {
40+
if (!value) return null
41+
return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => process.env[name] ?? `\${${name}}`)
42+
}

packages/state-watcher/src/parsers/teams.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,28 @@ import type { Team } from '../protocol.js'
1818
* The markdown fallback extracts team names from table rows in
1919
* AGENT_TEAMS.md and produces minimal Team objects with sensible defaults.
2020
*/
21+
function detectRepoName(root: string): string {
22+
try {
23+
const markerPath = path.join(root, '.agentkit-repo')
24+
if (fs.existsSync(markerPath)) {
25+
return fs.readFileSync(markerPath, 'utf-8').trim()
26+
}
27+
} catch { /* ignore */ }
28+
return path.basename(root)
29+
}
30+
2131
export function parseTeams(root: string): Team[] {
22-
// Try YAML spec first
23-
const yamlPath = path.join(root, '.agentkit', 'spec', 'AGENT_TEAMS.yaml')
24-
if (fs.existsSync(yamlPath)) {
25-
const teams = parseTeamsYaml(yamlPath)
26-
if (teams.length > 0) return teams
32+
// Try canonical spec path first, then legacy name, then overlay
33+
const candidates = [
34+
path.join(root, '.agentkit', 'spec', 'teams.yaml'), // canonical
35+
path.join(root, '.agentkit', 'spec', 'AGENT_TEAMS.yaml'), // legacy
36+
path.join(root, '.agentkit', 'overlays', detectRepoName(root), 'teams.yaml'), // overlay
37+
]
38+
for (const yamlPath of candidates) {
39+
if (fs.existsSync(yamlPath)) {
40+
const teams = parseTeamsYaml(yamlPath)
41+
if (teams.length > 0) return teams
42+
}
2743
}
2844

2945
// Fallback: markdown table in AGENT_TEAMS.md

packages/state-watcher/src/protocol.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type HostMessage =
77
| { type: 'backlog:updated'; items: BacklogItem[] }
88
| { type: 'teams:updated'; teams: Team[] }
99
| { type: 'sync:status'; state: 'idle' | 'running' | 'error' | 'success'; message?: string }
10+
| { type: 'cogmesh:health:updated'; health: CognitiveMeshHealth }
1011

1112
export type ClientMessage =
1213
| { type: 'ready' }
@@ -43,8 +44,22 @@ export interface AgentTask {
4344
maxTurns?: number
4445
createdAt: string
4546
updatedAt: string
47+
// Cognitive-mesh escalation — present when `retort run cogmesh` dispatched this task
48+
escalatedTo?: string
49+
workflowId?: string
50+
workflowName?: string
51+
workflowStage?: number
52+
workflowTotalStages?: number
53+
fallbackCli?: string
54+
resultPath?: string
4655
}
4756

57+
export type CognitiveMeshHealth =
58+
| { status: 'connected'; latencyMs: number }
59+
| { status: 'degraded'; latencyMs: number }
60+
| { status: 'unreachable' }
61+
| { status: 'unconfigured' }
62+
4863
export interface SessionState {
4964
orchestratorId?: string
5065
phase?: string

packages/ui/src/App.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { AgentFleetPanel } from './panels/AgentFleetPanel'
55
import { BacklogPanel } from './panels/BacklogPanel'
66
import { TeamsPanel } from './panels/TeamsPanel'
77
import { HandoffFeedPanel } from './panels/HandoffFeedPanel'
8+
import { CognitiveMeshPanel } from './panels/CognitiveMeshPanel'
89
import { OnboardingPanel } from './panels/OnboardingPanel'
910

1011
interface Tab {
@@ -17,6 +18,7 @@ const TABS: Tab[] = [
1718
{ id: 'backlog', label: 'Backlog' },
1819
{ id: 'teams', label: 'Teams' },
1920
{ id: 'handoff', label: 'Handoff' },
21+
{ id: 'cogmesh', label: 'Mesh' },
2022
]
2123

2224
function SyncIndicator() {
@@ -29,6 +31,17 @@ function SyncIndicator() {
2931
)
3032
}
3133

34+
function CogmeshDot() {
35+
const health = useStore((s) => s.cogmeshHealth)
36+
if (!health || health.status === 'unconfigured') return null
37+
return (
38+
<span
39+
className={`cogmesh-dot cogmesh-dot--${health.status}`}
40+
title={`Cognitive Mesh: ${health.status}${'latencyMs' in health ? ` (${health.latencyMs}ms)` : ''}`}
41+
/>
42+
)
43+
}
44+
3245
export function App() {
3346
const bridge = useBridge()
3447
const activePanel = useStore((s) => s.activePanel)
@@ -47,6 +60,7 @@ export function App() {
4760
case 'backlog': return <BacklogPanel />
4861
case 'teams': return <TeamsPanel />
4962
case 'handoff': return <HandoffFeedPanel />
63+
case 'cogmesh': return <CognitiveMeshPanel />
5064
default: return <AgentFleetPanel />
5165
}
5266
}
@@ -70,6 +84,7 @@ export function App() {
7084
</nav>
7185
<div className="header-status">
7286
<SyncIndicator />
87+
<CogmeshDot />
7388
<span
7489
className={`connection-dot${bridge.connected ? ' connection-dot--online' : ''}`}
7590
title={bridge.connected ? 'Connected' : 'Disconnected'}

packages/ui/src/bridge/types.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export type HostMessage =
77
| { type: 'backlog:updated'; items: BacklogItem[] }
88
| { type: 'teams:updated'; teams: Team[] }
99
| { type: 'sync:status'; state: 'idle' | 'running' | 'error' | 'success'; message?: string }
10+
| { type: 'cogmesh:health:updated'; health: CognitiveMeshHealth }
1011

1112
export type ClientMessage =
1213
| { type: 'ready' }
@@ -43,8 +44,22 @@ export interface AgentTask {
4344
maxTurns?: number
4445
createdAt: string
4546
updatedAt: string
47+
// Cognitive-mesh escalation — present when `retort run cogmesh` dispatched this task
48+
escalatedTo?: string
49+
workflowId?: string
50+
workflowName?: string
51+
workflowStage?: number
52+
workflowTotalStages?: number
53+
fallbackCli?: string
54+
resultPath?: string
4655
}
4756

57+
export type CognitiveMeshHealth =
58+
| { status: 'connected'; latencyMs: number }
59+
| { status: 'degraded'; latencyMs: number }
60+
| { status: 'unreachable' }
61+
| { status: 'unconfigured' }
62+
4863
export interface SessionState {
4964
orchestratorId?: string
5065
phase?: string

packages/ui/src/bridge/useStore.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { create } from 'zustand'
2-
import type { AgentTask, BacklogItem, HostMessage, SessionState, Team } from './types'
2+
import type { AgentTask, BacklogItem, CognitiveMeshHealth, HostMessage, SessionState, Team } from './types'
33

4-
export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'onboard'
4+
export type ActivePanel = 'fleet' | 'backlog' | 'teams' | 'handoff' | 'cogmesh' | 'onboard'
55

66
interface SyncStatus {
77
state: 'idle' | 'running' | 'error' | 'success'
@@ -14,6 +14,7 @@ interface RetortStore {
1414
tasks: AgentTask[]
1515
session: SessionState | null
1616
syncStatus: SyncStatus
17+
cogmeshHealth: CognitiveMeshHealth | null
1718
activePanel: ActivePanel
1819

1920
setActivePanel: (panel: ActivePanel) => void
@@ -28,6 +29,7 @@ export const useStore = create<RetortStore>((set) => ({
2829
tasks: [],
2930
session: null,
3031
syncStatus: { state: 'idle' },
32+
cogmeshHealth: null,
3133
activePanel: 'fleet',
3234

3335
setActivePanel: (panel) => set({ activePanel: panel }),
@@ -65,6 +67,10 @@ export const useStore = create<RetortStore>((set) => ({
6567
case 'sync:status':
6668
set({ syncStatus: { state: msg.state, message: msg.message } })
6769
break
70+
71+
case 'cogmesh:health:updated':
72+
set({ cogmeshHealth: msg.health })
73+
break
6874
}
6975
},
7076
}))
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useStore } from '../bridge/useStore'
2+
import type { CognitiveMeshHealth } from '../bridge/types'
3+
4+
interface ServiceIndicator {
5+
label: string
6+
status: 'ok' | 'warn' | 'error' | 'off'
7+
detail?: string
8+
}
9+
10+
function cogmeshIndicator(health: CognitiveMeshHealth | null): ServiceIndicator {
11+
if (!health || health.status === 'unconfigured') {
12+
return { label: 'CogMesh', status: 'off', detail: 'not configured' }
13+
}
14+
if (health.status === 'connected') {
15+
return { label: 'CogMesh', status: 'ok', detail: `${health.latencyMs}ms` }
16+
}
17+
if (health.status === 'degraded') {
18+
return { label: 'CogMesh', status: 'warn', detail: `degraded ${health.latencyMs}ms` }
19+
}
20+
return { label: 'CogMesh', status: 'error', detail: 'unreachable' }
21+
}
22+
23+
function Pill({ indicator }: { indicator: ServiceIndicator }) {
24+
return (
25+
<span
26+
className={`health-pill health-pill--${indicator.status}`}
27+
title={indicator.detail}
28+
>
29+
<span className="health-pill-dot" />
30+
{indicator.label}
31+
{indicator.detail && <span className="health-pill-detail">{indicator.detail}</span>}
32+
</span>
33+
)
34+
}
35+
36+
/**
37+
* Compact service-health row shown at the top of the Fleet panel.
38+
* Hidden entirely when all services are unconfigured (avoids noise on fresh installs).
39+
*/
40+
export function SystemHealthBar() {
41+
const cogmeshHealth = useStore((s) => s.cogmeshHealth)
42+
43+
const indicators: ServiceIndicator[] = [
44+
cogmeshIndicator(cogmeshHealth),
45+
// Future: phoenix-flow, mcp-org, sluice, etc.
46+
]
47+
48+
// Don't render the bar if every service is 'off' — no value shown
49+
if (indicators.every((i) => i.status === 'off')) return null
50+
51+
return (
52+
<div className="system-health-bar" role="status" aria-label="Service health">
53+
{indicators.map((ind) => (
54+
<Pill key={ind.label} indicator={ind} />
55+
))}
56+
</div>
57+
)
58+
}

0 commit comments

Comments
 (0)