From 6ce4e3f507a56e08e9e33fa9bfc01930af638667 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Jul 2026 15:41:01 +0200 Subject: [PATCH] Let agents rename workspaces (#1876) * feat(agent): let agents rename workspaces * fix(agent): scope workspace rename tool * fix(agent): allow explicit workspace renames --- docs/product.md | 2 +- .../src/server/agent/mcp-server.test.ts | 191 +++++++++++++++++- .../src/server/agent/tools/paseo-tools.ts | 78 +++++++ packages/server/src/server/bootstrap.ts | 1 + 4 files changed, 270 insertions(+), 2 deletions(-) diff --git a/docs/product.md b/docs/product.md index e05728eab..fbfc4cfa0 100644 --- a/docs/product.md +++ b/docs/product.md @@ -73,7 +73,7 @@ Anyone who builds software: - Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP - One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers - Voice mode: dictate prompts or talk through problems hands-free -- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees) +- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming) - Scheduled agents (cron-style triggers) via app, CLI, and MCP - Frequent releases (multiple per week) - Community contributions across packaging, providers, and bug fixes diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 8bfee67b2..707269d84 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -23,7 +23,11 @@ import { AgentPermissionRequestPayloadSchema, AgentSnapshotPayloadSchema, } from "@getpaseo/protocol/messages"; -import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js"; +import { + createPersistedWorkspaceRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, +} from "../workspace-registry.js"; import type { CreateScheduleInput, StoredSchedule, @@ -3070,6 +3074,191 @@ describe("update_agent MCP tool", () => { }); }); +describe("rename_workspace MCP tool", () => { + const logger = createTestLogger(); + + it("renames the caller workspace when workspaceId is omitted", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "wks_parent", + projectId: "proj_parent", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-07-03T09:00:00.000Z", + updatedAt: "2026-07-03T09:00:00.000Z", + }); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + const upsertedWorkspaces: PersistedWorkspaceRecord[] = []; + const emittedWorkspaceIds: string[][] = []; + spies.agentManager.getAgent.mockReturnValue( + createManagedAgent({ + id: "parent-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + }), + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + upsert: async (record) => { + upsertedWorkspaces.push(record); + workspaces.set(record.workspaceId, record); + }, + }, + emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { + emittedWorkspaceIds.push(Array.from(workspaceIds)); + }, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "rename_workspace"); + + const response = await invokeToolWithParsedInput(tool, { + title: " Payments flow ", + }); + + expect(upsertedWorkspaces).toEqual([ + { + ...workspace, + title: "Payments flow", + updatedAt: expect.any(String), + }, + ]); + expect(response.structuredContent).toEqual({ + success: true, + workspaceId: "wks_parent", + title: "Payments flow", + }); + expect(emittedWorkspaceIds).toEqual([["wks_parent"]]); + }); + + it("renames an explicit workspace outside the caller workspace", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const parentWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "wks_parent", + projectId: "proj_parent", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-07-03T09:00:00.000Z", + updatedAt: "2026-07-03T09:00:00.000Z", + }); + const otherWorkspace = createPersistedWorkspaceRecord({ + workspaceId: "wks_other", + projectId: "proj_other", + cwd: TARGET_CWD, + kind: "local_checkout", + displayName: "other", + createdAt: "2026-07-03T09:00:00.000Z", + updatedAt: "2026-07-03T09:00:00.000Z", + }); + const workspaces = new Map([ + [parentWorkspace.workspaceId, parentWorkspace], + [otherWorkspace.workspaceId, otherWorkspace], + ]); + const upsertedWorkspaces: PersistedWorkspaceRecord[] = []; + const emittedWorkspaceIds: string[][] = []; + spies.agentManager.getAgent.mockReturnValue( + createManagedAgent({ + id: "parent-agent", + cwd: REPO_CWD, + workspaceId: parentWorkspace.workspaceId, + }), + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + upsert: async (record) => { + upsertedWorkspaces.push(record); + workspaces.set(record.workspaceId, record); + }, + }, + emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { + emittedWorkspaceIds.push(Array.from(workspaceIds)); + }, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "rename_workspace"); + + const response = await invokeToolWithParsedInput(tool, { + workspaceId: "wks_other", + title: "Payments flow", + }); + + expect(upsertedWorkspaces).toEqual([ + { + ...otherWorkspace, + title: "Payments flow", + updatedAt: expect.any(String), + }, + ]); + expect(response.structuredContent).toEqual({ + success: true, + workspaceId: "wks_other", + title: "Payments flow", + }); + expect(emittedWorkspaceIds).toEqual([["wks_other"]]); + }); + + it("rejects archived workspaces", async () => { + const { agentManager, agentStorage, spies } = createTestDeps(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: "wks_archived", + projectId: "proj_parent", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "main", + archivedAt: "2026-07-03T10:00:00.000Z", + createdAt: "2026-07-03T09:00:00.000Z", + updatedAt: "2026-07-03T10:00:00.000Z", + }); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + const upsertedWorkspaces: PersistedWorkspaceRecord[] = []; + const emittedWorkspaceIds: string[][] = []; + spies.agentManager.getAgent.mockReturnValue( + createManagedAgent({ + id: "parent-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + }), + ); + const server = await createAgentMcpServer({ + agentManager, + agentStorage, + providerSnapshotManager: createOpenCodeManager().manager, + workspaceRegistry: { + get: async (workspaceId) => workspaces.get(workspaceId) ?? null, + upsert: async (record) => { + upsertedWorkspaces.push(record); + workspaces.set(record.workspaceId, record); + }, + }, + emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => { + emittedWorkspaceIds.push(Array.from(workspaceIds)); + }, + callerAgentId: "parent-agent", + logger, + }); + const tool = registeredTool(server, "rename_workspace"); + + await expect( + invokeToolWithParsedInput(tool, { + title: "Payments flow", + }), + ).rejects.toThrow("Workspace wks_archived is archived"); + expect(upsertedWorkspaces).toEqual([]); + expect(emittedWorkspaceIds).toEqual([]); + }); +}); + describe("create_schedule MCP tool", () => { const logger = createTestLogger(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 9d6d75150..f10c7d1b3 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -65,6 +65,7 @@ import { } from "../lifecycle-command.js"; import type { GitHubService } from "../../../services/github-service.js"; import type { WorkspaceGitService } from "../../workspace-git-service.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; import { WorktreeRequestError } from "../../worktree-errors.js"; import { archiveCommand, @@ -99,6 +100,7 @@ export interface PaseoToolHostDependencies { listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; + workspaceRegistry?: Pick; markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"]; clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"]; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; @@ -554,6 +556,22 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase return options.ensureWorkspaceForCreate(resolvedCwd); } + function resolveWorkspaceIdForRename(requestedWorkspaceId?: string): string { + const explicitWorkspaceId = requestedWorkspaceId?.trim(); + if (explicitWorkspaceId) { + return explicitWorkspaceId; + } + + if (callerAgentId) { + const callerAgent = resolveCallerAgent(); + if (!callerAgent?.workspaceId) { + throw new Error(`Caller agent ${callerAgentId} has no current workspace`); + } + return callerAgent.workspaceId; + } + throw new Error("workspaceId is required outside an agent-scoped session"); + } + const buildCallerAgentScheduleConfigExtras = ( callerAgent: NonNullable>, ): Record => { @@ -1781,6 +1799,66 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase }, ); + registerTool( + "rename_workspace", + { + title: "Rename workspace", + description: + "Rename a workspace by setting its user-visible title. Omit workspaceId to rename your current workspace.", + inputSchema: { + workspaceId: z + .string() + .trim() + .min(1) + .optional() + .describe("Workspace id to rename. Omit to rename your current workspace."), + title: z + .string() + .trim() + .min(1, "title is required") + .describe("New user-visible workspace title."), + }, + outputSchema: { + success: z.boolean(), + workspaceId: z.string(), + title: z.string(), + }, + }, + async ({ workspaceId: requestedWorkspaceId, title }) => { + if (!options.workspaceRegistry) { + throw new Error("Workspace registry is required to rename workspaces"); + } + if (!options.emitWorkspaceUpdatesForWorkspaceIds) { + throw new Error("Workspace update emitter is required to rename workspaces"); + } + + const workspaceId = resolveWorkspaceIdForRename(requestedWorkspaceId); + const existing = await options.workspaceRegistry.get(workspaceId); + if (!existing) { + throw new Error(`Workspace ${workspaceId} not found`); + } + if (existing.archivedAt) { + throw new Error(`Workspace ${workspaceId} is archived`); + } + + await options.workspaceRegistry.upsert({ + ...existing, + title, + updatedAt: new Date().toISOString(), + }); + await options.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]); + + return { + content: [], + structuredContent: ensureValidJson({ + success: true, + workspaceId, + title, + }), + }; + }, + ); + registerTool( "list_terminals", { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 1ce92dcdf..6d158716b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -976,6 +976,7 @@ export async function createPaseoDaemon( listActiveWorkspaces: listActiveWorkspacesExternal, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, + workspaceRegistry, markWorkspaceArchiving: markWorkspaceArchivingExternal, clearWorkspaceArchiving: clearWorkspaceArchivingExternal, ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,