Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions src/services/statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -81,13 +82,23 @@ export function writeStatusline(overrides?: Map<string, string>): 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 });
Expand Down
76 changes: 47 additions & 29 deletions src/tools/check-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +38,7 @@ interface AgentStatusRow {
branch?: string;
cloudInfo?: CloudInfo;
tokenUsage?: { input: number; output: number };
category: string | null;
}

/**
Expand Down Expand Up @@ -81,6 +82,7 @@ async function checkAgent(agent: ReturnType<typeof getAllAgents>[number]): Promi
lastLlmActivityAt: agent.lastLlmActivityAt,
branch: agent.lastBranch,
tokenUsage: agent.tokenUsage,
category: agent.category?.trim() || null,
};

const strategy = getStrategy(agent);
Expand Down Expand Up @@ -204,15 +206,17 @@ export async function fleetStatus(input?: FleetStatusInput): Promise<string> {

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,
};
});

Expand Down Expand Up @@ -254,33 +258,47 @@ export async function fleetStatus(input?: FleetStatusInput): Promise<string> {
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;
}
36 changes: 21 additions & 15 deletions src/tools/list-members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -90,27 +90,33 @@ export async function listMembers(input?: ListMembersInput): Promise<string> {
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;
}
5 changes: 5 additions & 0 deletions src/tools/register-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof registerMemberSchema>;
Expand Down Expand Up @@ -174,6 +175,7 @@ export async function registerMember(input: RegisterMemberInput): Promise<string
cloud: cloudConfig,
llmProvider: input.llm_provider ?? 'claude',
unattended: input.unattended ?? false,
category: input.category,
};

// --- SSH-dependent steps (skipped for stopped cloud instances) ---
Expand Down Expand Up @@ -276,6 +278,9 @@ export async function registerMember(input: RegisterMemberInput): Promise<string
result += ` OS: ${detectedOS}\n`;
result += ` Folder: ${tempAgent.workFolder}\n`;
result += ` Provider: ${tempAgent.llmProvider ?? 'claude'}\n`;
if (tempAgent.category) {
result += ` Category: ${tempAgent.category}\n`;
}
if (claudeVersion) {
result += ` CLI: ${claudeVersion}\n`;
}
Expand Down
2 changes: 2 additions & 0 deletions src/tools/update-member.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const updateMemberSchema = z.object({
cloud_activity_command: z.string().optional().describe('Custom shell command for workload detection. Must output "busy" or "idle". Pass empty string to clear.'),
llm_provider: z.enum(['claude', 'gemini', 'codex', 'copilot']).optional().describe('Change the LLM provider for this member.'),
unattended: z.union([z.literal(false), z.literal('auto'), z.literal('dangerous')]).optional().describe('Permission mode for unattended execution. false = interactive prompts; "auto" = auto-approve safe operations; "dangerous" = skip all permission checks.'),
category: z.string().max(64).optional().describe('Group label for this member (e.g. "doers", "reviewers"). Pass empty string to clear.'),
});

export type UpdateMemberInput = z.infer<typeof updateMemberSchema>;
Expand Down Expand Up @@ -120,6 +121,7 @@ export async function updateMember(input: UpdateMemberInput): Promise<string> {
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;
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions src/utils/agent-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
items: T[],
getCategory: (item: T) => string | null | undefined,
): { grouped: Map<string, T[]>; sortedKeys: string[] } {
const grouped = new Map<string, T[]>();
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.
*/
Expand Down
Loading
Loading