Skip to content

Commit 8e4cbb2

Browse files
docs(swarm): remove dead reference to non-existent aurora-rotate-worker.sh (#257)
* fix: load kanban task assignees from Hermes profiles * docs(swarm): remove dead reference to non-existent aurora-rotate-worker.sh Fixes #255. Option A referenced a personal helper script that has never existed in the repo. The Add Swarm dialog (former Option B) remains as the single supported path for spawning tmux-backed workers. --------- Co-authored-by: dontcallmejames <dontcallmejames@users.noreply.github.com>
1 parent 6485d20 commit 8e4cbb2

2 files changed

Lines changed: 109 additions & 44 deletions

File tree

docs/swarm/QUICKSTART.md

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -67,26 +67,7 @@ If a worker exists in the roster but has no local profile, it can still appear a
6767

6868
## 5. Spawn a tmux-backed worker
6969

70-
There are two supported paths.
71-
72-
### Option A: rotate/start script
73-
74-
If your environment has the helper script installed, use:
75-
76-
```bash
77-
/tmp/aurora-rotate-worker.sh swarm7
78-
```
79-
80-
Replace `swarm7` with the worker ID you want. The convention is:
81-
82-
```text
83-
tmux session: swarm-<workerId>
84-
profile: ~/.hermes/profiles/<workerId>
85-
```
86-
87-
After the script starts the session, open Swarm Mode and use Runtime view to attach to the TUI.
88-
89-
### Option B: Add Swarm dialog
70+
### Add Swarm dialog
9071

9172
In the workspace:
9273

src/routes/api/claude-tasks-assignees.ts

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,29 @@
77
*/
88
import { createFileRoute } from '@tanstack/react-router'
99
import { 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'
1111
import fs from 'node:fs'
1212
import path from 'node:path'
1313
import os from 'node:os'
1414
import 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+
1630
const CLAUDE_HOME = process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
1731
const 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

2034
function 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+
46120
export 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

Comments
 (0)