Skip to content
Merged
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
56 changes: 54 additions & 2 deletions src/commands/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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;
}

Expand Down Expand Up @@ -55,3 +61,49 @@ export const registerAgents = (program: Command): void => {
}
});
};

const listHubAgents = async (jsonMode: boolean): Promise<void> => {
let agents: HubAgent[];
try {
agents = await get<HubAgent[]>('/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
);
}
};
27 changes: 25 additions & 2 deletions src/commands/logout.ts
Original file line number Diff line number Diff line change
@@ -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.'));
});
};
66 changes: 63 additions & 3 deletions src/commands/machines.ts
Original file line number Diff line number Diff line change
@@ -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<Machine[]>('/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`;
};
153 changes: 136 additions & 17 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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>', 'Agent name')
.argument('<agent>', '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')
Expand All @@ -48,27 +59,96 @@ export const registerRun = (program: Command): void => {
return;
}

const config = opts.config
? (JSON.parse(opts.config) as Record<string, unknown>)
: undefined;
const { agentName, machineName } = parseAgentTarget(agent);

const { runId } = await post<RunResponse>(`/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<void> => {
const config = opts.config ? (JSON.parse(opts.config) as Record<string, unknown>) : undefined;

const { runId } = await post<RunResponse>(`/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<void> => {
// Resolve machine name to machine ID
let machines: Array<{ id: string; name: string }>;
try {
machines = await get<Array<{ id: string; name: string }>>('/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<string, unknown>).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<void> =>
new Promise((resolve) => {
const ws = connectWs((data) => {
Expand Down Expand Up @@ -109,3 +189,42 @@ const streamRun = (runId: string, jsonMode: boolean): Promise<void> =>
resolve();
});
});

const pollRemoteRun = async (runId: string, jsonMode: boolean): Promise<void> => {
let lastEventId: string | undefined;
const pollInterval = 1000;

const poll = async (): Promise<boolean> => {
try {
const url = lastEventId
? `/api/hub/runs/${runId}/events?after=${lastEventId}`
: `/api/hub/runs/${runId}/events`;

const events = await get<Array<{ id: string; type: string; data: unknown }>>(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));
}
};
Loading