Files
paseo/packages/cli/src/commands/agent/ls.ts
T
667664847d Connect to remote daemons over SSH (#3989)
Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations.

Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits.

Closes #3949

Co-authored-by: James Strain <jstrain@nvidia.com>
Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
2026-08-28 16:32:12 +02:00

272 lines
8.5 KiB
TypeScript

import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { isSameOrDescendantPath } from "../../utils/paths.js";
type FetchAgentsOptions = NonNullable<
Parameters<Awaited<ReturnType<typeof connectToDaemon>>["fetchAgents"]>[0]
>;
export function addLsOptions(cmd: Command): Command {
return cmd
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "List agents across all directories")
.option(
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option("--thinking <id>", "Filter by thinking option ID");
}
/** Agent list item for display */
export interface AgentListItem {
id: string;
shortId: string;
name: string;
provider: string;
thinking: string;
status: string;
cwd: string;
created: string;
}
/** Helper to get relative time string */
function relativeTime(date: Date | string): string {
const now = Date.now();
const then = new Date(date).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
return `${Math.floor(seconds / 86400)} days ago`;
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return "~" + path.slice(home.length);
}
return path;
}
function normalizeModelId(modelId: string | null | undefined): string | null {
if (typeof modelId !== "string") return null;
const normalized = modelId.trim();
if (!normalized || normalized.toLowerCase() === "default") return null;
return normalized;
}
/** Schema for agent ls output */
export const agentLsSchema: OutputSchema<AgentListItem> = {
idField: "shortId",
columns: [
{ header: "AGENT ID", field: "shortId", width: 12 },
{ header: "NAME", field: "name", width: 20 },
{ header: "PROVIDER", field: "provider", width: 15 },
{ header: "THINKING", field: "thinking", width: 12 },
{
header: "STATUS",
field: "status",
width: 10,
color: (value) => {
if (value === "running") return "green";
if (value === "idle") return "yellow";
if (value === "error") return "red";
return undefined;
},
},
{ header: "CWD", field: "cwd", width: 30 },
{ header: "CREATED", field: "created", width: 15 },
],
};
/** Transform agent snapshot to AgentListItem */
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
name: agent.title ?? "-",
provider: model ? `${agent.provider}/${model}` : agent.provider,
thinking: agent.effectiveThinkingOptionId ?? "auto",
status: agent.status,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
};
}
export type AgentLsResult = ListResult<AgentListItem>;
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
const reason = cause instanceof Error ? cause.message : String(cause);
const isSsh = host.trim().startsWith("ssh://");
return {
code: "DAEMON_NOT_RUNNING",
message: `Cannot reach the daemon at ${host}: ${reason}`,
details: isSsh
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
: [
"Start a local daemon with: paseo daemon start",
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
].join("\n"),
};
}
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean;
/** -g: List agents across all directories */
global?: boolean;
/** Filter by specific status */
status?: string;
/** Filter by specific cwd */
cwd?: string;
/** Filter by labels (key=value format) */
label?: string[];
/** Filter by thinking option ID */
thinking?: string;
}
function parseLabelFilters(labels: string[] | undefined): Record<string, string> {
const labelFilters: Record<string, string> = {};
for (const labelStr of labels ?? []) {
const eqIndex = labelStr.indexOf("=");
if (eqIndex !== -1) {
const key = labelStr.slice(0, eqIndex);
const value = labelStr.slice(eqIndex + 1);
labelFilters[key] = value;
}
}
return labelFilters;
}
export function buildAgentLsFetchOptions(
options: Pick<AgentLsOptions, "all" | "global" | "label" | "thinking">,
): FetchAgentsOptions {
const labelFilters = parseLabelFilters(options.label);
const normalizedThinkingOptionId = options.thinking?.trim();
const daemonFilter: NonNullable<FetchAgentsOptions["filter"]> = {};
if (options.all) {
daemonFilter.includeArchived = true;
}
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters;
}
if (normalizedThinkingOptionId) {
daemonFilter.thinkingOptionId = normalizedThinkingOptionId;
}
const fetchOptions: FetchAgentsOptions = {};
if (!options.global) {
fetchOptions.scope = "active";
}
if (Object.keys(daemonFilter).length > 0) {
fetchOptions.filter = daemonFilter;
}
return fetchOptions;
}
/**
* Agent ls command semantics:
* - `paseo agent ls` → active non-archived agents
* - `paseo agent ls -g` → global non-archived agents
* - `paseo agent ls -a` → active agents, including archived
* - `paseo agent ls -ag` → global agents, including archived
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command,
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = await connectToDaemon({ host: options.host });
} catch (err) {
throw daemonConnectionFailure(host, err);
}
try {
const normalizedThinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !normalizedThinkingOptionId) {
const error: CommandError = {
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
};
throw error;
}
const labelFilters = parseLabelFilters(options.label);
const fetchPayload = await client.fetchAgents(buildAgentLsFetchOptions(options));
let agents = fetchPayload.entries.map((entry) => entry.agent);
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {
agents = agents.filter((a) => !a.archivedAt);
}
// If explicit status filter is provided, apply it.
if (options.status) {
agents = agents.filter((a) => a.status === options.status);
}
// Optional cwd filter.
if (options.cwd) {
agents = agents.filter((a) => isSameOrDescendantPath(options.cwd!, a.cwd));
}
// Apply label filtering only when explicitly requested.
if (Object.keys(labelFilters).length > 0) {
agents = agents.filter((a) => {
const agentLabels = a.labels;
for (const [key, value] of Object.entries(labelFilters)) {
if (agentLabels[key] !== value) {
return false;
}
}
return true;
});
}
await client.close();
// Sort agents: running first, then idle, then others; within each group, most recent first
const statusOrder = { running: 0, idle: 1 } as Record<string, number>;
agents.sort((a, b) => {
// Primary sort: by status
const aOrder = statusOrder[a.status] ?? 999;
const bOrder = statusOrder[b.status] ?? 999;
if (aOrder !== bOrder) return aOrder - bOrder;
// Secondary sort: by creation time (most recent first)
const aTime = new Date(a.createdAt).getTime();
const bTime = new Date(b.createdAt).getTime();
return bTime - aTime;
});
const items = agents.map(toListItem);
return {
type: "list",
data: items,
schema: agentLsSchema,
};
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "LIST_AGENTS_FAILED",
message: `Failed to list agents: ${message}`,
};
throw error;
}
}