mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +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>
284 lines
9.7 KiB
TypeScript
284 lines
9.7 KiB
TypeScript
import path from "node:path";
|
|
import { readFileSync } from "node:fs";
|
|
import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity";
|
|
import { connectDaemonClient } from "./daemon-client-loader";
|
|
import { withProjectOwnership } from "./project-ownership";
|
|
import { createTempDirectory, createTempGitRepo } from "./workspace";
|
|
|
|
export interface SeedWorkspaceDescriptor {
|
|
id: string;
|
|
name: string;
|
|
projectId: string;
|
|
projectDisplayName: string;
|
|
projectRootPath: string;
|
|
workspaceDirectory: string;
|
|
diffStat: { additions: number; deletions: number } | null;
|
|
labels?: string[];
|
|
}
|
|
|
|
interface SeedProjectDescriptor {
|
|
projectId: string;
|
|
projectKey?: string;
|
|
}
|
|
|
|
/**
|
|
* The general-purpose E2E daemon client used to seed and drive state out of
|
|
* band (workspaces, agents, terminals) while the UI is exercised through the
|
|
* browser. Domain-specific helpers wrap it for their own flows; specs should
|
|
* prefer those wrappers over reaching for this client directly.
|
|
*/
|
|
export interface SeedDaemonClient {
|
|
connect(): Promise<void>;
|
|
close(): Promise<void>;
|
|
addProject(cwd: string): Promise<{
|
|
project: {
|
|
projectId: string;
|
|
projectKey?: string;
|
|
projectDisplayName: string;
|
|
projectRootPath: string;
|
|
} | null;
|
|
error: string | null;
|
|
}>;
|
|
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
|
renameProject(projectId: string, customName: string | null): Promise<void>;
|
|
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
|
entries: SeedWorkspaceDescriptor[];
|
|
}>;
|
|
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
|
clearWorkspaceAttention(workspaceId: string): Promise<void>;
|
|
setWorkspaceLabel(input: {
|
|
workspaceId: string;
|
|
label: { name: string; color: "red" };
|
|
assigned: boolean;
|
|
}): Promise<unknown>;
|
|
listProjects(): Promise<{ projects: SeedProjectDescriptor[] }>;
|
|
createWorkspace(input: {
|
|
source:
|
|
| { kind: "directory"; path: string; projectId?: string }
|
|
| {
|
|
kind: "worktree";
|
|
cwd?: string;
|
|
projectId?: string;
|
|
action?: "branch-off" | "checkout";
|
|
refName?: string;
|
|
baseBranch?: string;
|
|
githubPrNumber?: number;
|
|
worktreeSlug?: string;
|
|
};
|
|
title?: string;
|
|
background?: boolean;
|
|
}): Promise<{
|
|
workspace: SeedWorkspaceDescriptor | null;
|
|
error: string | null;
|
|
}>;
|
|
archiveWorkspace(workspaceId: string): Promise<{ error: string | null }>;
|
|
/**
|
|
* Force the daemon to recompute its git snapshot and diff for a checkout,
|
|
* mirroring the UI's manual refresh. Tests use this to make an out-of-band
|
|
* working-tree write authoritative before asserting on it in the UI, instead
|
|
* of racing the filesystem watcher's debounce.
|
|
*/
|
|
checkoutRefresh(cwd: string): Promise<{ success: boolean; error: unknown }>;
|
|
createTerminal(
|
|
cwd: string,
|
|
name?: string,
|
|
requestId?: string,
|
|
options?: { agentId?: string; command?: string; args?: string[]; workspaceId?: string },
|
|
): Promise<{
|
|
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
|
error: string | null;
|
|
}>;
|
|
listTerminals(
|
|
cwd?: string,
|
|
requestId?: string,
|
|
options?: { workspaceId?: string },
|
|
): Promise<{
|
|
terminals: Array<{
|
|
id: string;
|
|
name: string;
|
|
cwd: string;
|
|
title?: string;
|
|
activity?: TerminalActivity | null;
|
|
}>;
|
|
error?: string | null;
|
|
}>;
|
|
createAgent(options: {
|
|
provider: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
title?: string;
|
|
modeId?: string;
|
|
model?: string;
|
|
thinkingOptionId?: string;
|
|
featureValues?: Record<string, unknown>;
|
|
initialPrompt?: string;
|
|
labels?: Record<string, string>;
|
|
}): Promise<{ id: string; status: string }>;
|
|
fetchAgents(options?: { scope?: "active" }): Promise<{
|
|
entries: Array<{
|
|
agent: {
|
|
id: string;
|
|
provider: string;
|
|
cwd: string;
|
|
workspaceId?: string;
|
|
model: string | null;
|
|
currentModeId: string | null;
|
|
status: string;
|
|
title?: string | null;
|
|
};
|
|
}>;
|
|
}>;
|
|
fetchRecentProviderSessions(options: {
|
|
cwd: string;
|
|
providers: string[];
|
|
limit: number;
|
|
}): Promise<{
|
|
entries: Array<{
|
|
providerId: string;
|
|
providerHandleId: string;
|
|
cwd: string;
|
|
firstPromptPreview?: string | null;
|
|
}>;
|
|
}>;
|
|
updateAgent(agentId: string, updates: { name?: string }): Promise<void>;
|
|
setAgentMode(agentId: string, modeId: string): Promise<unknown>;
|
|
waitForAgentUpsert(
|
|
agentId: string,
|
|
predicate: (snapshot: { status: string }) => boolean,
|
|
timeout?: number,
|
|
): Promise<{ status: string }>;
|
|
sendAgentMessage(agentId: string, text: string): Promise<void>;
|
|
waitForFinish(
|
|
agentId: string,
|
|
timeout?: number,
|
|
): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
|
|
archiveAgent(agentId: string): Promise<{ archivedAt: string }>;
|
|
refreshAgent(agentId: string): Promise<unknown>;
|
|
fetchAgent(options: {
|
|
agentId: string;
|
|
}): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>;
|
|
getLastServerInfoMessage(): {
|
|
features?: {
|
|
projectAdd?: boolean;
|
|
workspaceRecovery?: boolean;
|
|
workspaceMarkUnread?: boolean;
|
|
} | null;
|
|
} | null;
|
|
fetchAgentHistory(options?: {
|
|
page?: { limit: number };
|
|
}): Promise<{ entries: Array<{ agent: { id: string } }> }>;
|
|
subscribeTerminal(
|
|
terminalId: string,
|
|
): Promise<{ terminalId: string; slot: number; error: null } | { error: string }>;
|
|
sendTerminalInput(
|
|
terminalId: string,
|
|
message:
|
|
| { type: "input"; data: string }
|
|
| { type: "resize"; rows: number; cols: number; intent?: "claim" | "update" },
|
|
): void;
|
|
captureTerminal(
|
|
terminalId: string,
|
|
options?: { start?: number; end?: number; stripAnsi?: boolean },
|
|
): Promise<{ terminalId: string; lines: string[]; totalLines: number; requestId: string }>;
|
|
onTerminalStreamEvent(
|
|
handler: (event: { terminalId: string; type: string; data?: Uint8Array }) => void,
|
|
): () => void;
|
|
killTerminal(terminalId: string): Promise<{ error: string | null }>;
|
|
}
|
|
|
|
export async function connectSeedClient(options?: {
|
|
port?: number;
|
|
/** Use only with a private host whose teardown removes its entire PASEO_HOME. */
|
|
projectOwnership?: "client" | "host";
|
|
}): Promise<SeedDaemonClient> {
|
|
const client = await connectDaemonClient<SeedDaemonClient>({
|
|
clientIdPrefix: "seed",
|
|
appVersion: loadAppVersion(),
|
|
port: options?.port,
|
|
});
|
|
return options?.projectOwnership === "host" ? client : withProjectOwnership(client);
|
|
}
|
|
|
|
/**
|
|
* A temp project opened as a workspace, with a seed client connected to drive
|
|
* it out of band. `cleanup` closes the client and removes the project. This is
|
|
* the canonical bootstrap for specs that need a real workspace plus daemon
|
|
* access; domain helpers (e.g. mock-agent) build on it rather than re-rolling
|
|
* the trio. `repoPath` is the project root on disk (git repo or plain dir).
|
|
*/
|
|
export interface SeededWorkspace {
|
|
client: SeedDaemonClient;
|
|
repoPath: string;
|
|
workspaceId: string;
|
|
workspaceName: string;
|
|
workspaceDirectory: string;
|
|
/** Host-local identity used by daemon project operations. */
|
|
projectId: string;
|
|
/** Opaque cross-host key used by grouped project UI. */
|
|
projectKey: string;
|
|
/** Project label the UI shows (owner/repo for known remotes, else basename). */
|
|
projectDisplayName: string;
|
|
cleanup(): Promise<void>;
|
|
}
|
|
|
|
export async function seedWorkspace(options: {
|
|
repoPrefix: string;
|
|
title?: string;
|
|
background?: boolean;
|
|
port?: number;
|
|
/** Repo fixture options; only applies to git projects (the default). */
|
|
repo?: Parameters<typeof createTempGitRepo>[1];
|
|
/** Set to false to seed a plain non-git directory instead of a git repo. */
|
|
git?: boolean;
|
|
}): Promise<SeededWorkspace> {
|
|
const project =
|
|
options.git === false
|
|
? await createTempDirectory(options.repoPrefix)
|
|
: await createTempGitRepo(options.repoPrefix, options.repo);
|
|
const client = await connectSeedClient({ port: options.port });
|
|
try {
|
|
const created = await client.createWorkspace({
|
|
source: { kind: "directory", path: project.path },
|
|
title: options.title,
|
|
background: options.background,
|
|
});
|
|
if (!created.workspace) {
|
|
throw new Error(created.error ?? `Failed to create workspace ${project.path}`);
|
|
}
|
|
const workspace = created.workspace;
|
|
const listed = await client.listProjects();
|
|
const descriptor = listed.projects.find((item) => item.projectId === workspace.projectId);
|
|
if (!descriptor?.projectKey) {
|
|
throw new Error(`Created project ${workspace.projectId} has no project key`);
|
|
}
|
|
return {
|
|
client,
|
|
repoPath: project.path,
|
|
workspaceId: workspace.id,
|
|
workspaceName: workspace.name,
|
|
workspaceDirectory: workspace.workspaceDirectory,
|
|
projectId: workspace.projectId,
|
|
projectKey: descriptor.projectKey,
|
|
projectDisplayName: workspace.projectDisplayName,
|
|
cleanup: async () => {
|
|
await client.removeProject(workspace.projectId).catch(() => undefined);
|
|
await client.close().catch(() => undefined);
|
|
await project.cleanup().catch(() => undefined);
|
|
},
|
|
};
|
|
} catch (error) {
|
|
await client.close().catch(() => undefined);
|
|
await project.cleanup().catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function loadAppVersion(): string {
|
|
const packageJsonPath = path.resolve(__dirname, "../../../package.json");
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
|
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
|
throw new Error(`Missing app version in ${packageJsonPath}`);
|
|
}
|
|
return packageJson.version;
|
|
}
|