From 82fca5f1a6ea411161ce76334274dafe76e0c453 Mon Sep 17 00:00:00 2001 From: Manoj K Date: Mon, 4 May 2026 13:31:46 +0530 Subject: [PATCH 1/2] Add member categorization and resolve merge conflicts --- package-lock.json | 6 ++++ src/services/statusline.ts | 22 ++++++++++--- src/tools/check-status.ts | 63 +++++++++++++++++++++++------------- src/tools/list-members.ts | 37 +++++++++++++-------- src/tools/register-member.ts | 5 +++ src/tools/update-member.ts | 2 ++ src/types.ts | 1 + 7 files changed, 95 insertions(+), 41 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40098a1e..8104ef39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -965,6 +965,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "devOptional": true, + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1524,6 +1525,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1770,6 +1772,7 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -2052,6 +2055,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "peer": true, "engines": { "node": ">=12" }, @@ -2560,6 +2564,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2745,6 +2750,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/services/statusline.ts b/src/services/statusline.ts index 81994477..7f732ce1 100644 --- a/src/services/statusline.ts +++ b/src/services/statusline.ts @@ -75,13 +75,27 @@ 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[a.status] ?? 99) - (PRIORITY[b.status] ?? 99)); + // Group by category, sort within each group by priority + const grouped = new Map(); + for (const s of states) { + if (!grouped.has(s.category)) grouped.set(s.category, []); + grouped.get(s.category)!.push(s); + } + for (const members of grouped.values()) { + members.sort((a, b) => (PRIORITY[a.status] ?? 99) - (PRIORITY[b.status] ?? 99)); + } - const line = states - .map(s => `${s.icon} ${s.name}:${STATUS_EMOJI[s.status] ?? '?'} ${s.status}`) - .join(' '); + const categoryParts: string[] = []; + for (const [category, members] of grouped) { + const membersStr = members + .map(s => `${s.icon} ${s.name}:${STATUS_EMOJI[s.status] ?? '?'} ${s.status}`) + .join(' '); + categoryParts.push(`[${category}]: ${membersStr}`); + } + const line = categoryParts.join('\n'); 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 ddc6559e..7a99cd18 100644 --- a/src/tools/check-status.ts +++ b/src/tools/check-status.ts @@ -216,10 +216,11 @@ export async function fleetStatus(input?: FleetStatusInput): Promise { const updateNotice = getUpdateNotice(); if (format === 'json') { + const rowsWithCategory = rows.map((r, i) => ({ ...r, category: agents[i].category ?? null })); const payload: Record = { version: serverVersion, summary: { total: rows.length, online, offline: rows.length - online }, - members: rows, + members: rowsWithCategory, }; if (updateNotice) { const m = updateNotice.match(/apra-fleet (v[\d.]+) is available \(installed: (v[\d.]+)/); @@ -228,32 +229,48 @@ export async function fleetStatus(input?: FleetStatusInput): Promise { return JSON.stringify(payload); } + // Group rows by category + const grouped = new Map[number] }>>(); + for (let i = 0; i < rows.length; i++) { + const key = agents[i].category?.trim() || '(uncategorized)'; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push({ row: rows[i], agent: agents[i] }); + } + // Compact: 1 summary line + 1 line per member, multiple fields per line let t = updateNotice ? `${updateNotice}\n` : ''; - t += `Fleet ${serverVersion}: ${online}/${rows.length} online | `; - 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`; + for (const [category, members] of grouped) { + 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, members] of grouped) { + 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..a2069dd8 100644 --- a/src/tools/list-members.ts +++ b/src/tools/list-members.ts @@ -90,27 +90,36 @@ 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 - let t = `${agents.length} member(s)\n`; + // Compact: group members by category, one group per row block + const grouped = new Map>(); 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`; + const key = a.category?.trim() || '(uncategorized)'; + if (!grouped.has(key)) grouped.set(key, []); + grouped.get(key)!.push({ agent: a, authStatus: authStatuses[i] }); + } + + let t = `${agents.length} member(s)\n`; + for (const [category, members] of grouped) { + 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 d43879e9..2789ebc5 100644 --- a/src/tools/register-member.ts +++ b/src/tools/register-member.ts @@ -41,6 +41,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; @@ -162,6 +163,7 @@ export async function registerMember(input: RegisterMemberInput): Promise; @@ -119,6 +120,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 || 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 3e052c9a..9318ba4b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,7 @@ export interface Agent { lastBranch?: string; tokenUsage?: { input: number; output: number }; unattended?: false | 'auto' | 'dangerous'; + category?: string; } export interface GitHubAppConfig { From 232754423d1f26c9bf6ccfc03228f9771a41aa5d Mon Sep 17 00:00:00 2001 From: Manoj K Date: Thu, 7 May 2026 12:51:40 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix(category):=20address=20review=20comment?= =?UTF-8?q?s=20=E2=80=94=20tests,=20shared=20helper,=20package=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add groupByCategory helper to agent-helpers.ts; refactor check-status, list-members, and statusline to use it (eliminates duplicated group-and-sort logic) - Add 13 tests: whitespace-clear on update, category grouping in fleet_status and list_members (compact + JSON), uncategorized placement, whitespace handling - Restore package.json and package-lock.json from main (reverts unrelated version downgrade and peer:true additions introduced by merge base mismatch) Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 79 +++++++++++++------------ package.json | 4 +- src/services/statusline.ts | 13 ++--- src/tools/check-status.ts | 15 +---- src/tools/list-members.ts | 13 ++--- src/tools/update-member.ts | 2 +- src/utils/agent-helpers.ts | 22 +++++++ tests/category.test.ts | 112 ++++++++++++++++++++++++++++++++++++ tests/update-member.test.ts | 29 +++++++++- 9 files changed, 219 insertions(+), 70 deletions(-) create mode 100644 tests/category.test.ts diff --git a/package-lock.json b/package-lock.json index 8104ef39..d9efa1e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "apra-fleet", - "version": "0.1.4", + "version": "0.1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "apra-fleet", - "version": "0.1.4", + "version": "0.1.9.0", "license": "Apache-2.0", "dependencies": { "@inquirer/password": "^5.0.11", "@modelcontextprotocol/sdk": "^1.27.0", "smol-toml": "^1.6.1", "ssh2": "^1.17.0", - "uuid": "^11.0.0", + "uuid": "^14.0.0", "zod": "^3.25.0" }, "devDependencies": { @@ -469,9 +469,10 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", "engines": { "node": ">=18.14.1" }, @@ -965,7 +966,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "devOptional": true, - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1525,7 +1525,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1565,11 +1564,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", + "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", + "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -1769,10 +1769,10 @@ } }, "node_modules/hono": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", - "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", - "peer": true, + "version": "4.12.17", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.17.tgz", + "integrity": "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ==", + "license": "MIT", "engines": { "node": ">=16.9.0" } @@ -1817,9 +1817,10 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", "engines": { "node": ">= 12" } @@ -2030,9 +2031,10 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -2051,11 +2053,11 @@ "dev": true }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, - "peer": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -2072,9 +2074,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2090,6 +2092,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2540,15 +2543,16 @@ } }, "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", + "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], + "license": "MIT", "bin": { - "uuid": "dist/esm/bin/uuid" + "uuid": "dist-node/bin/uuid" } }, "node_modules/vary": { @@ -2560,11 +2564,11 @@ } }, "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "dev": true, - "peer": true, + "license": "MIT", "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2750,7 +2754,6 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 7b89f928..a3767b6b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "apra-fleet", - "version": "0.1.4", + "version": "0.1.9.0", "description": "MCP server for orchestrating multiple agentic AI instances like Claude, Gemini and others (called 'members') across machines via SSH", "author": "Apra Labs", "homepage": "https://github.com/Apra-Labs/apra-fleet", @@ -47,7 +47,7 @@ "@modelcontextprotocol/sdk": "^1.27.0", "smol-toml": "^1.6.1", "ssh2": "^1.17.0", - "uuid": "^11.0.0", + "uuid": "^14.0.0", "zod": "^3.25.0" }, "devDependencies": { diff --git a/src/services/statusline.ts b/src/services/statusline.ts index 7f732ce1..3b665d2a 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'); @@ -79,23 +80,19 @@ export function writeStatusline(overrides?: Map): void { })); // Group by category, sort within each group by priority - const grouped = new Map(); - for (const s of states) { - if (!grouped.has(s.category)) grouped.set(s.category, []); - grouped.get(s.category)!.push(s); - } + 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, members] of grouped) { + 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('\n'); + 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 2390fcd8..c51aede6 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'; @@ -236,17 +236,8 @@ export async function fleetStatus(input?: FleetStatusInput): Promise { } // Group rows by category (category is already attached to each row) - const grouped = new Map[number] }>>(); - for (let i = 0; i < rows.length; i++) { - const key = rows[i].category ?? '(uncategorized)'; - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key)!.push({ row: rows[i], agent: agents[i] }); - } - const sortedKeys = [...grouped.keys()].sort((a, b) => { - if (a === '(uncategorized)') return 1; - if (b === '(uncategorized)') return -1; - return a.localeCompare(b); - }); + 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` : ''; diff --git a/src/tools/list-members.ts b/src/tools/list-members.ts index a2069dd8..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({ @@ -96,15 +96,12 @@ export async function listMembers(input?: ListMembersInput): Promise { } // Compact: group members by category, one group per row block - const grouped = new Map>(); - for (const [i, a] of agents.entries()) { - const key = a.category?.trim() || '(uncategorized)'; - if (!grouped.has(key)) grouped.set(key, []); - grouped.get(key)!.push({ agent: a, authStatus: authStatuses[i] }); - } + 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 [category, members] of grouped) { + 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; diff --git a/src/tools/update-member.ts b/src/tools/update-member.ts index bbd8092b..3585769f 100644 --- a/src/tools/update-member.ts +++ b/src/tools/update-member.ts @@ -120,7 +120,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 || undefined; + 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/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: {