diff --git a/src/services/statusline.ts b/src/services/statusline.ts index 4e113d6f..548d38ff 100644 --- a/src/services/statusline.ts +++ b/src/services/statusline.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import { FLEET_DIR } from '../paths.js'; import { getAllAgents } from './registry.js'; import { DEFAULT_ICON } from './icons.js'; +import { groupByCategory } from '../utils/agent-helpers.js'; const STATUSLINE_PATH = path.join(FLEET_DIR, 'statusline.txt'); const STATE_PATH = path.join(FLEET_DIR, 'statusline-state.json'); @@ -81,13 +82,23 @@ export function writeStatusline(overrides?: Map): void { icon: a.icon ?? DEFAULT_ICON, name: a.friendlyName, status: saved[a.id] ?? 'idle', + category: a.category?.trim() || '(uncategorized)', })); - states.sort((a, b) => (PRIORITY[baseStatus(a.status)] ?? 99) - (PRIORITY[baseStatus(b.status)] ?? 99)); - - const line = states - .map(s => `${s.icon} ${s.name}:${STATUS_EMOJI[baseStatus(s.status)] ?? '?'} ${s.status}`) - .join(' '); + // Group by category, sort within each group by priority + const { grouped, sortedKeys } = groupByCategory(states, s => s.category); + for (const members of grouped.values()) { + members.sort((a, b) => (PRIORITY[a.status] ?? 99) - (PRIORITY[b.status] ?? 99)); + } + const categoryParts: string[] = []; + for (const category of sortedKeys) { + const members = grouped.get(category)!; + const membersStr = members + .map(s => `${s.icon} ${s.name}:${STATUS_EMOJI[s.status] ?? '?'} ${s.status}`) + .join(' '); + categoryParts.push(`[${category}]: ${membersStr}`); + } + const line = categoryParts.join(' | '); saveState(saved); fs.writeFileSync(STATUSLINE_PATH, line + '\n', { mode: 0o600 }); diff --git a/src/tools/check-status.ts b/src/tools/check-status.ts index f69fbc39..4f74f0c8 100644 --- a/src/tools/check-status.ts +++ b/src/tools/check-status.ts @@ -3,7 +3,7 @@ import { getAllAgents } from '../services/registry.js'; import { getStrategy } from '../services/strategy.js'; import { getOsCommands } from '../os/index.js'; import { getProvider } from '../providers/index.js'; -import { formatAgentHost, getAgentOS } from '../utils/agent-helpers.js'; +import { formatAgentHost, getAgentOS, groupByCategory } from '../utils/agent-helpers.js'; import { serverVersion } from '../version.js'; import { DEFAULT_ICON } from '../services/icons.js'; import { writeStatusline } from '../services/statusline.js'; @@ -38,6 +38,7 @@ interface AgentStatusRow { branch?: string; cloudInfo?: CloudInfo; tokenUsage?: { input: number; output: number }; + category: string | null; } /** @@ -81,6 +82,7 @@ async function checkAgent(agent: ReturnType[number]): Promi lastLlmActivityAt: agent.lastLlmActivityAt, branch: agent.lastBranch, tokenUsage: agent.tokenUsage, + category: agent.category?.trim() || null, }; const strategy = getStrategy(agent); @@ -204,15 +206,17 @@ export async function fleetStatus(input?: FleetStatusInput): Promise { const rows: AgentStatusRow[] = results.map((r, i) => { if (r.status === 'fulfilled') return r.value; - const hostLabel = formatAgentHost(agents[i]); + const agent = agents[i]; + const hostLabel = formatAgentHost(agent); return { - icon: agents[i].icon ?? DEFAULT_ICON, - name: agents[i].friendlyName, + icon: agent.icon ?? DEFAULT_ICON, + name: agent.friendlyName, host: hostLabel, status: 'OFFLINE' as const, busy: '-', - session: agents[i].sessionId ? agents[i].sessionId.substring(0, 8) + '...' : '(none)', - lastActivity: formatTimeAgo(agents[i].lastUsed), + session: agent.sessionId ? agent.sessionId.substring(0, 8) + '...' : '(none)', + lastActivity: formatTimeAgo(agent.lastUsed), + category: agent.category?.trim() || null, }; }); @@ -254,33 +258,47 @@ export async function fleetStatus(input?: FleetStatusInput): Promise { return JSON.stringify(payload); } + // Group rows by category (category is already attached to each row) + const combined = rows.map((row, i) => ({ row, agent: agents[i] })); + const { grouped, sortedKeys } = groupByCategory(combined, ({ row }) => row.category); + // Compact: 1 summary line + 1 line per member, multiple fields per line let t = updateNotice ? `${updateNotice}\n` : ''; - t += `Fleet ${serverVersion}: ${online}/${rows.length} online | `; - if (logFile) t += `log=${logFile} | `; - t += rows.map(r => { - const st = r.status === 'online' ? r.busy : (r.busy === 'OFF(cloud)' ? 'OFF(cloud)' : 'OFF'); - return `${r.icon} ${r.name}(${st})`; - }).join(', '); + t += `Fleet ${serverVersion}: ${online}/${rows.length} online`; + if (logFile) t += ` | log=${logFile}`; + for (const category of sortedKeys) { + const members = grouped.get(category)!; + const chips = members.map(({ row: r }) => { + const st = r.status === 'online' ? r.busy : (r.busy === 'OFF(cloud)' ? 'OFF(cloud)' : 'OFF'); + return `${r.icon} ${r.name}(${st})`; + }).join(', '); + t += ` | [${category}]: ${chips}`; + } t += '\n'; - for (const r of rows) { - const branchStr = r.branch ? ` | branch=${r.branch}` : ''; - const tokenStr = (r.tokenUsage && (r.tokenUsage.input > 0 || r.tokenUsage.output > 0)) - ? ` | tokens=in:${r.tokenUsage.input} out:${r.tokenUsage.output}` : ''; - let line = ` ${r.icon} ${r.name}: ${r.host} | session=${r.session} | ${r.lastActivity}${branchStr}${tokenStr}`; - if (r.cloudInfo) { - const ci = r.cloudInfo; - const uptimeHrs = uptimeHoursFromLaunch(ci.launchTime); - const uptime = ci.launchTime ? formatUptimeDuration(uptimeHrs) : '-'; - const cost = estimateCost(ci.instanceType, uptimeHrs); - const rate = hourlyRate(ci.instanceType); - const warn = costWarning(ci.instanceType, uptimeHrs); - const gpuStr = ci.gpuUtil !== undefined ? ` GPU:${ci.gpuUtil}%` : ''; - const typeStr = ci.instanceType ? ` ${ci.instanceType}` : ''; - const warnStr = warn ? ' ⚠' : ''; - line += ` | [cloud:${ci.state}${typeStr} ${uptime} ${cost} @${rate}${gpuStr}${warnStr}]`; + + // Detail lines grouped by category + for (const category of sortedKeys) { + const members = grouped.get(category)!; + t += `\n[${category}]\n`; + for (const { row: r } of members) { + const branchStr = r.branch ? ` | branch=${r.branch}` : ''; + const tokenStr = (r.tokenUsage && (r.tokenUsage.input > 0 || r.tokenUsage.output > 0)) + ? ` | tokens=in:${r.tokenUsage.input} out:${r.tokenUsage.output}` : ''; + let line = ` ${r.icon} ${r.name}: ${r.host} | session=${r.session} | ${r.lastActivity}${branchStr}${tokenStr}`; + if (r.cloudInfo) { + const ci = r.cloudInfo; + const uptimeHrs = uptimeHoursFromLaunch(ci.launchTime); + const uptime = ci.launchTime ? formatUptimeDuration(uptimeHrs) : '-'; + const cost = estimateCost(ci.instanceType, uptimeHrs); + const rate = hourlyRate(ci.instanceType); + const warn = costWarning(ci.instanceType, uptimeHrs); + const gpuStr = ci.gpuUtil !== undefined ? ` GPU:${ci.gpuUtil}%` : ''; + const typeStr = ci.instanceType ? ` ${ci.instanceType}` : ''; + const warnStr = warn ? ' ⚠' : ''; + line += ` | [cloud:${ci.state}${typeStr} ${uptime} ${cost} @${rate}${gpuStr}${warnStr}]`; + } + t += line + '\n'; } - t += line + '\n'; } return t; } diff --git a/src/tools/list-members.ts b/src/tools/list-members.ts index e98051eb..13f6ada9 100644 --- a/src/tools/list-members.ts +++ b/src/tools/list-members.ts @@ -5,7 +5,7 @@ import { serverVersion } from '../version.js'; import { getStrategy } from '../services/strategy.js'; import { getOsCommands } from '../os/index.js'; import { getProvider } from '../providers/index.js'; -import { getAgentOS } from '../utils/agent-helpers.js'; +import { getAgentOS, groupByCategory } from '../utils/agent-helpers.js'; import type { Agent } from '../types.js'; export const listMembersSchema = z.object({ @@ -90,27 +90,33 @@ export async function listMembers(input?: ListMembersInput): Promise { session: a.sessionId ?? null, created: a.createdAt, lastUsed: a.lastUsed ?? 'never', + category: a.category ?? null, })), }); } - // Compact: 1 line per member with key fields packed together + // Compact: group members by category, one group per row block + const combined = agents.map((agent, i) => ({ agent, authStatus: authStatuses[i] })); + const { grouped, sortedKeys } = groupByCategory(combined, ({ agent: a }) => a.category?.trim()); + let t = `${agents.length} member(s)\n`; - for (const [i, a] of agents.entries()) { - const icon = a.icon ?? DEFAULT_ICON; - const host = a.agentType === 'local' ? 'local' : `${a.host}:${a.port}`; - const authStatus = authStatuses[i]; - - t += ` ${icon} ${a.friendlyName}: ${a.id} | ${host} | ${a.os ?? '?'} | provider=${a.llmProvider ?? 'claude'}`; - if (a.agentType !== 'local') { - t += ` | user=${a.username} | ssh=${a.authType}`; - if (authStatus !== 'offline' && authStatus !== 'N/A') { - t += ` | llm-auth=${authStatus}`; - } else if (authStatus === 'offline') { - t += ` | status=offline`; + for (const category of sortedKeys) { + const members = grouped.get(category)!; + t += `\n[${category}]\n`; + for (const { agent: a, authStatus } of members) { + const icon = a.icon ?? DEFAULT_ICON; + const host = a.agentType === 'local' ? 'local' : `${a.host}:${a.port}`; + t += ` ${icon} ${a.friendlyName}: ${a.id} | ${host} | ${a.os ?? '?'} | provider=${a.llmProvider ?? 'claude'}`; + if (a.agentType !== 'local') { + t += ` | user=${a.username} | ssh=${a.authType}`; + if (authStatus !== 'offline' && authStatus !== 'N/A') { + t += ` | llm-auth=${authStatus}`; + } else if (authStatus === 'offline') { + t += ` | status=offline`; + } } + t += '\n'; } - t += '\n'; } return t; } diff --git a/src/tools/register-member.ts b/src/tools/register-member.ts index 400c0c21..6ed25d71 100644 --- a/src/tools/register-member.ts +++ b/src/tools/register-member.ts @@ -42,6 +42,7 @@ export const registerMemberSchema = z.object({ cloud_activity_command: z.string().min(1).optional().describe('Custom shell command for workload detection. Must output "busy" or "idle" on stdout. Checked after GPU, before process check. Useful for CPU-intensive tasks, downloads, or any non-GPU workload.'), llm_provider: z.enum(['claude', 'gemini', 'codex', 'copilot']).optional().default('claude').describe('LLM provider for this member (default: "claude"). Determines which CLI is used for execute_prompt, provision_llm_auth, and update_llm_cli.'), unattended: z.union([z.literal(false), z.literal('auto'), z.literal('dangerous')]).optional().describe('Permission mode for unattended execution. false (default) = interactive prompts; "auto" = auto-approve safe operations; "dangerous" = skip all permission checks.'), + category: z.string().max(64).optional().describe('Optional group label for this member (e.g. "doers", "reviewers", "cloud"). Used to group devices in fleet status output.'), }); export type RegisterMemberInput = z.infer; @@ -174,6 +175,7 @@ export async function registerMember(input: RegisterMemberInput): Promise; @@ -120,6 +121,7 @@ export async function updateMember(input: UpdateMemberInput): Promise { if (input.friendly_name) updates.friendlyName = input.friendly_name; if (input.llm_provider !== undefined) updates.llmProvider = input.llm_provider; if (input.unattended !== undefined) updates.unattended = input.unattended; + if (input.category !== undefined) updates.category = input.category.trim() || undefined; if (input.host) updates.host = input.host; if (input.port) updates.port = input.port; if (input.username) updates.username = input.username; diff --git a/src/types.ts b/src/types.ts index 20de1fb5..535134a9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -30,6 +30,7 @@ export interface Agent { tokenUsage?: { input: number; output: number }; unattended?: false | 'auto' | 'dangerous'; lastLlmActivityAt?: string; // ISO 8601 + category?: string; } export interface GitHubAppConfig { diff --git a/src/utils/agent-helpers.ts b/src/utils/agent-helpers.ts index f1090910..c7b12a22 100644 --- a/src/utils/agent-helpers.ts +++ b/src/utils/agent-helpers.ts @@ -86,6 +86,28 @@ export function clearStoredPid(agentId: string): void { _activePids.delete(agentId); } +/** + * Group items by category key, returning a map and alphabetically sorted keys + * with `(uncategorized)` always last. + */ +export function groupByCategory( + items: T[], + getCategory: (item: T) => string | null | undefined, +): { grouped: Map; sortedKeys: string[] } { + const grouped = new Map(); + for (const item of items) { + const key = getCategory(item) || '(uncategorized)'; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push(item); + } + const sortedKeys = [...grouped.keys()].sort((a, b) => { + if (a === '(uncategorized)') return 1; + if (b === '(uncategorized)') return -1; + return a.localeCompare(b); + }); + return { grouped, sortedKeys }; +} + /** * Touch an agent's lastUsed timestamp and optionally update its sessionId. */ diff --git a/tests/category.test.ts b/tests/category.test.ts new file mode 100644 index 00000000..c07a9f63 --- /dev/null +++ b/tests/category.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { makeTestAgent, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; +import { addAgent } from '../src/services/registry.js'; +import { fleetStatus } from '../src/tools/check-status.js'; +import { listMembers } from '../src/tools/list-members.js'; + +vi.mock('../src/services/strategy.js', () => ({ + getStrategy: () => ({ + execCommand: vi.fn().mockResolvedValue({ stdout: 'idle', stderr: '', code: 0 }), + testConnection: vi.fn().mockResolvedValue({ ok: true, latencyMs: 5 }), + transferFiles: vi.fn(), + close: vi.fn(), + }), +})); + +describe('fleet_status — category grouping', () => { + beforeEach(() => { + backupAndResetRegistry(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreRegistry(); + }); + + it('groups members by category in compact output', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1', category: 'doers' })); + addAgent(makeTestAgent({ friendlyName: 'reviewer-1', category: 'reviewers' })); + const result = await fleetStatus({ format: 'compact' }); + expect(result).toContain('[doers]'); + expect(result).toContain('[reviewers]'); + expect(result).toContain('worker-1'); + expect(result).toContain('reviewer-1'); + }); + + it('shows uncategorized members in (uncategorized) group', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1' })); + const result = await fleetStatus({ format: 'compact' }); + expect(result).toContain('[(uncategorized)]'); + expect(result).toContain('worker-1'); + }); + + it('places (uncategorized) after named categories', async () => { + addAgent(makeTestAgent({ friendlyName: 'anon' })); + addAgent(makeTestAgent({ friendlyName: 'alpha', category: 'alpha-team' })); + const result = await fleetStatus({ format: 'compact' }); + const alphaPos = result.indexOf('[alpha-team]'); + const uncatPos = result.indexOf('[(uncategorized)]'); + expect(alphaPos).toBeGreaterThan(-1); + expect(uncatPos).toBeGreaterThan(alphaPos); + }); + + it('includes category in JSON output', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1', category: 'doers' })); + const result = await fleetStatus({ format: 'json' }); + const parsed = JSON.parse(result); + expect(parsed.members[0].category).toBe('doers'); + }); + + it('has null category in JSON output for uncategorized member', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1' })); + const result = await fleetStatus({ format: 'json' }); + const parsed = JSON.parse(result); + expect(parsed.members[0].category).toBeNull(); + }); + + it('treats whitespace-only category as uncategorized', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1', category: ' ' })); + const result = await fleetStatus({ format: 'compact' }); + expect(result).toContain('[(uncategorized)]'); + expect(result).not.toMatch(/\[\s+\]/); + }); +}); + +describe('list_members — category grouping', () => { + beforeEach(() => { + backupAndResetRegistry(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreRegistry(); + }); + + it('groups members by category in compact output', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1', category: 'doers' })); + addAgent(makeTestAgent({ friendlyName: 'reviewer-1', category: 'reviewers' })); + const result = await listMembers({ format: 'compact' }); + expect(result).toContain('[doers]'); + expect(result).toContain('[reviewers]'); + }); + + it('shows uncategorized members under (uncategorized)', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1' })); + const result = await listMembers({ format: 'compact' }); + expect(result).toContain('[(uncategorized)]'); + }); + + it('includes category field in JSON output', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1', category: 'doers' })); + const result = await listMembers({ format: 'json' }); + const parsed = JSON.parse(result); + expect(parsed.members[0].category).toBe('doers'); + }); + + it('has null category in JSON output for uncategorized member', async () => { + addAgent(makeTestAgent({ friendlyName: 'worker-1' })); + const result = await listMembers({ format: 'json' }); + const parsed = JSON.parse(result); + expect(parsed.members[0].category).toBeNull(); + }); +}); diff --git a/tests/update-member.test.ts b/tests/update-member.test.ts index 8ca38284..aa26f0e2 100644 --- a/tests/update-member.test.ts +++ b/tests/update-member.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { makeTestAgent, makeTestLocalAgent, backupAndResetRegistry, restoreRegistry } from './test-helpers.js'; -import { addAgent } from '../src/services/registry.js'; +import { addAgent, getAllAgents } from '../src/services/registry.js'; import { updateMember } from '../src/tools/update-member.js'; import { credentialSet, credentialDelete } from '../src/services/credential-store.js'; @@ -109,6 +109,33 @@ describe('updateMember', () => { expect(result).toContain('Member was NOT updated.'); }); + it('stores a valid category', async () => { + const member = makeTestLocalAgent(); + addAgent(member); + const result = await updateMember({ member_id: member.id, category: 'doers' }); + expect(result).toContain('updated'); + const updated = getAllAgents().find(a => a.id === member.id); + expect(updated?.category).toBe('doers'); + }); + + it('clears category when empty string is passed', async () => { + const member = makeTestLocalAgent({ category: 'doers' }); + addAgent(member); + const result = await updateMember({ member_id: member.id, category: '' }); + expect(result).toContain('updated'); + const updated = getAllAgents().find(a => a.id === member.id); + expect(updated?.category).toBeUndefined(); + }); + + it('clears category when whitespace-only string is passed', async () => { + const member = makeTestLocalAgent({ category: 'doers' }); + addAgent(member); + const result = await updateMember({ member_id: member.id, category: ' ' }); + expect(result).toContain('updated'); + const updated = getAllAgents().find(a => a.id === member.id); + expect(updated?.category).toBeUndefined(); + }); + it('does not warn when updating a cloud member', async () => { const member = makeTestAgent({ // A remote member with a cloud property cloud: {