diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e8a1df50e..92f096aed 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -21,6 +21,8 @@ Agent-scoped `create_agent` accepts `detached: true` for agents that should stan - **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. - **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it. +Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. + `notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents. ## Archive @@ -70,6 +72,8 @@ parentAgentId === thisAgent.id AND !archivedAt Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client. +To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. + ## Why this shape The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally: @@ -77,6 +81,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**, - **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on - **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow - **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface +- **Detach button on track rows** — lets a subagent continue independently without killing its work - **Cascade archive on parent** — keeps subagents from leaking when the parent is archived We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on. diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index d8f8dbd4d..c95217f0e 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -82,6 +82,7 @@ export interface SeedDaemonClient { thinkingOptionId?: string; featureValues?: Record; initialPrompt?: string; + labels?: Record; }): Promise<{ id: string; status: string }>; fetchAgents(options?: { scope?: "active" }): Promise<{ entries: Array<{ diff --git a/packages/app/e2e/helpers/subagents.ts b/packages/app/e2e/helpers/subagents.ts new file mode 100644 index 000000000..46a227e66 --- /dev/null +++ b/packages/app/e2e/helpers/subagents.ts @@ -0,0 +1,83 @@ +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { expect, type Page } from "@playwright/test"; +import type { SeededWorkspace } from "./seed-client"; + +export interface SeededSubagentPair { + parent: { + id: string; + title: string; + }; + child: { + id: string; + title: string; + }; + workspaceId: string; +} + +export async function seedParentWithSubagent( + workspace: Pick, + input: { parentTitle: string; childTitle: string }, +): Promise { + const parent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: input.parentTitle, + modeId: "load-test", + model: "ten-second-stream", + }); + const child = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: input.childTitle, + modeId: "load-test", + model: "ten-second-stream", + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + }, + }); + + return { + parent: { + id: parent.id, + title: input.parentTitle, + }, + child: { + id: child.id, + title: input.childTitle, + }, + workspaceId: workspace.workspaceId, + }; +} + +export async function openSubagentsTrack(page: Page): Promise { + await page.getByTestId("subagents-track-header").click(); +} + +export async function expectSubagentRowVisible(page: Page, childId: string): Promise { + await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({ + timeout: 30_000, + }); +} + +export async function expectSubagentRowGone(page: Page, childId: string): Promise { + await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, { + timeout: 30_000, + }); +} + +export async function detachSubagentFromTrack(page: Page, childId: string): Promise { + const row = page.getByTestId(`subagents-track-row-${childId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + page.once("dialog", (dialog) => { + expect(dialog.message()).toContain("Detach subagent?"); + void dialog.accept(); + }); + + const detachButton = page.getByTestId(`subagents-track-detach-${childId}`); + await expect(detachButton).toBeVisible({ timeout: 30_000 }); + await detachButton.click(); +} diff --git a/packages/app/e2e/subagent-detach.spec.ts b/packages/app/e2e/subagent-detach.spec.ts new file mode 100644 index 000000000..b2da2217c --- /dev/null +++ b/packages/app/e2e/subagent-detach.spec.ts @@ -0,0 +1,44 @@ +import { test } from "./fixtures"; +import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; +import { expectAgentTabActive } from "./helpers/launcher"; +import { openAgentRoute } from "./helpers/mock-agent"; +import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { + detachSubagentFromTrack, + expectSubagentRowGone, + expectSubagentRowVisible, + openSubagentsTrack, + seedParentWithSubagent, +} from "./helpers/subagents"; + +test.describe("Subagent detach", () => { + let workspace: SeededWorkspace; + + test.beforeAll(async () => { + workspace = await seedWorkspace({ repoPrefix: "subagent-detach-" }); + }); + + test.afterAll(async () => { + await workspace?.cleanup(); + }); + + test("detaching a subagent focuses it as a workspace tab", async ({ page }) => { + const agents = await seedParentWithSubagent(workspace, { + parentTitle: "Detach parent", + childTitle: "Detached child", + }); + + await openAgentRoute(page, { + workspaceId: agents.workspaceId, + agentId: agents.parent.id, + }); + await openSubagentsTrack(page); + await expectSubagentRowVisible(page, agents.child.id); + + await detachSubagentFromTrack(page, agents.child.id); + + await expectSubagentRowGone(page, agents.child.id); + await expectWorkspaceTabVisible(page, agents.child.id); + await expectAgentTabActive(page, agents.child.id); + }); +}); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8b99a6f77..8b524d770 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1307,6 +1307,8 @@ export const ar: TranslationResources = { backdrop: "خلفية القائمة", }, subagents: { + detachAction: "فصل {{label}}", + detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index a17d54de0..c24647418 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1314,6 +1314,8 @@ export const en = { backdrop: "Menu backdrop", }, subagents: { + detachAction: "Detach {{label}}", + detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", }, diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 8240c7899..1536bb502 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1343,6 +1343,8 @@ export const es: TranslationResources = { backdrop: "Fondo del menú", }, subagents: { + detachAction: "Separar {{label}}", + detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 271066ae9..398cbefb2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1346,6 +1346,8 @@ export const fr: TranslationResources = { backdrop: "Toile de fond du menu", }, subagents: { + detachAction: "Detacher {{label}}", + detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 2a9ceb22c..45690ef1d 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1335,6 +1335,8 @@ export const ru: TranslationResources = { backdrop: "Фон меню", }, subagents: { + detachAction: "Отсоединить {{label}}", + detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index d05a0f5ac..ca0a98c3c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = { backdrop: "菜单背景", }, subagents: { + detachAction: "分离 {{label}}", + detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 1c52f15cd..2dd9048b2 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -77,7 +77,7 @@ import { type Agent, useSessionStore } from "@/stores/session-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; -import { useArchiveSubagent, useSubagentsForParent } from "@/subagents"; +import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -1442,6 +1442,9 @@ function ActiveAgentComposer({ serverId, parentAgentId: agentId, }); + const canDetachSubagents = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, + ); const handleOpenSubagent = useCallback( (subagentId: string) => { navigateToAgent({ serverId, agentId: subagentId }); @@ -1449,6 +1452,7 @@ function ActiveAgentComposer({ [serverId], ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); + const handleDetachSubagent = useDetachSubagent({ serverId }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1545,6 +1549,7 @@ function ActiveAgentComposer({ rows={subagentRows} onOpenSubagent={handleOpenSubagent} onArchiveSubagent={handleArchiveSubagent} + onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> { ).map((row) => row.id), ).toEqual(["child-agent"]); }); + + it("moves a detached child out of the parent section and back into normal workspace tabs", () => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + expect(workspaceKey).toBeTruthy(); + + const parent = makeAgent({ + id: "parent-agent", + title: "Parent agent", + }); + const child = makeAgent({ + id: "child-agent", + parentAgentId: "parent-agent", + title: "Child agent", + }); + + initializeAgents([parent, child]); + reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); + + expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); + expect( + selectSubagentsForParent( + useSessionStore.getState(), + { + serverId: SERVER_ID, + parentAgentId: "parent-agent", + }, + new Set(), + ).map((row) => row.id), + ).toEqual(["child-agent"]); + + appendAgent({ ...child, parentAgentId: null, labels: {} }); + reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); + + expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]); + expect( + selectSubagentsForParent( + useSessionStore.getState(), + { + serverId: SERVER_ID, + parentAgentId: "parent-agent", + }, + new Set(), + ), + ).toEqual([]); + }); }); diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index 460653561..9314961b1 100644 --- a/packages/app/src/subagents/archive-subagent.test.ts +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -16,6 +16,7 @@ interface FakeArchiveSubagentEnv { deps: ArchiveSubagentDeps; recordedArchives: RecordedArchive[]; recordedConfirmInputs: ConfirmDialogInput[]; + recordedErrors: unknown[]; setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void; } @@ -31,10 +32,12 @@ function createFakeEnv( } const recordedArchives: RecordedArchive[] = []; const recordedConfirmInputs: ConfirmDialogInput[] = []; + const recordedErrors: unknown[] = []; return { recordedArchives, recordedConfirmInputs, + recordedErrors, setSubagent(id, snapshot) { subagents.set(id, snapshot); }, @@ -47,6 +50,9 @@ function createFakeEnv( archiveAgent: async (input) => { recordedArchives.push(input); }, + reportError: (error) => { + recordedErrors.push(error); + }, }, }; } @@ -176,7 +182,7 @@ describe("requestArchiveSubagent", () => { expect(env.recordedArchives).toEqual([]); }); - it("swallows archive errors so the caller never sees them", async () => { + it("reports archive errors after the user confirms", async () => { const env = createFakeEnv({ confirmResult: true, initialSubagents: [ @@ -186,12 +192,14 @@ describe("requestArchiveSubagent", () => { }, ], }); + const error = new Error("daemon offline"); env.deps.archiveAgent = async () => { - throw new Error("daemon offline"); + throw error; }; await expect( requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), ).resolves.toBeUndefined(); + expect(env.recordedErrors).toEqual([error]); }); }); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index bc860bf1e..653dcb1cb 100644 --- a/packages/app/src/subagents/archive-subagent.ts +++ b/packages/app/src/subagents/archive-subagent.ts @@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps { getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined; confirm: (input: ConfirmDialogInput) => Promise; archiveAgent: (input: { serverId: string; agentId: string }) => Promise; + reportError: (error: unknown) => void; } export interface RequestArchiveSubagentInput { @@ -62,5 +63,9 @@ export async function requestArchiveSubagent( if (!confirmed) { return; } - void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {}); + try { + await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }); + } catch (error) { + deps.reportError(error); + } } diff --git a/packages/app/src/subagents/detach-subagent.test.ts b/packages/app/src/subagents/detach-subagent.test.ts new file mode 100644 index 000000000..a9875dc18 --- /dev/null +++ b/packages/app/src/subagents/detach-subagent.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; +import { + requestDetachSubagent, + resolveDetachSubagentDialog, + type DetachSubagentDeps, + type ResolveDetachSubagentDialogInput, +} from "./detach-subagent"; + +interface RecordedDetach { + serverId: string; + agentId: string; +} + +interface RecordedOpen { + serverId: string; + agentId: string; +} + +interface FakeDetachSubagentEnv { + deps: DetachSubagentDeps; + recordedDetaches: RecordedDetach[]; + recordedOpens: RecordedOpen[]; + recordedConfirmInputs: ConfirmDialogInput[]; + recordedErrors: unknown[]; +} + +function createFakeEnv( + options: { + confirmResult?: boolean; + initialSubagents?: Array<{ id: string; snapshot: ResolveDetachSubagentDialogInput }>; + } = {}, +): FakeDetachSubagentEnv { + const subagents = new Map(); + for (const entry of options.initialSubagents ?? []) { + subagents.set(entry.id, entry.snapshot); + } + const recordedDetaches: RecordedDetach[] = []; + const recordedOpens: RecordedOpen[] = []; + const recordedConfirmInputs: ConfirmDialogInput[] = []; + const recordedErrors: unknown[] = []; + + return { + recordedDetaches, + recordedOpens, + recordedConfirmInputs, + recordedErrors, + deps: { + getSubagent: (id) => subagents.get(id), + confirm: async (dialog) => { + recordedConfirmInputs.push(dialog); + return options.confirmResult ?? false; + }, + detachAgent: async (input) => { + recordedDetaches.push(input); + }, + openDetachedAgent: (input) => { + recordedOpens.push(input); + }, + reportError: (error) => { + recordedErrors.push(error); + }, + }, + }; +} + +describe("resolveDetachSubagentDialog", () => { + it("uses non-destructive copy for named subagents", () => { + expect(resolveDetachSubagentDialog({ title: "Review branch" })).toEqual({ + title: "Detach subagent?", + message: "Review branch will leave this track and continue as a standalone agent.", + confirmLabel: "Detach", + cancelLabel: "Cancel", + destructive: false, + }); + }); + + it("falls back to this subagent when the title is not displayable", () => { + expect(resolveDetachSubagentDialog({ title: "New Agent" })).toEqual({ + title: "Detach subagent?", + message: "This subagent will leave this track and continue as a standalone agent.", + confirmLabel: "Detach", + cancelLabel: "Cancel", + destructive: false, + }); + }); +}); + +describe("requestDetachSubagent", () => { + it("detaches the subagent with the server id when the user confirms", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], + }); + + await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); + }); + + it("opens the detached subagent after detach succeeds", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], + }); + + await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]); + }); + + it("does not detach the subagent when the user cancels", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], + }); + + await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedDetaches).toEqual([]); + expect(env.recordedOpens).toEqual([]); + }); + + it("asks for confirmation using the resolved dialog for the subagent", async () => { + const env = createFakeEnv({ + confirmResult: false, + initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], + }); + + await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps); + + expect(env.recordedConfirmInputs).toEqual([ + resolveDetachSubagentDialog({ title: "Review branch" }), + ]); + }); + + it("reports detach errors after the user confirms", async () => { + const env = createFakeEnv({ + confirmResult: true, + initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }], + }); + const error = new Error("daemon offline"); + env.deps.detachAgent = async () => { + throw error; + }; + + await expect( + requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), + ).resolves.toBeUndefined(); + expect(env.recordedErrors).toEqual([error]); + expect(env.recordedOpens).toEqual([]); + }); +}); diff --git a/packages/app/src/subagents/detach-subagent.ts b/packages/app/src/subagents/detach-subagent.ts new file mode 100644 index 000000000..f325cb032 --- /dev/null +++ b/packages/app/src/subagents/detach-subagent.ts @@ -0,0 +1,68 @@ +import type { Agent } from "@/stores/session-store"; +import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; + +export interface ResolveDetachSubagentDialogInput { + title: Agent["title"] | null | undefined; +} + +function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function resolveDetachSubagentDialog( + input: ResolveDetachSubagentDialogInput, +): ConfirmDialogInput { + const subagentLabel = resolveSubagentLabel(input.title) ?? "This subagent"; + + return { + title: "Detach subagent?", + message: `${subagentLabel} will leave this track and continue as a standalone agent.`, + confirmLabel: "Detach", + cancelLabel: "Cancel", + destructive: false, + }; +} + +export interface DetachSubagentDeps { + getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined; + confirm: (input: ConfirmDialogInput) => Promise; + detachAgent: (input: { serverId: string; agentId: string }) => Promise; + openDetachedAgent: (input: { serverId: string; agentId: string }) => void; + reportError: (error: unknown) => void; +} + +export interface RequestDetachSubagentInput { + serverId: string; + subagentId: string; +} + +export async function requestDetachSubagent( + input: RequestDetachSubagentInput, + deps: DetachSubagentDeps, +): Promise { + const subagent = deps.getSubagent(input.subagentId); + const confirmed = await deps.confirm( + resolveDetachSubagentDialog({ + title: subagent?.title, + }), + ); + if (!confirmed) { + return; + } + try { + await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId }); + deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId }); + } catch (error) { + deps.reportError(error); + } +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 0a0be0a9e..a30e9d5e9 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -1,5 +1,6 @@ export type { SubagentRow } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; +export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy"; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index 130689676..c768e82ed 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState, type ReactElement } from "react"; import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { useTranslation } from "react-i18next"; -import { Archive, ChevronDown, ChevronRight } from "lucide-react-native"; +import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { getProviderIcon } from "@/components/provider-icons"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -18,6 +18,7 @@ import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-pre const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); +const ThemedUnlink = withUnistyles(Unlink); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -28,6 +29,7 @@ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void; + onDetachSubagent?: (id: string) => void; } const SUBAGENTS_LIST_MAX_HEIGHT = 200; @@ -43,6 +45,7 @@ export function SubagentsTrack({ rows, onOpenSubagent, onArchiveSubagent, + onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { const [expanded, setExpanded] = useState(false); @@ -103,6 +106,7 @@ export function SubagentsTrack({ row={row} onOpenSubagent={onOpenSubagent} onArchiveSubagent={onArchiveSubagent} + onDetachSubagent={onDetachSubagent} /> ))} @@ -117,12 +121,14 @@ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void; + onDetachSubagent?: (id: string) => void; } function SubagentsTrackRow({ row, onOpenSubagent, onArchiveSubagent, + onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); @@ -136,10 +142,13 @@ function SubagentsTrackRow({ const handleArchivePress = useCallback(() => { onArchiveSubagent(row.id); }, [onArchiveSubagent, row.id]); + const handleDetachPress = useCallback(() => { + onDetachSubagent?.(row.id); + }, [onDetachSubagent, row.id]); const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerLeave = useCallback(() => setHovered(false), []); - const archiveAlwaysVisible = isNative || isCompact; - const archiveVisible = archiveAlwaysVisible || hovered; + const actionsAlwaysVisible = isNative || isCompact; + const actionsVisible = actionsAlwaysVisible || hovered; return ( // Wrapper View handles hover so moving the pointer between the row and @@ -158,11 +167,12 @@ function SubagentsTrackRow({ {displayLabel} - )} @@ -171,49 +181,93 @@ function SubagentsTrackRow({ ); } -function SubagentArchiveButton({ +function SubagentRowActions({ rowId, displayLabel, visible, - onPress, + onDetachPress, + onArchivePress, }: { rowId: string; displayLabel: string; visible: boolean; - onPress: () => void; + onDetachPress?: () => void; + onArchivePress: () => void; }): ReactElement { const { t } = useTranslation(); return ( - - - - {({ hovered, pressed }) => ( - - )} - - - - {t("subagents.archiveTooltip")} - - + {onDetachPress ? ( + + ) : null} + ); } +type SubagentActionIcon = "archive" | "detach"; + +function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement { + const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping; + if (icon === "detach") { + return ; + } + return ; +} + +function SubagentActionButton({ + accessibilityLabel, + testID, + tooltipLabel, + icon, + visible, + onPress, +}: { + accessibilityLabel: string; + testID: string; + tooltipLabel: string; + icon: SubagentActionIcon; + visible: boolean; + onPress: () => void; +}): ReactElement { + return ( + + + + {({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)} + + + + {tooltipLabel} + + + ); +} + const styles = StyleSheet.create((theme) => ({ outer: { width: "100%", @@ -288,13 +342,19 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, - archiveSlotVisible: { + actionClusterVisible: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], opacity: 1, }, - archiveSlotHidden: { + actionClusterHidden: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], opacity: 0, }, - archiveButton: { + actionButton: { padding: theme.spacing[1], alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts index 6f344d3f8..b00f9bb87 100644 --- a/packages/app/src/subagents/use-archive-subagent.ts +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -1,7 +1,9 @@ import { useCallback } from "react"; +import { useToast } from "@/contexts/toast-context"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useSessionStore } from "@/stores/session-store"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { toErrorMessage } from "@/utils/error-messages"; import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent"; @@ -18,6 +20,7 @@ export interface UseArchiveSubagentInput { export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { const { archiveAgent } = useArchiveAgent(); const { serverId } = input; + const toast = useToast(); return useCallback( (subagentId: string) => { @@ -28,9 +31,12 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: useSessionStore.getState().sessions[serverId]?.agents?.get(id), confirm: confirmDialog, archiveAgent, + reportError: (error) => { + toast.error(toErrorMessage(error)); + }, }, ); }, - [archiveAgent, serverId], + [archiveAgent, serverId, toast], ); } diff --git a/packages/app/src/subagents/use-detach-subagent.ts b/packages/app/src/subagents/use-detach-subagent.ts new file mode 100644 index 000000000..36b2f2a56 --- /dev/null +++ b/packages/app/src/subagents/use-detach-subagent.ts @@ -0,0 +1,51 @@ +import { useCallback } from "react"; +import { useToast } from "@/contexts/toast-context"; +import { i18n } from "@/i18n/i18next"; +import { useSessionStore } from "@/stores/session-store"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { toErrorMessage } from "@/utils/error-messages"; +import { navigateToAgent } from "@/utils/navigate-to-agent"; +import { requestDetachSubagent, type ResolveDetachSubagentDialogInput } from "./detach-subagent"; + +export { resolveDetachSubagentDialog, requestDetachSubagent } from "./detach-subagent"; +export type { + DetachSubagentDeps, + RequestDetachSubagentInput, + ResolveDetachSubagentDialogInput, +} from "./detach-subagent"; + +export interface UseDetachSubagentInput { + serverId: string; +} + +export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void { + const { serverId } = input; + const toast = useToast(); + + return useCallback( + (subagentId: string) => { + void requestDetachSubagent( + { serverId, subagentId }, + { + getSubagent: (id): ResolveDetachSubagentDialogInput | undefined => + useSessionStore.getState().sessions[serverId]?.agents?.get(id), + confirm: confirmDialog, + detachAgent: async ({ serverId: targetServerId, agentId }) => { + const client = useSessionStore.getState().sessions[targetServerId]?.client; + if (!client) { + throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected")); + } + await client.detachAgent(agentId); + }, + openDetachedAgent: ({ serverId: targetServerId, agentId }) => { + navigateToAgent({ serverId: targetServerId, agentId }); + }, + reportError: (error) => { + toast.error(toErrorMessage(error)); + }, + }, + ); + }, + [serverId, toast], + ); +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 36dfdf3d0..f4eed72dd 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -2762,6 +2762,48 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => { }); }); +test("detaches an agent through the namespaced detach RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.detachAgent("child-agent"); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toMatchObject({ + type: "agent.detach.request", + agentId: "child-agent", + }); + expect(typeof request.requestId).toBe("string"); + + mock.triggerMessage( + wrapSessionMessage({ + type: "agent.detach.response", + payload: { + requestId: request.requestId, + agentId: "child-agent", + accepted: true, + error: null, + }, + }), + ); + + await expect(promise).resolves.toBeUndefined(); +}); + test("sends active-scoped fetch_agents_request", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 776644d1e..a1212c3b5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2098,6 +2098,19 @@ export class DaemonClient { return { archivedAt: result.archivedAt }; } + async detachAgent(agentId: string): Promise { + const payload = await this.sendNamespacedCorrelatedSessionRequest<"agent.detach.response">({ + message: { + type: "agent.detach.request", + agentId, + }, + timeout: 10000, + }); + if (!payload.accepted) { + throw new Error(payload.error ?? "detachAgent rejected"); + } + } + async updateAgent( agentId: string, updates: { name?: string; labels?: Record }, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 0f20d9270..5faa0bf9d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -229,6 +229,7 @@ export interface PaseoAgentHandle { refetch(requestId?: string): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; archive(): Promise<{ archivedAt: string }>; + detach(): Promise; subscribe(handler: (update: PaseoAgentUpdate) => void): () => void; } @@ -483,6 +484,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor } return result; }, + detach: async () => { + await daemonClient.detachAgent(id); + }, subscribe: (handler) => daemonClient.on("agent_update", (message) => { const update = message.payload; diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index cb9c564e3..038155f6e 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest"; import { FileExplorerRequestSchema, PaseoWorktreeArchiveRequestSchema, + parseServerInfoStatusPayload, + SessionInboundMessageSchema, SessionOutboundMessageSchema, } from "./messages.js"; @@ -131,6 +133,51 @@ describe("workspace descriptor message compatibility", () => { }); }); +describe("agent detach RPC", () => { + test("parses the namespaced detach request", () => { + const parsed = SessionInboundMessageSchema.parse({ + type: "agent.detach.request", + agentId: "child-agent", + requestId: "req-detach", + }); + + expect(parsed).toEqual({ + type: "agent.detach.request", + agentId: "child-agent", + requestId: "req-detach", + }); + }); + + test("parses the namespaced detach response", () => { + const parsed = SessionOutboundMessageSchema.parse({ + type: "agent.detach.response", + payload: { + requestId: "req-detach", + agentId: "child-agent", + accepted: true, + error: null, + }, + }); + + expect(parsed.type).toBe("agent.detach.response"); + }); + + test("parses the agentDetach server feature gate", () => { + const parsed = parseServerInfoStatusPayload({ + status: "server_info", + serverId: "srv-test", + features: { + agentDetach: true, + }, + }); + + if (!parsed) { + throw new Error("Expected server info payload to parse"); + } + expect(parsed.features?.agentDetach).toBe(true); + }); +}); + describe("file explorer request compatibility", () => { test("acceptBinary is optional for old clients and accepted for new clients", () => { expect( diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 34db46266..284d32278 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1308,6 +1308,17 @@ export const SetAgentFeatureResponseMessageSchema = z.object({ payload: AgentActionResponsePayloadSchema, }); +export const AgentDetachRequestMessageSchema = z.object({ + type: z.literal("agent.detach.request"), + agentId: z.string(), + requestId: z.string(), +}); + +export const AgentDetachResponseMessageSchema = z.object({ + type: z.literal("agent.detach.response"), + payload: AgentActionResponsePayloadSchema, +}); + export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]); export const AgentRewindRequestMessageSchema = z.object({ @@ -2021,6 +2032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelRequestMessageSchema, SetAgentThinkingRequestMessageSchema, SetAgentFeatureRequestMessageSchema, + AgentDetachRequestMessageSchema, AgentRewindRequestMessageSchema, AgentPermissionResponseMessageSchema, CheckoutStatusRequestSchema, @@ -2280,6 +2292,8 @@ export const ServerInfoStatusPayloadSchema = z projectRemove: z.boolean().optional(), // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: z.boolean().optional(), + // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. + agentDetach: z.boolean().optional(), }) .optional(), }) @@ -4019,6 +4033,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SetAgentModelResponseMessageSchema, SetAgentThinkingResponseMessageSchema, SetAgentFeatureResponseMessageSchema, + AgentDetachResponseMessageSchema, AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, ProjectRenameResponseSchema, @@ -4166,6 +4181,7 @@ export type SetAgentModeResponseMessage = z.infer; export type SetAgentThinkingResponseMessage = z.infer; export type SetAgentFeatureResponseMessage = z.infer; +export type AgentDetachResponseMessage = z.infer; export type AgentRewindResponseMessage = z.infer; export type UpdateAgentResponseMessage = z.infer; export type ProjectRenameResponse = z.infer; @@ -4299,6 +4315,7 @@ export type SetAgentModeRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; export type SetAgentFeatureRequestMessage = z.infer; +export type AgentDetachRequestMessage = z.infer; export type AgentPermissionResponseMessage = z.infer; export type CheckoutStatusRequest = z.infer; export type CheckoutStatusResponse = z.infer; diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 9de65fb7a..d073a8dd5 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1847,18 +1847,23 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => { }); await manager.closeAgent(snapshot.id); - const before = await storage.get(snapshot.id); - expect(before).not.toBeNull(); + const closed = await storage.get(snapshot.id); + expect(closed).not.toBeNull(); + const before = { ...closed!, labels: { surface: "mobile" } }; + await storage.upsert(before); expect(manager.getAgent(snapshot.id)).toBeNull(); + const upsertSpy = vi.spyOn(storage, "upsert"); + await manager.updateAgentMetadata(snapshot.id, { title: "Stored title", labels: { role: "worker" }, }); + expect(upsertSpy).toHaveBeenCalledTimes(1); const after = await storage.get(snapshot.id); expect(after?.title).toBe("Stored title"); - expect(after?.labels).toEqual({ role: "worker" }); + expect(after?.labels).toEqual({ surface: "mobile", role: "worker" }); expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); }); @@ -2002,6 +2007,134 @@ test("setLabels merges and persists labels", async () => { }); }); +test("detachAgent removes only the parent label from a live agent and emits state", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Child", + }, + undefined, + { + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + team: "infra", + }, + }, + ); + const emittedLabels: Array> = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === child.id) { + emittedLabels.push(event.agent.labels); + } + }, + { agentId: child.id, replayState: false }, + ); + + const result = await manager.detachAgent(child.id); + await manager.flush(); + unsubscribe(); + + expect(result.previousParentAgentId).toBe(parent.id); + expect(result.live).toBe(true); + expect(result.record.labels).toEqual({ team: "infra" }); + expect(manager.getAgent(child.id)?.labels).toEqual({ team: "infra" }); + expect((await storage.get(child.id))?.labels).toEqual({ team: "infra" }); + expect(emittedLabels).toContainEqual({ team: "infra" }); +}); + +test("detachAgent removes the parent label from a stored-only agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Stored child", + }, + undefined, + { + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + role: "reviewer", + }, + }, + ); + await manager.closeAgent(child.id); + + const result = await manager.detachAgent(child.id); + + expect(result.previousParentAgentId).toBe(parent.id); + expect(result.live).toBe(false); + expect(result.record.labels).toEqual({ role: "reviewer" }); + expect((await storage.get(child.id))?.labels).toEqual({ role: "reviewer" }); +}); + +test("archiveAgent does not cascade to a detached former child", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-cascade-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + + await manager.detachAgent(child.id); + await manager.archiveAgent(parent.id); + + expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String)); + expect((await storage.get(child.id))?.archivedAt).toBeFalsy(); +}); + test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d4b2e0348..6b2e2468c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5,7 +5,11 @@ import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus, } from "@getpaseo/protocol/agent-lifecycle"; -import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { + getParentAgentIdFromLabels, + isDelegatedAgent, + PARENT_AGENT_ID_LABEL, +} from "@getpaseo/protocol/agent-labels"; import type { Logger } from "pino"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -345,6 +349,17 @@ type ActiveManagedAgent = | ManagedAgentError; type LiveManagedAgent = ActiveManagedAgent; +type AgentLabelPatch = Record; + +interface WriteLabelsResult { + record: StoredAgentRecord | null; + live: boolean; +} + +interface AgentMetadataPatch { + title?: string; + labels?: AgentLabelPatch; +} const SYSTEM_ERROR_PREFIX = "[System Error]"; @@ -403,6 +418,21 @@ function validateAgentId(agentId: string, source: string): string { return result.data; } +function applyLabelPatch( + labels: Record, + patch: AgentLabelPatch, +): Record { + const nextLabels = { ...labels }; + for (const [key, value] of Object.entries(patch)) { + if (value === null) { + delete nextLabels[key]; + } else { + nextLabels[key] = value; + } + } + return nextLabels; +} + function buildExplicitTimelineSeedForRegister( now: Date, options: @@ -1151,9 +1181,8 @@ export class AgentManager { // Children created via the MCP `create_agent` tool carry the parent-agent-id // label pointing back at the caller. Archiving the parent cascades to those - // children so subagent fleets don't outlive their orchestrator. Handoff agents - // launched the same way are caught by this cascade — see docs/agent-lifecycle.md - // for the accepted limitation. + // children so subagent fleets don't outlive their orchestrator. Detached + // handoff agents omit this label, so they stand outside the cascade. private async cascadeArchiveChildren(parentAgentId: string): Promise { const registry = this.registry; if (!registry) { @@ -1333,10 +1362,83 @@ export class AgentManager { async setLabels(agentId: string, labels: Record): Promise { const agent = this.requireAgent(agentId); - agent.labels = { ...agent.labels, ...labels }; - this.touchUpdatedAt(agent); - await this.persistSnapshot(agent); - this.emitState(agent, { persist: false }); + await this.writeLabels(agent.id, labels); + } + + private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise { + const liveAgent = this.agents.get(agentId); + if (liveAgent) { + liveAgent.labels = applyLabelPatch(liveAgent.labels, patch); + this.touchUpdatedAt(liveAgent); + await this.persistSnapshot(liveAgent); + this.emitState(liveAgent, { persist: false }); + const record = this.registry ? await this.registry.get(agentId) : null; + return { record, live: true }; + } + + const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch }); + return { record: nextRecord, live: false }; + } + + private async writeStoredMetadata( + agentId: string, + patch: AgentMetadataPatch, + ): Promise { + const registry = this.requireRegistry(); + const record = await registry.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + + const nextRecord = { + ...record, + ...(patch.title ? { title: patch.title } : {}), + ...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}), + updatedAt: this.nextStoredUpdatedAt(record), + }; + await registry.upsert(nextRecord); + return nextRecord; + } + + async detachAgent(agentId: string): Promise<{ + record: StoredAgentRecord; + live: boolean; + previousParentAgentId: string | null; + }> { + const registry = this.requireRegistry(); + const liveAgent = this.agents.get(agentId); + if (liveAgent) { + const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels); + if (!previousParentAgentId) { + await this.persistSnapshot(liveAgent); + const record = await registry.get(agentId); + if (!record) { + throw new Error(`Agent not found in storage after detach: ${agentId}`); + } + return { record, live: true, previousParentAgentId: null }; + } + + const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null }); + if (!record) { + throw new Error(`Agent not found in storage after detach: ${agentId}`); + } + return { record, live: true, previousParentAgentId }; + } + + const record = await registry.get(agentId); + if (!record) { + throw new Error(`Agent not found: ${agentId}`); + } + const previousParentAgentId = getParentAgentIdFromLabels(record.labels); + if (!previousParentAgentId) { + return { record, live: false, previousParentAgentId: null }; + } + + const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null }); + if (!result.record) { + throw new Error(`Agent not found in storage after detach: ${agentId}`); + } + return { record: result.record, live: false, previousParentAgentId }; } notifyAgentState(agentId: string): void { @@ -1433,23 +1535,12 @@ export class AgentManager { await this.setTitle(agentId, updates.title); } if (updates.labels) { - await this.setLabels(agentId, updates.labels); + await this.writeLabels(agentId, updates.labels); } return; } - const registry = this.requireRegistry(); - const existing = await registry.get(agentId); - if (!existing) { - throw new Error(`Agent not found: ${agentId}`); - } - - await registry.upsert({ - ...existing, - ...(updates.title ? { title: updates.title } : {}), - ...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}), - updatedAt: this.nextStoredUpdatedAt(existing), - }); + await this.writeStoredMetadata(agentId, updates); } async runAgent( diff --git a/packages/server/src/server/agent/lifecycle-command.test.ts b/packages/server/src/server/agent/lifecycle-command.test.ts index fe7bba93d..d00f944cc 100644 --- a/packages/server/src/server/agent/lifecycle-command.test.ts +++ b/packages/server/src/server/agent/lifecycle-command.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "vitest"; +import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { createTestLogger } from "../../test-utils/test-logger.js"; import type { StoredAgentRecord } from "./agent-storage.js"; import { archiveAgentCommand, cancelAgentRunCommand, + detachAgentCommand, setAgentModeCommand, updateAgentCommand, type LifecycleAgentSnapshot, @@ -39,6 +41,7 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { readonly labelUpdates: Array<{ agentId: string; labels: Record }> = []; readonly notifiedAgentIds: string[] = []; readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; + readonly detachedAgentIds: string[] = []; inFlightAgentIds = new Set(); constructor(private readonly storage: FakeLifecycleAgentStorage) {} @@ -94,6 +97,39 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager { this.labelUpdates.push({ agentId, labels }); } + async detachAgent(agentId: string): Promise<{ + record: StoredAgentRecord; + live: boolean; + previousParentAgentId: string | null; + }> { + this.detachedAgentIds.push(agentId); + const existing = this.storage.records.get(agentId); + if (!existing) { + throw new Error(`Agent not found: ${agentId}`); + } + const previousParentAgentId = getParentAgentIdFromLabels(existing.labels); + if (!previousParentAgentId) { + return { + record: existing, + live: this.liveAgents.has(agentId), + previousParentAgentId: null, + }; + } + const labels = { ...existing.labels }; + delete labels[PARENT_AGENT_ID_LABEL]; + const record = { + ...existing, + labels, + updatedAt: "2026-05-10T10:30:00.000Z", + }; + this.storage.records.set(agentId, record); + return { + record, + live: this.liveAgents.has(agentId), + previousParentAgentId, + }; + } + notifyAgentState(agentId: string): void { this.notifiedAgentIds.push(agentId); } @@ -206,6 +242,44 @@ describe("agent lifecycle commands", () => { ]); }); + test("detaches an agent by clearing only the parent relationship", async () => { + const storage = new FakeLifecycleAgentStorage(); + storage.records.set("agent-1", { + ...storedAgent("agent-1"), + labels: { + [PARENT_AGENT_ID_LABEL]: "parent-agent", + team: "infra", + }, + }); + const manager = new FakeLifecycleAgentManager(storage); + + await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({ + agentId: "agent-1", + live: false, + previousParentAgentId: "parent-agent", + record: { + ...storedAgent("agent-1"), + labels: { team: "infra" }, + updatedAt: "2026-05-10T10:30:00.000Z", + }, + }); + + expect(manager.detachedAgentIds).toEqual(["agent-1"]); + }); + + test("detach is accepted when the agent is already detached", async () => { + const storage = new FakeLifecycleAgentStorage(); + storage.records.set("agent-1", storedAgent("agent-1")); + const manager = new FakeLifecycleAgentManager(storage); + + await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({ + agentId: "agent-1", + live: false, + previousParentAgentId: null, + record: storedAgent("agent-1"), + }); + }); + test("sets an agent mode and returns the accepted mode", async () => { const storage = new FakeLifecycleAgentStorage(); const manager = new FakeLifecycleAgentManager(storage); diff --git a/packages/server/src/server/agent/lifecycle-command.ts b/packages/server/src/server/agent/lifecycle-command.ts index 77f618181..5e91329c6 100644 --- a/packages/server/src/server/agent/lifecycle-command.ts +++ b/packages/server/src/server/agent/lifecycle-command.ts @@ -14,6 +14,11 @@ export interface LifecycleAgentManager { archiveSnapshot(agentId: string, archivedAt: string): Promise; closeAgent(agentId: string): Promise; setLabels(agentId: string, labels: Record): Promise; + detachAgent(agentId: string): Promise<{ + record: StoredAgentRecord; + live: boolean; + previousParentAgentId: string | null; + }>; notifyAgentState(agentId: string): void; setAgentMode(agentId: string, modeId: string): Promise; updateAgentMetadata( @@ -161,6 +166,24 @@ export async function updateAgentCommand( }; } +export interface DetachAgentResult { + agentId: string; + record: StoredAgentRecord; + live: boolean; + previousParentAgentId: string | null; +} + +export async function detachAgentCommand( + dependencies: Pick, + agentId: string, +): Promise { + const result = await dependencies.agentManager.detachAgent(agentId); + return { + agentId, + ...result, + }; +} + export async function setAgentModeCommand( dependencies: Pick, input: { diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 1652092e2..490a7b474 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -6,6 +6,7 @@ import { join, resolve as resolvePath } from "path"; import pino from "pino"; import { afterEach, describe, expect, test, vi } from "vitest"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import { decodeFileTransferFrame, @@ -13,6 +14,7 @@ import { } from "@getpaseo/protocol/binary-frames/index"; import { Session } from "./session.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; +import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { SessionOptions } from "./session.js"; import type { @@ -199,6 +201,8 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => { }); interface SessionForTestOptions { + agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown }; + agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown }; github?: Partial; checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType }; workspaceGitService?: { @@ -264,9 +268,11 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { agentManager: asAgentManager({ listAgents: vi.fn(() => []), subscribe: vi.fn(() => () => {}), + ...options.agentManager, }), agentStorage: asAgentStorage({ list: vi.fn().mockResolvedValue([]), + ...options.agentStorage, }), projectRegistry: options.projectRegistry ?? { list: vi.fn().mockResolvedValue([]), @@ -596,6 +602,143 @@ describe("file explorer binary responses", () => { }); }); +function createStoredAgentRecord( + overrides: Pick & Partial, +): StoredAgentRecord { + return { + id: overrides.id, + provider: overrides.provider ?? "codex", + cwd: overrides.cwd, + workspaceId: overrides.workspaceId, + createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z", + updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z", + lastUserMessageAt: overrides.lastUserMessageAt ?? null, + title: overrides.title ?? null, + labels: overrides.labels ?? {}, + lastStatus: overrides.lastStatus ?? "idle", + lastModeId: overrides.lastModeId ?? null, + config: overrides.config ?? null, + runtimeInfo: overrides.runtimeInfo, + features: overrides.features, + persistence: overrides.persistence ?? null, + lastError: overrides.lastError, + requiresAttention: overrides.requiresAttention, + attentionReason: overrides.attentionReason, + attentionTimestamp: overrides.attentionTimestamp, + internal: overrides.internal, + archivedAt: overrides.archivedAt ?? null, + }; +} + +describe("agent detach RPC", () => { + test("detaches a stored subagent and emits the updated standalone agent", async () => { + const messages: unknown[] = []; + const childBefore = createStoredAgentRecord({ + id: "child-agent", + cwd: "/tmp/child", + workspaceId: "workspace-child", + title: "Child", + labels: { + [PARENT_AGENT_ID_LABEL]: "parent-agent", + topic: "handoff", + }, + }); + const childAfter = createStoredAgentRecord({ + ...childBefore, + updatedAt: "2026-01-01T00:00:01.000Z", + labels: { topic: "handoff" }, + }); + const workspace = { + workspaceId: "workspace-child", + projectId: "project-child", + cwd: "/tmp/child", + kind: "worktree" as const, + displayName: "Child workspace", + title: null, + branch: "child", + baseBranch: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + }; + const project = { + projectId: "project-child", + rootPath: "/tmp/child", + kind: "git" as const, + displayName: "Project", + customName: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + }; + const detachAgent = vi.fn().mockResolvedValue({ + record: childAfter, + live: false, + previousParentAgentId: "parent-agent", + }); + const getAgent = vi.fn(() => null); + + const session = createSessionForTest({ + messages, + agentManager: { + getAgent, + detachAgent, + }, + agentStorage: { + list: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + }, + workspaceRegistry: { + get: vi.fn().mockResolvedValue(workspace), + list: vi.fn().mockResolvedValue([workspace]), + }, + projectRegistry: { + get: vi.fn().mockResolvedValue(project), + list: vi.fn().mockResolvedValue([project]), + }, + }); + + await session.handleMessage({ + type: "fetch_agents_request", + requestId: "subscribe-agents", + subscribe: { subscriptionId: "agents-sub" }, + }); + messages.splice(0); + + await session.handleMessage({ + type: "agent.detach.request", + agentId: childBefore.id, + requestId: "detach-1", + }); + + expect(detachAgent).toHaveBeenCalledWith("child-agent"); + expect(messages).toContainEqual({ + type: "agent_update", + payload: { + kind: "upsert", + agent: expect.objectContaining({ + id: "child-agent", + labels: { topic: "handoff" }, + workspaceId: "workspace-child", + }), + project: expect.objectContaining({ + projectKey: "project-child", + workspaceName: "Child workspace", + }), + }, + }); + expect(messages).toContainEqual({ + type: "agent.detach.response", + payload: { + requestId: "detach-1", + agentId: "child-agent", + accepted: true, + error: null, + }, + }); + }); +}); + function createProjectRecord(rootPath: string, archivedAt: string | null = null) { return { projectId: `project:${rootPath}`, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index f49131411..a67ea5563 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -92,6 +92,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto import type { DaemonConfigStore } from "./daemon-config-store.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; +import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService, @@ -112,6 +113,7 @@ import { archiveAgentCommand, cancelAgentRunCommand, closeAgentCommand, + detachAgentCommand, setAgentModeCommand, updateAgentCommand, } from "./agent/lifecycle-command.js"; @@ -1621,6 +1623,45 @@ export class Session { }); } + private async emitStoredAgentUpdate(record: StoredAgentRecord): Promise { + const payload = this.buildStoredAgentPayload(record); + const subscription = this.agentUpdatesSubscription; + if (!subscription) { + return payload; + } + + const project = payload.workspaceId + ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId) + : null; + if (!project) { + this.bufferOrEmitAgentUpdate(subscription, { + kind: "remove", + agentId: payload.id, + }); + return payload; + } + + const matches = this.matchesAgentFilter({ + agent: payload, + project, + filter: subscription.filter, + }); + this.bufferOrEmitAgentUpdate( + subscription, + matches + ? { + kind: "upsert", + agent: payload, + project, + } + : { + kind: "remove", + agentId: payload.id, + }, + ); + return payload; + } + private flushBootstrappedAgentUpdates(options?: { snapshotUpdatedAtByAgentId?: Map; }): void { @@ -1816,6 +1857,7 @@ export class Session { const promise = this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ?? + this.dispatchAgentRelationshipMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ?? this.dispatchCheckoutMessage(msg) ?? @@ -1886,6 +1928,15 @@ export class Session { } } + private dispatchAgentRelationshipMessage(msg: SessionInboundMessage): Promise | undefined { + switch (msg.type) { + case "agent.detach.request": + return this.handleDetachAgentRequest(msg.agentId, msg.requestId); + default: + return undefined; + } + } + private async handleDictationStreamStart( msg: Extract, ): Promise { @@ -2454,35 +2505,7 @@ export class Session { ); if (this.agentUpdatesSubscription) { - const payload = this.buildStoredAgentPayload(archivedRecord); - const project = payload.workspaceId - ? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId) - : null; - if (project) { - const matches = this.matchesAgentFilter({ - agent: payload, - project, - filter: this.agentUpdatesSubscription.filter, - }); - this.bufferOrEmitAgentUpdate( - this.agentUpdatesSubscription, - matches - ? { - kind: "upsert", - agent: payload, - project, - } - : { - kind: "remove", - agentId, - }, - ); - } else { - this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, { - kind: "remove", - agentId, - }); - } + const payload = await this.emitStoredAgentUpdate(archivedRecord); if (payload.workspaceId) { await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); } @@ -2491,6 +2514,59 @@ export class Session { return { agentId, archivedAt }; } + private async handleDetachAgentRequest(agentId: string, requestId: string): Promise { + this.sessionLogger.info({ agentId, requestId }, "Detaching agent from parent"); + + try { + const result = await detachAgentCommand({ agentManager: this.agentManager }, agentId); + const affectedWorkspaceIds = new Set(); + + if (!result.live) { + const payload = await this.emitStoredAgentUpdate(result.record); + if (payload.workspaceId) { + affectedWorkspaceIds.add(payload.workspaceId); + } + } else if (result.record.workspaceId) { + affectedWorkspaceIds.add(result.record.workspaceId); + } + + if (result.previousParentAgentId) { + const rootWorkspaceId = await this.resolveDelegationRootWorkspaceId( + result.previousParentAgentId, + ); + if (rootWorkspaceId) { + affectedWorkspaceIds.add(rootWorkspaceId); + } + } + + await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, { + skipReconcile: true, + }); + + this.emit({ + type: "agent.detach.response", + payload: { + requestId, + agentId, + accepted: true, + error: null, + }, + }); + } catch (error) { + const message = getErrorMessageOr(error, "Failed to detach agent"); + this.sessionLogger.error({ err: error, agentId, requestId }, "Failed to detach agent"); + this.emit({ + type: "agent.detach.response", + payload: { + requestId, + agentId, + accepted: false, + error: message, + }, + }); + } + } + private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise { const archiveResults = await Promise.allSettled( msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)), @@ -6554,6 +6630,33 @@ export class Session { return this.isProviderVisibleToClient(payload.provider) ? payload : null; } + private async resolveDelegationRootWorkspaceId(agentId: string): Promise { + const seen = new Set(); + let currentAgentId = agentId; + + while (true) { + if (seen.has(currentAgentId)) { + return null; + } + seen.add(currentAgentId); + + const live = this.agentManager.getAgent(currentAgentId); + const source = live ?? (await this.agentStorage.get(currentAgentId)); + if (!source) { + return null; + } + if ("archivedAt" in source && source.archivedAt) { + return null; + } + + const parentAgentId = getParentAgentIdFromLabels(source.labels); + if (!parentAgentId) { + return source.workspaceId ?? null; + } + currentAgentId = parentAgentId; + } + } + private async buildActiveProjectPlacementsByWorkspaceId(): Promise< Map > { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 48711dc65..d0423fbd5 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1161,6 +1161,8 @@ export class VoiceAssistantWebSocketServer { projectRemove: true, // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 worktreeRestore: true, + // COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98. + agentDetach: true, }, }; } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 065264654..af7734d59 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -139,6 +139,16 @@ class WorkspaceStatus { ); } + hasDetachedAgentInWorktree(input: AgentState): void { + this.agents.push( + createAgent({ + ...input, + cwd: this.worktreeWorkspace.cwd, + workspaceId: this.worktreeWorkspace.workspaceId, + }), + ); + } + async workspaceStatus(): Promise { const entries = await this.directory.listFetchEntries({ type: "fetch_workspaces_request", @@ -378,6 +388,19 @@ describe("WorkspaceDirectory", () => { }); }); + test("running detached child contributes running to its own workspace", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasWorktreeWorkspace(); + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasDetachedAgentInWorktree({ id: "child-agent", status: "running" }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-worktree": "running", + }); + }); + test("working terminal contributes running status, beating done", async () => { const workspace = new WorkspaceStatus(); const changedAt = new Date(NOW).getTime();