Detach subagents without archiving them (#1612)

* Add detach action for subagents

* Translate detached subagent connection error

* Address subagent detach review feedback

* Fix subagent integration test import

* Focus detached subagents as tabs
This commit is contained in:
Mohamed Boudra
2026-06-19 17:19:05 +08:00
committed by GitHub
parent cda66ae5f3
commit 7af92120fe
33 changed files with 1359 additions and 94 deletions
+5
View File
@@ -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.
+1
View File
@@ -82,6 +82,7 @@ export interface SeedDaemonClient {
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
initialPrompt?: string;
labels?: Record<string, string>;
}): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{
+83
View File
@@ -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<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentTitle: string; childTitle: string },
): Promise<SeededSubagentPair> {
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<void> {
await page.getByTestId("subagents-track-header").click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
});
}
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
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();
}
+44
View File
@@ -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);
});
});
+2
View File
@@ -1307,6 +1307,8 @@ export const ar: TranslationResources = {
backdrop: "خلفية القائمة",
},
subagents: {
detachAction: "فصل {{label}}",
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي",
},
+2
View File
@@ -1314,6 +1314,8 @@ export const en = {
backdrop: "Menu backdrop",
},
subagents: {
detachAction: "Detach {{label}}",
detachTooltip: "Detach subagent",
archiveAction: "Archive {{label}}",
archiveTooltip: "Archive subagent",
},
+2
View File
@@ -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",
},
+2
View File
@@ -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",
},
+2
View File
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
backdrop: "Фон меню",
},
subagents: {
detachAction: "Отсоединить {{label}}",
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент",
},
+2
View File
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
backdrop: "菜单背景",
},
subagents: {
detachAction: "分离 {{label}}",
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}",
archiveTooltip: "归档 subagent",
},
+6 -1
View File
@@ -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}
/>
<Composer
agentId={agentId}
@@ -5,7 +5,7 @@ import {
deriveWorkspaceAgentVisibility,
type WorkspaceAgentVisibility,
} from "@/workspace-tabs/agent-visibility";
import { selectSubagentsForParent } from "@/subagents";
import { selectSubagentsForParent } from "@/subagents/select";
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store";
@@ -167,4 +167,52 @@ describe("workspace subagents integration", () => {
).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([]);
});
});
@@ -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]);
});
});
@@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps {
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
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);
}
}
@@ -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<string, ResolveDetachSubagentDialogInput | undefined>();
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([]);
});
});
@@ -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<boolean>;
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
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<void> {
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);
}
}
+1
View File
@@ -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";
+95 -35
View File
@@ -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}
/>
))}
</ScrollView>
@@ -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({
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
<SubagentArchiveButton
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={archiveVisible}
onPress={handleArchivePress}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
</View>
)}
@@ -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 (
<View
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden}
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
pointerEvents={visible ? "auto" : "none"}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
onPress={onPress}
style={styles.archiveButton}
hitSlop={8}
>
{({ hovered, pressed }) => (
<ThemedArchive
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("subagents.archiveTooltip")}</Text>
</TooltipContent>
</Tooltip>
{onDetachPress ? (
<SubagentActionButton
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
testID={`subagents-track-detach-${rowId}`}
tooltipLabel={t("subagents.detachTooltip")}
icon="detach"
visible={visible}
onPress={onDetachPress}
/>
) : null}
<SubagentActionButton
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
testID={`subagents-track-archive-${rowId}`}
tooltipLabel={t("subagents.archiveTooltip")}
icon="archive"
visible={visible}
onPress={onArchivePress}
/>
</View>
);
}
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={14} uniProps={uniProps} />;
}
return <ThemedArchive size={14} uniProps={uniProps} />;
}
function SubagentActionButton({
accessibilityLabel,
testID,
tooltipLabel,
icon,
visible,
onPress,
}: {
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
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",
@@ -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],
);
}
@@ -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],
);
}
+42
View File
@@ -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();
+13
View File
@@ -2098,6 +2098,19 @@ export class DaemonClient {
return { archivedAt: result.archivedAt };
}
async detachAgent(agentId: string): Promise<void> {
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<string, string> },
+4
View File
@@ -229,6 +229,7 @@ export interface PaseoAgentHandle {
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
archive(): Promise<{ archivedAt: string }>;
detach(): Promise<void>;
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;
+47
View File
@@ -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(
+17
View File
@@ -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<typeof SetAgentModeResponseMes
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessageSchema>;
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
@@ -4299,6 +4315,7 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
export type AgentDetachRequestMessage = z.infer<typeof AgentDetachRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
@@ -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<Record<string, string>> = [];
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");
+112 -21
View File
@@ -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<string, string | null>;
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<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
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<void> {
const registry = this.registry;
if (!registry) {
@@ -1333,10 +1362,83 @@ export class AgentManager {
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
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<WriteLabelsResult> {
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<StoredAgentRecord> {
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(
@@ -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<string, string> }> = [];
readonly notifiedAgentIds: string[] = [];
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
readonly detachedAgentIds: string[] = [];
inFlightAgentIds = new Set<string>();
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);
@@ -14,6 +14,11 @@ export interface LifecycleAgentManager {
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
closeAgent(agentId: string): Promise<void>;
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}>;
notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>;
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<AgentLifecycleCommandDependencies, "agentManager">,
agentId: string,
): Promise<DetachAgentResult> {
const result = await dependencies.agentManager.detachAgent(agentId);
return {
agentId,
...result,
};
}
export async function setAgentModeCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
input: {
+143
View File
@@ -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<GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
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<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
): 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}`,
+132 -29
View File
@@ -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<AgentSnapshotPayload> {
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<string, number>;
}): 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<void> | undefined {
switch (msg.type) {
case "agent.detach.request":
return this.handleDetachAgentRequest(msg.agentId, msg.requestId);
default:
return undefined;
}
}
private async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> {
@@ -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<void> {
this.sessionLogger.info({ agentId, requestId }, "Detaching agent from parent");
try {
const result = await detachAgentCommand({ agentManager: this.agentManager }, agentId);
const affectedWorkspaceIds = new Set<string>();
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<void> {
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<string | null> {
const seen = new Set<string>();
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<string, ProjectPlacementPayload>
> {
@@ -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,
},
};
}
@@ -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<WorkspaceDescriptorPayload["status"]> {
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();