Files
paseo/packages/cli/src/commands/agent/ls.ts
T
Mohamed Boudra 129102d5c6 feat(server): add background agents
An agent can be created background in any workspace. It stays a durable,
addressable agent, but default discovery, workspace status and attention
leave it out. Effective background is agent.background ||
workspace.background.

- Protocol: `create_agent_request.background` now marks the agent itself
  and stays the workspace intent for a workspace the request creates.
  Snapshots carry `background`. The daemon advertises
  `features.backgroundAgents`. Clients advertise the `background_agents`
  capability.
- Daemon: the flag persists in the agent record, so restart and resume
  keep it. AgentDirectory owns visibility. Inclusive reads and
  subscriptions from sources without `background_agents` see only agents
  in background workspaces, because older apps would otherwise open a tab
  for each one.
- Inheritance: omitted on agent-scoped creation, children and new
  workspaces take the caller agent's background.
- MCP: create_agent accepts `background`.
- SDK: placing a background agent in an existing workspace, or under a
  caller, fails fast with "Update the host to use background agents." on
  older hosts.
- CLI: `ls -a` and `ls --background` add a BACKGROUND column.
2026-11-03 13:38:08 +01:00

285 lines
9.0 KiB
TypeScript

import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { connectToDaemon } from "../../utils/client.js";
import { fetchAllAgents } from "../../utils/agent-directory.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 and background agents")
.option("-g, --global", "List agents across all directories")
.option("--background", "Include background agents and agents in background workspaces")
.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;
background: boolean;
}
/** 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 },
],
};
/** Inclusive listings mark background agents so they read apart from visible ones. */
const agentLsInclusiveSchema: OutputSchema<AgentListItem> = {
...agentLsSchema,
columns: [
...agentLsSchema.columns,
{ header: "BACKGROUND", field: (item) => (item.background ? "yes" : "-"), width: 10 },
],
};
/** 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),
background: agent.background === true,
};
}
export type AgentLsResult = ListResult<AgentListItem>;
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean;
/** -g: List agents across all directories */
global?: boolean;
/** --background: Include background workspace agents */
background?: 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" | "background" | "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 (options.background || options.all) {
daemonFilter.includeBackground = true;
}
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters;
}
if (normalizedThinkingOptionId) {
daemonFilter.thinkingOptionId = normalizedThinkingOptionId;
}
const fetchOptions: FetchAgentsOptions = {};
if (!options.global && !options.all) {
fetchOptions.scope = "active";
}
if (Object.keys(daemonFilter).length > 0) {
fetchOptions.filter = daemonFilter;
}
return fetchOptions;
}
interface AgentLsClient {
fetchAgents(options?: FetchAgentsOptions): Promise<{
entries: { agent: AgentSnapshotPayload }[];
pageInfo: { nextCursor: string | null };
}>;
close(): Promise<void>;
}
/**
* Agent ls command semantics:
* - `paseo agent ls` → active non-archived agents
* - `paseo agent ls -g` → global non-archived agents
* - `paseo agent ls -a` → all agents, including archived and background
* - `paseo agent ls -ag` → global agents, including archived
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command,
): Promise<AgentLsResult> {
return listAgents(options);
}
export async function listAgents(
options: AgentLsOptions,
connect: (
options: Parameters<typeof connectToDaemon>[0],
) => Promise<AgentLsClient> = connectToDaemon,
): Promise<AgentLsResult> {
const client = await connect({ target: options.daemonTarget });
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 fetchOptions = buildAgentLsFetchOptions(options);
let agents = await fetchAllAgents(client, fetchOptions);
// 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: options.all || options.background ? agentLsInclusiveSchema : 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;
}
}