diff --git a/src/commands/agents.ts b/src/commands/agents.ts index 2cb73f5..22e756b 100644 --- a/src/commands/agents.ts +++ b/src/commands/agents.ts @@ -4,6 +4,13 @@ import { type AgentManifest } from '@agentage/core'; import { ensureDaemon } from '../utils/ensure-daemon.js'; import { get, post } from '../utils/daemon-client.js'; +interface HubAgent { + name: string; + description?: string; + version?: string; + machines?: { name: string; status: string }; +} + export const registerAgents = (program: Command): void => { program .command('agents') @@ -15,8 +22,7 @@ export const registerAgents = (program: Command): void => { await ensureDaemon(); if (opts.all) { - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); - process.exitCode = 1; + await listHubAgents(opts.json ?? false); return; } @@ -55,3 +61,49 @@ export const registerAgents = (program: Command): void => { } }); }; + +const listHubAgents = async (jsonMode: boolean): Promise => { + let agents: HubAgent[]; + try { + agents = await get('/api/hub/agents'); + } catch { + console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + process.exitCode = 1; + return; + } + + if (jsonMode) { + console.log(JSON.stringify(agents, null, 2)); + return; + } + + if (agents.length === 0) { + console.log(chalk.gray('No agents found across machines.')); + return; + } + + const nameWidth = Math.max(12, ...agents.map((a) => a.name.length)) + 2; + const machineWidth = Math.max(10, ...agents.map((a) => (a.machines?.name || '').length)) + 2; + const descWidth = + Math.max(12, ...agents.map((a) => (a.description || '').length).slice(0, 40)) + 2; + + console.log( + chalk.bold('NAME'.padEnd(nameWidth)) + + chalk.bold('MACHINE'.padEnd(machineWidth)) + + chalk.bold('DESCRIPTION'.padEnd(descWidth)) + + chalk.bold('STATUS') + ); + + for (const agent of agents) { + const machineName = agent.machines?.name || ''; + const status = + agent.machines?.status === 'online' ? chalk.green('online') : chalk.gray('offline'); + + console.log( + agent.name.padEnd(nameWidth) + + machineName.padEnd(machineWidth) + + (agent.description || '').substring(0, 38).padEnd(descWidth) + + status + ); + } +}; diff --git a/src/commands/logout.ts b/src/commands/logout.ts index f7f0975..f74496c 100644 --- a/src/commands/logout.ts +++ b/src/commands/logout.ts @@ -1,11 +1,34 @@ import { type Command } from 'commander'; import chalk from 'chalk'; +import { ensureDaemon } from '../utils/ensure-daemon.js'; +import { readAuth, deleteAuth } from '../hub/auth.js'; +import { createHubClient } from '../hub/hub-client.js'; export const registerLogout = (program: Command): void => { program .command('logout') .description('Disconnect from hub') - .action(() => { - console.log(chalk.yellow('Hub sync not yet available.')); + .action(async () => { + await ensureDaemon(); + + const auth = readAuth(); + if (!auth) { + console.log(chalk.yellow('Not logged in.')); + return; + } + + // Best-effort deregister from hub + try { + const client = createHubClient(auth.hub.url, auth); + await client.deregister(auth.hub.machineId); + } catch { + // Hub may be unreachable — that's fine + } + + deleteAuth(); + + console.log(chalk.green('Disconnected from hub. Machine deregistered.')); + console.log(chalk.dim('Daemon continues running in standalone mode.')); + console.log(chalk.dim('Run `agentage daemon restart` to apply.')); }); }; diff --git a/src/commands/machines.ts b/src/commands/machines.ts index d4080b0..d71f396 100644 --- a/src/commands/machines.ts +++ b/src/commands/machines.ts @@ -1,15 +1,75 @@ import { type Command } from 'commander'; import chalk from 'chalk'; import { ensureDaemon } from '../utils/ensure-daemon.js'; +import { get } from '../utils/daemon-client.js'; + +interface Machine { + id: string; + name: string; + platform: string; + status: string; + last_seen_at: string; + agents?: unknown[]; +} export const registerMachines = (program: Command): void => { program .command('machines') .description('List connected machines') .option('--json', 'JSON output') - .action(async () => { + .action(async (opts: { json?: boolean }) => { await ensureDaemon(); - console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); - process.exitCode = 1; + + let machines: Machine[]; + try { + machines = await get('/api/hub/machines'); + } catch { + console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + process.exitCode = 1; + return; + } + + if (opts.json) { + console.log(JSON.stringify(machines, null, 2)); + return; + } + + if (machines.length === 0) { + console.log(chalk.gray('No machines registered.')); + return; + } + + const nameWidth = Math.max(12, ...machines.map((m) => m.name.length)) + 2; + + console.log( + chalk.bold('NAME'.padEnd(nameWidth)) + + chalk.bold('PLATFORM'.padEnd(12)) + + chalk.bold('STATUS'.padEnd(12)) + + chalk.bold('LAST SEEN') + ); + + for (const machine of machines) { + const status = machine.status === 'online' ? chalk.green('online') : chalk.gray('offline'); + + const lastSeen = formatLastSeen(machine.last_seen_at); + + console.log( + machine.name.padEnd(nameWidth) + + machine.platform.padEnd(12) + + status.padEnd(12 + (status.length - machine.status.length)) + + chalk.gray(lastSeen) + ); + } }); }; + +const formatLastSeen = (iso: string): string => { + const diff = Date.now() - new Date(iso).getTime(); + const seconds = Math.floor(diff / 1000); + + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +}; diff --git a/src/commands/run.ts b/src/commands/run.ts index 677cc49..d63d41e 100644 --- a/src/commands/run.ts +++ b/src/commands/run.ts @@ -2,7 +2,7 @@ import { type Command } from 'commander'; import chalk from 'chalk'; import { type RunEvent } from '@agentage/core'; import { ensureDaemon } from '../utils/ensure-daemon.js'; -import { connectWs, post } from '../utils/daemon-client.js'; +import { connectWs, get, post } from '../utils/daemon-client.js'; import { renderEvent } from '../utils/render.js'; interface RunResponse { @@ -24,10 +24,21 @@ type WsMessage = WsRunEventMessage | WsRunStateMessage; const TERMINAL_STATES = ['completed', 'failed', 'canceled']; +const parseAgentTarget = (input: string): { agentName: string; machineName?: string } => { + const atIndex = input.lastIndexOf('@'); + if (atIndex > 0) { + return { + agentName: input.substring(0, atIndex), + machineName: input.substring(atIndex + 1), + }; + } + return { agentName: input }; +}; + export const registerRun = (program: Command): void => { program .command('run') - .argument('', 'Agent name') + .argument('', 'Agent name (or agent@machine for remote)') .argument('[prompt]', 'Task/prompt for the agent') .description('Run an agent') .option('-d, --detach', 'Run in background, print run ID') @@ -48,27 +59,96 @@ export const registerRun = (program: Command): void => { return; } - const config = opts.config - ? (JSON.parse(opts.config) as Record) - : undefined; + const { agentName, machineName } = parseAgentTarget(agent); - const { runId } = await post(`/api/agents/${agent}/run`, { - task: prompt, - config, - context: opts.context, - }); - - if (opts.detach) { - console.log(runId); - return; + if (machineName) { + await runRemote(agentName, machineName, prompt, opts); + } else { + await runLocal(agentName, prompt, opts); } - - // Stream events via WebSocket - await streamRun(runId, opts.json ?? false); } ); }; +const runLocal = async ( + agentName: string, + prompt: string, + opts: { detach?: boolean; json?: boolean; config?: string; context?: string[] } +): Promise => { + const config = opts.config ? (JSON.parse(opts.config) as Record) : undefined; + + const { runId } = await post(`/api/agents/${agentName}/run`, { + task: prompt, + config, + context: opts.context, + }); + + if (opts.detach) { + console.log(runId); + return; + } + + await streamRun(runId, opts.json ?? false); +}; + +const runRemote = async ( + agentName: string, + machineName: string, + prompt: string, + opts: { detach?: boolean; json?: boolean } +): Promise => { + // Resolve machine name to machine ID + let machines: Array<{ id: string; name: string }>; + try { + machines = await get>('/api/hub/machines'); + } catch { + console.error(chalk.red("Not connected to hub. Run 'agentage login' first.")); + process.exitCode = 1; + return; + } + + const machine = machines.find((m) => m.name === machineName); + if (!machine) { + console.error(chalk.red(`Machine "${machineName}" not found.`)); + console.error(chalk.dim(`Available: ${machines.map((m) => m.name).join(', ') || 'none'}`)); + process.exitCode = 1; + return; + } + + // Create run via hub + let result: { runId?: string }; + try { + result = await post<{ runId?: string }>('/api/hub/runs', { + machineId: machine.id, + agentName, + input: prompt, + }); + } catch (err) { + console.error( + chalk.red(`Failed to start remote run: ${err instanceof Error ? err.message : String(err)}`) + ); + process.exitCode = 1; + return; + } + + const runId = result.runId ?? (result as Record).runId; + if (!runId) { + console.error(chalk.red('Failed to get run ID from hub')); + process.exitCode = 1; + return; + } + + if (opts.detach) { + console.log(runId); + return; + } + + console.log(chalk.dim(`Running ${agentName} on ${machineName}...`)); + + // Poll for events from hub (MVP approach — daemon proxies) + await pollRemoteRun(runId as string, opts.json ?? false); +}; + const streamRun = (runId: string, jsonMode: boolean): Promise => new Promise((resolve) => { const ws = connectWs((data) => { @@ -109,3 +189,42 @@ const streamRun = (runId: string, jsonMode: boolean): Promise => resolve(); }); }); + +const pollRemoteRun = async (runId: string, jsonMode: boolean): Promise => { + let lastEventId: string | undefined; + const pollInterval = 1000; + + const poll = async (): Promise => { + try { + const url = lastEventId + ? `/api/hub/runs/${runId}/events?after=${lastEventId}` + : `/api/hub/runs/${runId}/events`; + + const events = await get>(url); + + for (const event of events) { + if (jsonMode) { + console.log(JSON.stringify(event)); + } else { + renderEvent(event as unknown as RunEvent); + } + lastEventId = event.id; + } + + // Check run state + const run = await get<{ state: string }>(`/api/hub/runs/${runId}`); + if (TERMINAL_STATES.includes(run.state)) { + return true; + } + } catch { + // Hub may be temporarily unreachable + } + return false; + }; + + while (true) { + const done = await poll(); + if (done) break; + await new Promise((r) => setTimeout(r, pollInterval)); + } +}; diff --git a/src/daemon/routes.ts b/src/daemon/routes.ts index 89be8e9..8ac2451 100644 --- a/src/daemon/routes.ts +++ b/src/daemon/routes.ts @@ -178,5 +178,38 @@ export const createRoutes = (): Router => { } }); + router.get('/api/hub/runs/:id', async (req, res) => { + const auth = readAuth(); + if (!auth) { + res.status(401).json({ error: 'Not logged in' }); + return; + } + try { + const client = createHubClient(auth.hub.url, auth); + const run = await client.getRun(req.params.id); + res.json(run); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }); + + router.get('/api/hub/runs/:id/events', async (req, res) => { + const auth = readAuth(); + if (!auth) { + res.status(401).json({ error: 'Not logged in' }); + return; + } + try { + const client = createHubClient(auth.hub.url, auth); + const after = req.query.after as string | undefined; + const events = await client.getRunEvents(req.params.id, after); + res.json(events); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + res.status(502).json({ error: message }); + } + }); + return router; }; diff --git a/src/hub/hub-client.ts b/src/hub/hub-client.ts index 74ca928..cf122d7 100644 --- a/src/hub/hub-client.ts +++ b/src/hub/hub-client.ts @@ -21,6 +21,8 @@ export interface HubClient { createRun: (machineId: string, agentName: string, input: string) => Promise; cancelRun: (runId: string) => Promise; sendRunInput: (runId: string, text: string) => Promise; + getRun: (runId: string) => Promise; + getRunEvents: (runId: string, after?: string) => Promise; } export const createHubClient = (hubUrl: string, auth: AuthState): HubClient => { @@ -89,5 +91,16 @@ export const createHubClient = (hubUrl: string, auth: AuthState): HubClient => { sendRunInput: async (runId, text) => { await request('POST', `/runs/${runId}/input`, { text }); }, + + getRun: async (runId) => { + const data = await request('GET', `/runs/${runId}`); + return data; + }, + + getRunEvents: async (runId, after) => { + const path = after ? `/runs/${runId}/events?after=${after}` : `/runs/${runId}/events`; + const data = await request('GET', path); + return data as unknown[]; + }, }; };