77 */
88import { createFileRoute } from '@tanstack/react-router'
99import { isAuthenticated } from '../../server/auth-middleware'
10- import { BEARER_TOKEN , CLAUDE_API } from '../../server/gateway-capabilities'
10+ import { BEARER_TOKEN , CLAUDE_API , CLAUDE_DASHBOARD_URL } from '../../server/gateway-capabilities'
1111import fs from 'node:fs'
1212import path from 'node:path'
1313import os from 'node:os'
1414import YAML from 'yaml'
1515
16+ type RawAssignee = {
17+ id ?: unknown
18+ name ?: unknown
19+ label ?: unknown
20+ isHuman ?: unknown
21+ is_human ?: unknown
22+ }
23+
24+ type TaskAssignee = {
25+ id : string
26+ label : string
27+ isHuman : boolean
28+ }
29+
1630const CLAUDE_HOME = process . env . HERMES_HOME ?? process . env . CLAUDE_HOME ?? path . join ( os . homedir ( ) , '.hermes' )
1731const CONFIG_PATH = path . join ( CLAUDE_HOME , 'config.yaml' )
18- const PROFILES_PATH = path . join ( os . homedir ( ) , '.claude' , 'profiles' )
32+ const PROFILES_PATH = path . join ( CLAUDE_HOME , 'profiles' )
1933
2034function readConfig ( ) : Record < string , unknown > {
2135 try {
@@ -29,7 +43,11 @@ function getProfileNames(): string[] {
2943 try {
3044 return fs . readdirSync ( PROFILES_PATH ) . filter ( name => {
3145 try {
32- return fs . statSync ( path . join ( PROFILES_PATH , name ) ) . isDirectory ( )
46+ const profilePath = path . join ( PROFILES_PATH , name )
47+ return (
48+ fs . statSync ( profilePath ) . isDirectory ( ) &&
49+ fs . existsSync ( path . join ( profilePath , 'config.yaml' ) )
50+ )
3351 } catch {
3452 return false
3553 }
@@ -43,6 +61,62 @@ function authHeaders(): Record<string, string> {
4361 return BEARER_TOKEN ? { Authorization : `Bearer ${ BEARER_TOKEN } ` } : { }
4462}
4563
64+ function titleCaseProfile ( name : string ) : string {
65+ return name
66+ . split ( / [ - _ \s ] + / )
67+ . filter ( Boolean )
68+ . map ( part => part . charAt ( 0 ) . toUpperCase ( ) + part . slice ( 1 ) )
69+ . join ( ' ' )
70+ }
71+
72+ function normalizeAssigneePayload ( payload : unknown , humanReviewer : string | null ) : Array < TaskAssignee > {
73+ const record = payload && typeof payload === 'object' && ! Array . isArray ( payload )
74+ ? payload as Record < string , unknown >
75+ : null
76+ const rawAssignees = Array . isArray ( payload )
77+ ? payload
78+ : Array . isArray ( record ?. assignees )
79+ ? record . assignees
80+ : [ ]
81+
82+ const seen = new Set < string > ( )
83+ const assignees : Array < TaskAssignee > = [ ]
84+
85+ for ( const raw of rawAssignees ) {
86+ const item = typeof raw === 'string' ? { id : raw , label : raw } : raw as RawAssignee
87+ const id = typeof item . id === 'string'
88+ ? item . id
89+ : typeof item . name === 'string'
90+ ? item . name
91+ : null
92+ if ( ! id || seen . has ( id ) ) continue
93+ seen . add ( id )
94+ const label = typeof item . label === 'string' && item . label . trim ( ) . length > 0
95+ ? item . label
96+ : titleCaseProfile ( id )
97+ assignees . push ( {
98+ id,
99+ label,
100+ isHuman : item . isHuman === true || item . is_human === true || id === humanReviewer ,
101+ } )
102+ }
103+
104+ return assignees
105+ }
106+
107+ async function fetchJson ( url : string ) : Promise < unknown | null > {
108+ try {
109+ const res = await fetch ( url , {
110+ signal : AbortSignal . timeout ( 2000 ) ,
111+ headers : authHeaders ( ) ,
112+ } )
113+ if ( ! res . ok ) return null
114+ return await res . json ( )
115+ } catch {
116+ return null
117+ }
118+ }
119+
46120export const Route = createFileRoute ( '/api/claude-tasks-assignees' ) ( {
47121 server : {
48122 handlers : {
@@ -51,33 +125,43 @@ export const Route = createFileRoute('/api/claude-tasks-assignees')({
51125 return new Response ( JSON . stringify ( { error : 'Unauthorized' } ) , { status : 401 } )
52126 }
53127
54- // Try gateway first — it may have a richer endpoint
55- try {
56- const res = await fetch ( `${ CLAUDE_API } /api/tasks/assignees` , {
57- signal : AbortSignal . timeout ( 2000 ) ,
58- headers : authHeaders ( ) ,
59- } )
60- if ( res . ok ) {
61- return new Response ( await res . text ( ) , {
62- status : 200 ,
63- headers : { 'Content-Type' : 'application/json' } ,
64- } )
65- }
66- } catch {
67- // fall through to local profile discovery
68- }
69-
70- // Fall back: derive from profile directories + config
71128 const config = readConfig ( )
72129 const tasksConfig = ( config . tasks ?? { } ) as Record < string , unknown >
73130 const humanReviewer = ( tasksConfig . human_reviewer as string ) || null
74- const profiles = getProfileNames ( )
75131
76- const assignees = profiles . map ( id => ( { id, label : id , isHuman : id === humanReviewer } ) )
77- if ( humanReviewer && ! profiles . includes ( humanReviewer ) ) {
78- assignees . unshift ( { id : humanReviewer , label : humanReviewer , isHuman : true } )
132+ // Prefer the dashboard plugin endpoint: it is the source used by the
133+ // Hermes kanban CLI and includes ~/.hermes/profiles plus assignees
134+ // already present on the board.
135+ const remotePayload =
136+ await fetchJson ( `${ CLAUDE_DASHBOARD_URL } /api/plugins/kanban/assignees` ) ??
137+ await fetchJson ( `${ CLAUDE_API } /api/tasks/assignees` )
138+ const remoteAssignees = remotePayload
139+ ? normalizeAssigneePayload ( remotePayload , humanReviewer )
140+ : [ ]
141+
142+ const profiles = getProfileNames ( )
143+ const merged = new Map < string , TaskAssignee > ( )
144+ for ( const assignee of remoteAssignees ) {
145+ merged . set ( assignee . id , assignee )
146+ }
147+ for ( const id of profiles ) {
148+ if ( ! merged . has ( id ) ) {
149+ merged . set ( id , { id, label : titleCaseProfile ( id ) , isHuman : id === humanReviewer } )
150+ }
151+ }
152+ if ( humanReviewer && ! merged . has ( humanReviewer ) ) {
153+ merged . set ( humanReviewer , {
154+ id : humanReviewer ,
155+ label : titleCaseProfile ( humanReviewer ) ,
156+ isHuman : true ,
157+ } )
79158 }
80159
160+ const assignees = Array . from ( merged . values ( ) ) . sort ( ( a , b ) => {
161+ if ( a . isHuman !== b . isHuman ) return a . isHuman ? - 1 : 1
162+ return a . label . localeCompare ( b . label )
163+ } )
164+
81165 return new Response (
82166 JSON . stringify ( { assignees, humanReviewer } ) ,
83167 { status : 200 , headers : { 'Content-Type' : 'application/json' } } ,
0 commit comments