mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
* feat(server): let clients create internal agents The daemon's own branch-name and commit-message generators run as internal agents: never persisted, listed, flagged, announced, or seen by plugin lifecycle hooks. Nothing outside the daemon could ask for one, because the wire config schema strips `internal` and the agent.create transform re-pins it. A plugin that runs a one-turn helper per event therefore left an archived session in History every time. Add an optional `internal` flag to `create_agent_request` (inherited by `agent.create.request`), copy it onto the config in the session's create path, and skip provider session persistence for it like the generators do. The client SDK forwards the flag on both creation paths and gates it once on `server_info.features.internalAgents`. Two daemon paths assumed every agent is public and had to follow: `resolveAgentIdentifier` now accepts a live agent by exact id, since the id set it matched against came from listings that exclude internal agents and left `wait_for_finish` answering "Agent not found"; and archiving an internal agent now closes it without writing the record that `registry.applySnapshot` used to leave behind, so `autoArchive` does not accumulate hidden files on disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): keep internal agents whole through failure, archive, and wait Review follow-ups for internal agents: - A create that fails after the runtime is registered now closes the internal agent before rethrowing, so a hidden runtime cannot outlive the failed request. Public agents keep today's behaviour. - Archiving an internal agent cascades to attached children like any parent. cascadeArchiveChildren takes the parent record instead of reading it back from storage, where an internal agent never is. - The archived snapshot and last message of an internal agent stay readable by exact id for ten minutes, so create-then-waitForFinish returns the finished turn whether the wait lands before or after an auto-archive. Storage plays that role for public agents. - The internal-agent e2e scenarios live in the existing create_agent_request lifecycle suite rather than a new file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes Internal children never reach storage, so the parent archive cascade scans live agents for them as well. A failed internal create now clears the created id only after the runtime is archived, so worktree cleanup cannot delete the directory under a runtime that would not close. Stored agent payloads carry lastError, so a retained failed helper reports its error through wait_for_finish instead of the generic message. * feat(cli,mcp): expose internal agents and document --no-wait paseo run gains --internal and create_agent gains internal, both forwarded to the create request. --no-wait becomes the documented spelling for returning immediately, matching paseo send; --background and --detach stay as hidden aliases under COMPAT tags. * test: follow the --no-wait rename and stub getRetiredInternalAgent * feat: list internal agents on request and add internal workspaces Internal agents stay hidden by default, but a caller can now ask for them: `fetch_agents_request` takes `filter.includeInternal`, snapshots carry `internal: true`, and `paseo ls --internal` shows them in an INTERNAL column. The app never passes the flag. Workspaces get the same flag. `workspace.create.request` with `internal: true` persists a hidden workspace: listings, update subscriptions, sync reads, empty-project counting, open-by-path, MCP `list_workspaces`, and plugin workspace hooks all skip it unless the caller opts in with `filter.includeInternal`. Every agent created inside an internal workspace is internal and unpersisted; AgentManager applies that from the workspace id before the plugin hook so creators cannot opt out. Archive and shutdown enumerate internal agents so nothing inside an internal workspace outlives it. Surfaces: `paseo workspace create --internal`, `paseo workspace ls --internal`, `paseo run --internal --new-workspace`, MCP `create_workspace({ internal })`, SDK `workspaces.create({ internal })` and `list({ filter: { includeInternal } })`. Clients gate on `features.internalWorkspaces`. Co-Authored-By: Claude <noreply@anthropic.com> * feat(workspaces): add background visibility with inherited creation defaults * fix(cli): resolve workspace callers on the selected daemon * test(workspaces): keep caller integration coverage in server suite * test(cli): exercise workspace callers through CLI integration harness * test(mcp): remove obsolete execution flags from browser fixtures --------- Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
256 lines
8.0 KiB
TypeScript
256 lines
8.0 KiB
TypeScript
import type { Command } from "commander";
|
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
|
import { connectToDaemon } 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 and background agents")
|
|
.option("-g, --global", "List agents across all directories")
|
|
.option("--background", "Include background workspace agents")
|
|
.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>;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
const client = await connectToDaemon({ 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 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;
|
|
}
|
|
}
|