From f62359fae0f192b14e8318a516ef96ad5c2d83ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 22:07:58 +0200 Subject: [PATCH] feat(subagents): archive finished children and show native names Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them. --- docs/agent-lifecycle.md | 7 +- .../app/e2e/provider-subagents.real.spec.ts | 18 ++++- packages/app/src/i18n/resources/ar.ts | 2 + packages/app/src/i18n/resources/en.ts | 2 + packages/app/src/i18n/resources/es.ts | 2 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 2 + packages/app/src/i18n/resources/pt-BR.ts | 2 + packages/app/src/i18n/resources/ru.ts | 2 + packages/app/src/i18n/resources/zh-CN.ts | 2 + packages/app/src/panels/agent-panel.tsx | 15 +++- packages/app/src/subagents/index.ts | 4 + .../src/subagents/track-presentation.test.ts | 36 +++++++++ .../app/src/subagents/track-presentation.ts | 9 +++ packages/app/src/subagents/track.tsx | 77 +++++++++++++----- .../use-archive-finished-subagents.ts | 28 +++++++ packages/client/src/daemon-client.ts | 31 ++++++++ .../src/messages.provider-subagents.test.ts | 28 +++++++ packages/protocol/src/messages.ts | 21 +++++ .../server/src/server/agent/agent-loading.ts | 1 + .../src/server/agent/agent-manager.test.ts | 67 ++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 79 +++++++++++++++++-- .../src/server/agent/agent-projections.ts | 1 + .../server/src/server/agent/agent-storage.ts | 1 + .../server/agent/providers/claude/agent.ts | 11 ++- .../agent.voice-history-regression.test.ts | 16 +++- .../claude/sidechain-tracker.test.ts | 38 +++++++++ .../providers/claude/sidechain-tracker.ts | 12 ++- .../providers/codex/tool-call-mapper.test.ts | 6 +- .../agent/providers/codex/tool-call-mapper.ts | 3 +- .../server/src/server/persistence-hooks.ts | 2 + packages/server/src/server/session.ts | 30 +++++++ .../server/src/server/websocket-server.ts | 2 + 33 files changed, 518 insertions(+), 41 deletions(-) create mode 100644 packages/app/src/subagents/use-archive-finished-subagents.ts create mode 100644 packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 01e392ffaa..a1df43c292 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -89,7 +89,9 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. -Archived Paseo subagents disappear from the track, by design. To remove one 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. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. +Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls. + +The track header's **Archive finished** action handles both kinds in one daemon operation. Finished Paseo children are soft-deleted normally. Finished provider children are hidden from the track; their native sessions are untouched. The daemon persists those provider IDs on the parent as dismissal tombstones so replaying Claude or Codex history does not resurrect them. Running children of either kind stay visible. 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. @@ -109,7 +111,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway ### Subagent accumulation under long-lived parents -A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. +A parent that spawns many subagents will see the track grow until the user chooses **Archive finished**. Cleanup is explicit rather than automatic so completed child output remains available until the user is done with it. ### Cross-client tab dismissal @@ -131,5 +133,6 @@ Each agent is a single JSON file. Fields relevant to this doc: | `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | | `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` | | `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| `dismissedProviderSubagentIds` | `string[]` | Native child IDs hidden by the parent's **Archive finished** action | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts index 62c08694ec..007d7d1486 100644 --- a/packages/app/e2e/provider-subagents.real.spec.ts +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents"; interface ProviderSubagentCase { provider: RewindFlowProvider; sentinel: string; + expectedName: string; prompt: string; providerConfig?: Parameters[0]["providerConfig"]; } @@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [ { provider: "claude", sentinel: "CLAUDE_CHILD_SENTINEL", + expectedName: "sentinel_child", providerConfig: { model: "opus" }, prompt: - "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + 'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "codex", sentinel: "CODEX_CHILD_SENTINEL", + expectedName: "sentinel_child", providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, prompt: - 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + 'Use the native collaboration.spawn_agent tool exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE. Do not use Paseo tools.', }, { provider: "opencode", sentinel: "OPENCODE_CHILD_SENTINEL", + expectedName: "Explore", prompt: "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", }, @@ -66,6 +70,7 @@ test.describe("real provider subagent timelines", () => { const rows = page.locator('[data-testid^="subagents-track-row-"]'); await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await expect(rows.first()).toContainText(scenario.expectedName); await rows.first().click(); const panel = page.getByTestId("provider-subagent-panel"); @@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => { await expect( panel.getByText("Start chatting with this agent...", { exact: true }), ).toHaveCount(0); + + await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click(); + await expect( + page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(), + ).toBeVisible({ timeout: 60_000 }); + const archiveFinished = page.getByTestId("subagents-track-archive-finished"); + await expect(archiveFinished).toBeVisible({ timeout: 30_000 }); + await archiveFinished.click(); + await expect(rows).toHaveCount(0, { timeout: 30_000 }); } finally { await cleanupRewindFlow({ handle, cwd }); } diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 52920c2bcd..b5864d6823 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1370,6 +1370,8 @@ export const ar: TranslationResources = { detachTooltip: "فصل الوكيل الفرعي", archiveAction: "أرشيف{{label}}", archiveTooltip: "أرشفة الوكيل الفرعي", + archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين", + archiveFinishedTooltip: "أرشفة المكتملين", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index cfe4612cb4..213961f7c3 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1378,6 +1378,8 @@ export const en = { detachTooltip: "Detach subagent", archiveAction: "Archive {{label}}", archiveTooltip: "Archive subagent", + archiveFinishedAction: "Archive finished subagents", + archiveFinishedTooltip: "Archive finished", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1172b96cad..e8ecb927f3 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1409,6 +1409,8 @@ export const es: TranslationResources = { detachTooltip: "Separar subagente", archiveAction: "Archivo{{label}}", archiveTooltip: "Subagente de archivo", + archiveFinishedAction: "Archivar subagentes finalizados", + archiveFinishedTooltip: "Archivar finalizados", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9a8b32c4eb..81cf5fddd2 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1412,6 +1412,8 @@ export const fr: TranslationResources = { detachTooltip: "Detacher le sous-agent", archiveAction: "Archiver{{label}}", archiveTooltip: "Sous-agent d'archivage", + archiveFinishedAction: "Archiver les sous-agents terminés", + archiveFinishedTooltip: "Archiver les terminés", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 0359705834..849aa62bda 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1387,6 +1387,8 @@ export const ja: TranslationResources = { detachTooltip: "サブエージェントを切り離す", archiveAction: "{{label}}をアーカイブ", archiveTooltip: "サブエージェントをアーカイブ", + archiveFinishedAction: "完了したサブエージェントをアーカイブ", + archiveFinishedTooltip: "完了した項目をアーカイブ", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 8966fe0cc6..f4d6a4d5d0 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1395,6 +1395,8 @@ export const ptBR: TranslationResources = { detachTooltip: "Desanexar subagente", archiveAction: "Arquivar {{label}}", archiveTooltip: "Arquivar subagente", + archiveFinishedAction: "Arquivar subagentes concluídos", + archiveFinishedTooltip: "Arquivar concluídos", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index bd1ea0172d..9755b24252 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1401,6 +1401,8 @@ export const ru: TranslationResources = { detachTooltip: "Отсоединить субагент", archiveAction: "Архив{{label}}", archiveTooltip: "Архивный субагент", + archiveFinishedAction: "Архивировать завершенные субагенты", + archiveFinishedTooltip: "Архивировать завершенные", }, panels: { draft: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 067b10304b..fce6d1266e 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = { detachTooltip: "分离 subagent", archiveAction: "归档 {{label}}", archiveTooltip: "归档 subagent", + archiveFinishedAction: "归档已完成的 subagent", + archiveFinishedTooltip: "归档已完成项", }, panels: { draft: { diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 73bf635f9e..0cc98e57d2 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -68,7 +68,12 @@ 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, useDetachSubagent, useSubagentsForParent } from "@/subagents"; +import { + useArchiveFinishedSubagents, + useArchiveSubagent, + useDetachSubagent, + useSubagentsForParent, +} from "@/subagents"; import { SubagentsTrack } from "@/subagents/track"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; @@ -1376,6 +1381,9 @@ function ActiveAgentComposer({ const canDetachSubagents = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true, ); + const canArchiveFinishedSubagents = useSessionStore( + (state) => state.sessions[serverId]?.serverInfo?.features?.archiveFinishedSubagents === true, + ); const handleOpenSubagent = useCallback( (subagentId: string) => { navigateToAgent({ serverId, agentId: subagentId }); @@ -1390,6 +1398,10 @@ function ActiveAgentComposer({ ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); + const handleArchiveFinishedSubagents = useArchiveFinishedSubagents({ + serverId, + parentAgentId: agentId, + }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ serverId, cwd, @@ -1490,6 +1502,7 @@ function ActiveAgentComposer({ onOpenSubagent={handleOpenSubagent} onOpenProviderSubagent={handleOpenProviderSubagent} onArchiveSubagent={handleArchiveSubagent} + onArchiveFinished={canArchiveFinishedSubagents ? handleArchiveFinishedSubagents : undefined} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} /> { }); }); +describe("countFinishedSubagents", () => { + it("counts terminal managed and native children", () => { + const providerRows: SubagentRow[] = [ + { + kind: "provider", + id: "native-running", + parentAgentId: "parent", + provider: "claude", + title: "running", + status: "running", + requiresAttention: false, + createdAt: new Date("2026-04-20T00:00:00.000Z"), + }, + { + kind: "provider", + id: "native-failed", + parentAgentId: "parent", + provider: "claude", + title: "failed", + status: "failed", + requiresAttention: true, + createdAt: new Date("2026-04-20T00:00:01.000Z"), + }, + ]; + + expect( + countFinishedSubagents([ + row({ id: "managed-running", status: "running" }), + row({ id: "managed-idle", status: "idle" }), + ...providerRows, + ]), + ).toBe(2); + }); +}); + describe("resolveRowLabel", () => { it("returns null when title is not a string", () => { expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null); diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d7255cc77b..d97a5fc8ea 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -48,6 +48,15 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string { return parts.join(" · "); } +export function countFinishedSubagents(rows: readonly SubagentRow[]): number { + return rows.filter((row) => { + if (row.kind === "provider") { + return row.status !== "running"; + } + return row.status !== "initializing" && row.status !== "running"; + }).length; +} + export function resolveRowLabel(title: SubagentRow["title"]): string | null { if (typeof title !== "string") { return null; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c0ff19ec31..9dd38c19bc 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -13,7 +13,11 @@ import { } from "@/screens/workspace/workspace-tab-presentation"; import type { Theme } from "@/styles/theme"; import type { SubagentRow } from "./select"; -import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation"; +import { + buildSubagentRowPresentationData, + countFinishedSubagents, + formatHeaderLabel, +} from "./track-presentation"; const ThemedArchive = withUnistyles(Archive); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -30,6 +34,7 @@ export interface SubagentsTrackProps { onOpenSubagent: (id: string) => void; onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; + onArchiveFinished?: () => void; onDetachSubagent?: (id: string) => void; } @@ -47,8 +52,10 @@ export function SubagentsTrack({ onOpenSubagent, onOpenProviderSubagent, onArchiveSubagent, + onArchiveFinished, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { + const { t } = useTranslation(); const [expanded, setExpanded] = useState(false); const toggleExpanded = useCallback(() => { @@ -62,10 +69,13 @@ export function SubagentsTrack({ const headerStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType) => [ - styles.header, - expanded ? styles.headerDivider : styles.headerCollapsed, + styles.headerToggle, (hovered || pressed) && styles.headerActive, ], + [], + ); + const headerContainerStyle = useMemo( + () => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed], [expanded], ); @@ -74,27 +84,42 @@ export function SubagentsTrack({ } const headerLabel = formatHeaderLabel(rows); + const finishedCount = countFinishedSubagents(rows); return ( - - {expanded ? ( - - ) : ( - - )} - - {headerLabel} - - + + + {expanded ? ( + + ) : ( + + )} + + {headerLabel} + + + {finishedCount > 0 && onArchiveFinished ? ( + + + + ) : null} + {expanded ? ( ({ header: { flexDirection: "row", alignItems: "center", + }, + headerToggle: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "center", gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], + paddingLeft: theme.spacing[3], + paddingRight: theme.spacing[1], paddingVertical: theme.spacing[2], }, + headerAction: { + paddingRight: theme.spacing[2], + }, headerCollapsed: { - paddingBottom: theme.spacing[6], + paddingBottom: theme.spacing[4], }, headerActive: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/subagents/use-archive-finished-subagents.ts b/packages/app/src/subagents/use-archive-finished-subagents.ts new file mode 100644 index 0000000000..641b28a69c --- /dev/null +++ b/packages/app/src/subagents/use-archive-finished-subagents.ts @@ -0,0 +1,28 @@ +import { useCallback } from "react"; +import { useToast } from "@/contexts/toast-context"; +import { i18n } from "@/i18n/i18next"; +import { useSessionStore } from "@/stores/session-store"; +import { toErrorMessage } from "@/utils/error-messages"; + +export interface UseArchiveFinishedSubagentsInput { + serverId: string; + parentAgentId: string; +} + +export function useArchiveFinishedSubagents({ + serverId, + parentAgentId, +}: UseArchiveFinishedSubagentsInput): () => void { + const toast = useToast(); + + return useCallback(() => { + const client = useSessionStore.getState().sessions[serverId]?.client; + if (!client) { + toast.error(i18n.t("workspaceSetup.errors.hostDisconnected")); + return; + } + void client.archiveFinishedSubagents(parentAgentId).catch((error) => { + toast.error(toErrorMessage(error)); + }); + }, [parentAgentId, serverId, toast]); +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 9f5f3d7c39..f08c90a844 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -498,6 +498,10 @@ export type ProviderSubagentListPayload = Extract< SessionOutboundMessage, { type: "agent.provider_subagents.list.response" } >["payload"]; +export type ArchiveFinishedSubagentsPayload = Extract< + SessionOutboundMessage, + { type: "agent.subagents.archive_finished.response" } +>["payload"]; export type ProviderSubagentTimelinePayload = Extract< SessionOutboundMessage, { type: "agent.provider_subagents.timeline.get.response" } @@ -2514,6 +2518,33 @@ export class DaemonClient { return payload; } + async archiveFinishedSubagents( + parentAgentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.subagents.archive_finished.request", + parentAgentId, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.subagents.archive_finished.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + async fetchProviderSubagentTimeline( parentAgentId: string, subagentId: string, diff --git a/packages/protocol/src/messages.provider-subagents.test.ts b/packages/protocol/src/messages.provider-subagents.test.ts index db75d6a61b..babd4c0e84 100644 --- a/packages/protocol/src/messages.provider-subagents.test.ts +++ b/packages/protocol/src/messages.provider-subagents.test.ts @@ -73,4 +73,32 @@ describe("provider subagent protocol", () => { }), ).toMatchObject({ payload: { subagents: [{ id: "child-1" }] } }); }); + + test("accepts one archive-finished operation across managed and provider children", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.subagents.archive_finished.request", + parentAgentId: "parent-1", + requestId: "request-1", + }), + ).toMatchObject({ parentAgentId: "parent-1" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.subagents.archive_finished.response", + payload: { + requestId: "request-1", + parentAgentId: "parent-1", + archivedAgentIds: ["managed-1"], + dismissedProviderSubagentIds: ["native-1"], + error: null, + }, + }), + ).toMatchObject({ + payload: { + archivedAgentIds: ["managed-1"], + dismissedProviderSubagentIds: ["native-1"], + }, + }); + }); }); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1718e11171..11e7026f69 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1308,6 +1308,12 @@ export const ProviderSubagentListRequestMessageSchema = z.object({ requestId: z.string(), }); +export const ArchiveFinishedSubagentsRequestMessageSchema = z.object({ + type: z.literal("agent.subagents.archive_finished.request"), + parentAgentId: z.string(), + requestId: z.string(), +}); + export const ProviderSubagentTimelineRequestMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.request"), parentAgentId: z.string(), @@ -2149,6 +2155,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, ProviderSubagentListRequestMessageSchema, + ArchiveFinishedSubagentsRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, @@ -2431,6 +2438,8 @@ export const ServerInfoStatusPayloadSchema = z agentForkContext: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(archiveFinishedSubagents): added in v0.1.108, remove gate after 2027-01-14. + archiveFinishedSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: z.boolean().optional(), // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13. @@ -3066,6 +3075,17 @@ export const ProviderSubagentListResponseMessageSchema = z.object({ }), }); +export const ArchiveFinishedSubagentsResponseMessageSchema = z.object({ + type: z.literal("agent.subagents.archive_finished.response"), + payload: z.object({ + requestId: z.string(), + parentAgentId: z.string(), + archivedAgentIds: z.array(z.string()), + dismissedProviderSubagentIds: z.array(z.string()), + error: z.string().nullable(), + }), +}); + export const ProviderSubagentTimelineResponseMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.response"), payload: z.object({ @@ -4373,6 +4393,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, ProviderSubagentListResponseMessageSchema, + ArchiveFinishedSubagentsResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, ProviderSubagentUpdateMessageSchema, AgentForkContextResponseMessageSchema, diff --git a/packages/server/src/server/agent/agent-loading.ts b/packages/server/src/server/agent/agent-loading.ts index 578502195d..46af90032f 100644 --- a/packages/server/src/server/agent/agent-loading.ts +++ b/packages/server/src/server/agent/agent-loading.ts @@ -74,6 +74,7 @@ export async function ensureAgentLoaded( } snapshot = await deps.agentManager.createAgent(config, agentId, { labels: record.labels, + dismissedProviderSubagentIds: record.dismissedProviderSubagentIds, workspaceId: record.workspaceId, }); deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config"); diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 759f9b2f41..ee1f6d0962 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2826,6 +2826,73 @@ test("hydrateTimelineFromProvider restores and broadcasts provider children from }); }); +test("archiveFinishedSubagents archives managed children and keeps dismissed provider history hidden", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-finished-subagents-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ParentSession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "native-finished", status: "completed" }, + }; + yield { + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "native-running", status: "running" }, + }; + yield { + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "native-failed", status: "failed" }, + }; + } + } + class SubagentsClient extends TestAgentClient { + private sessionsCreated = 0; + + override async createSession(config: AgentSessionConfig): Promise { + this.sessionsCreated += 1; + return this.sessionsCreated === 1 ? new ParentSession(config) : new TestAgentSession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new SubagentsClient() }, + registry: storage, + logger, + }); + const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const managedFinished = await manager.createAgent( + { provider: "codex", cwd: workdir }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, + ); + await manager.hydrateTimelineFromProvider(parent.id); + + const result = await manager.archiveFinishedSubagents(parent.id); + + expect(result).toEqual({ + archivedAgentIds: [managedFinished.id], + dismissedProviderSubagentIds: ["native-finished", "native-failed"], + }); + expectArchivedAgentRecord(await storage.get(managedFinished.id), "closed"); + expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([ + "native-running", + ]); + expect((await storage.get(parent.id))?.dismissedProviderSubagentIds).toEqual([ + "native-finished", + "native-failed", + ]); + + await manager.hydrateTimelineFromProvider(parent.id, { force: true }); + + expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([ + "native-running", + ]); +}); + test("force provider hydration removes children absent from current history", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-force-")); const storage = new AgentStorage(join(workdir, "agents"), logger); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fa34cbaa16..25632c6566 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -226,6 +226,7 @@ type ProviderClientMap = Partial>; export interface CreateAgentOptions { labels?: Record; + dismissedProviderSubagentIds?: string[]; initialPrompt?: string; env?: Record; persistSession?: boolean; @@ -338,6 +339,7 @@ interface ManagedAgentBase { * User-defined labels for categorizing agents (e.g., { surface: "workspace" }). */ labels: Record; + dismissedProviderSubagentIds: Set; } type ManagedAgentWithSession = ManagedAgentBase & { @@ -962,6 +964,51 @@ export class AgentManager { return this.providerSubagents.list(parentAgentId); } + async archiveFinishedSubagents(parentAgentId: string): Promise<{ + archivedAgentIds: string[]; + dismissedProviderSubagentIds: string[]; + }> { + const parent = this.requirePublicAgent(parentAgentId); + const registry = this.requireRegistry(); + const archivedAgentIds: string[] = []; + + for (const record of await registry.list()) { + if (record.archivedAt || record.labels[PARENT_AGENT_ID_LABEL] !== parentAgentId) { + continue; + } + const live = this.agents.get(record.id); + const status = live?.lifecycle ?? record.lastStatus; + if (isAgentBusy(status)) { + continue; + } + if (live) { + await this.archiveAgent(record.id); + } else { + await this.markRecordArchived(record); + await this.cascadeArchiveChildren(record.id); + } + archivedAgentIds.push(record.id); + } + + const dismissedProviderSubagentIds = this.providerSubagents + .list(parentAgentId) + .filter((subagent) => subagent.status !== "running") + .map((subagent) => subagent.id); + for (const subagentId of dismissedProviderSubagentIds) { + parent.dismissedProviderSubagentIds.add(subagentId); + const event = this.providerSubagents.apply(parentAgentId, parent.provider, { + type: "remove", + id: subagentId, + }); + this.dispatch({ type: "provider_subagent", event }); + } + if (dismissedProviderSubagentIds.length > 0) { + await this.persistSnapshot(parent); + } + + return { archivedAgentIds, dismissedProviderSubagentIds }; + } + getProviderSubagent( parentAgentId: string, subagentId: string, @@ -1005,6 +1052,7 @@ export class AgentManager { const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); return this.registerSession(session, storedConfig, resolvedAgentId, { labels: options.labels, + dismissedProviderSubagentIds: options.dismissedProviderSubagentIds, initialTitle: options.initialTitle, workspaceId: options.workspaceId, }); @@ -1030,6 +1078,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + dismissedProviderSubagentIds?: string[]; }, ): Promise { return this.trackAgentRegistrationOperation( @@ -1047,6 +1096,7 @@ export class AgentManager { lastUserMessageAt?: Date | null; labels?: Record; workspaceId?: string; + dismissedProviderSubagentIds?: string[]; }, ): Promise { this.assertAcceptingAgentRegistrations(); @@ -1233,6 +1283,7 @@ export class AgentManager { lastUsage: preservedLastUsage, lastError: preservedLastError, attention: preservedAttention, + dismissedProviderSubagentIds: [...existing.dismissedProviderSubagentIds], }); } finally { if (!handedToRegistration) { @@ -1443,6 +1494,7 @@ export class AgentManager { attention: { requiresAttention: false }, internal: record.internal, labels: record.labels, + dismissedProviderSubagentIds: new Set(record.dismissedProviderSubagentIds), }, }); } @@ -2566,6 +2618,7 @@ export class AgentManager { initialTitle?: string | null; publishWhenReady?: boolean; workspaceId?: string; + dismissedProviderSubagentIds?: string[]; }, ): Promise { let registered = false; @@ -2701,6 +2754,7 @@ export class AgentManager { attention?: AttentionState; persistence?: AgentPersistenceHandle; workspaceId?: string; + dismissedProviderSubagentIds?: string[]; } | undefined; }): ActiveManagedAgent { @@ -2738,6 +2792,7 @@ export class AgentManager { attention: resolveInitialAttention(options?.attention), internal: config.internal ?? false, labels: options?.labels ?? {}, + dismissedProviderSubagentIds: new Set(options?.dismissedProviderSubagentIds ?? []), } as ActiveManagedAgent; } @@ -2851,8 +2906,10 @@ export class AgentManager { event: AgentStreamEvent, ): Promise { if (event.type === "provider_subagent") { - const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - this.dispatch({ type: "provider_subagent", event: update }); + const update = this.applyProviderSubagentEvent(agent, event); + if (update) { + this.dispatch({ type: "provider_subagent", event: update }); + } return; } const turnId = getAgentStreamEventTurnId(event); @@ -2892,6 +2949,16 @@ export class AgentManager { ); } + private applyProviderSubagentEvent( + agent: ManagedAgent, + event: Extract, + ): ProviderSubagentStoreEvent | null { + if (agent.dismissedProviderSubagentIds.has(event.event.id)) { + return null; + } + return this.providerSubagents.apply(agent.id, event.provider, event.event); + } + private async resolveInitialPersistedTitle( agentId: string, config: AgentSessionConfig, @@ -3029,8 +3096,8 @@ export class AgentManager { } } for (const event of providerSubagentEvents) { - const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - if (broadcast) { + const update = this.applyProviderSubagentEvent(agent, event); + if (broadcast && update) { this.dispatch({ type: "provider_subagent", event: update }); } } @@ -3060,8 +3127,8 @@ export class AgentManager { try { for await (const event of agent.session.streamHistory()) { if (event.type === "provider_subagent") { - const update = this.providerSubagents.apply(agent.id, event.provider, event.event); - if (broadcast) { + const update = this.applyProviderSubagentEvent(agent, event); + if (broadcast && update) { this.dispatch({ type: "provider_subagent", event: update }); } continue; diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 6adee4bd57..4c9b511322 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -93,6 +93,7 @@ export function toStoredAgentRecord( ? agent.attention.attentionTimestamp.toISOString() : null, internal: options?.internal, + dismissedProviderSubagentIds: [...agent.dismissedProviderSubagentIds], } satisfies StoredAgentRecord; } diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 069865f660..0d210b3310 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -63,6 +63,7 @@ const STORED_AGENT_SCHEMA = z.object({ attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(), attentionTimestamp: z.string().nullable().optional(), internal: z.boolean().optional(), + dismissedProviderSubagentIds: z.array(z.string()).default([]), archivedAt: z.string().nullable().optional(), }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 60001add3b..99a6acfe2f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] { } interface ClaudeHistoricalSubagentToolCall { + name?: string; subagentType?: string; description?: string; } @@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls( continue; } const input = toObjectRecord(block.input); + const name = readNonEmptyString(input?.name); const subagentType = readNonEmptyString(input?.subagent_type); const description = readNonEmptyString(input?.description); toolCalls.set(block.id, { + ...(name ? { name } : {}), ...(subagentType ? { subagentType } : {}), ...(description ? { description } : {}), }); @@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents( return events; } +function resolveClaudeHistoricalSubagentTitle( + toolCall: ClaudeHistoricalSubagentToolCall | undefined, +): string { + return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent"; +} + function buildClaudePersistedSidechainAgentEvents( agentId: string, entries: ClaudeHistoryEntry[], @@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents( event: { type: "upsert", id, - title: toolCall?.subagentType ?? "Claude subagent", + title: resolveClaudeHistoricalSubagentTitle(toolCall), description: toolCall?.description ?? null, status: "running", toolCallId: result?.toolCallId ?? null, diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index a9d852a455..6dab8d205c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => { type: "tool_use", id: "history-task-call", name: "Agent", - input: { description: "Inspect persisted history" }, + input: { + name: "history_researcher", + subagent_type: "Explore", + description: "Inspect persisted history", + }, }, ], }, @@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => { timestamp: "2026-07-12T10:00:01.000Z", }, }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + title: "history_researcher", + status: "running", + }), + }); expect(historyEvents).toContainEqual({ type: "provider_subagent", provider: "claude", diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts new file mode 100644 index 0000000000..615ca81f3a --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { ClaudeSidechainTracker } from "./sidechain-tracker.js"; + +describe("ClaudeSidechainTracker", () => { + it("uses Claude's native agent name for the provider subagent title", () => { + const tracker = new ClaudeSidechainTracker({ + getToolInput: () => ({ + name: "repo_researcher", + subagent_type: "Explore", + description: "Inspect the repository", + }), + }); + + const events = tracker.handleMessage( + { + type: "assistant", + parent_tool_use_id: "task-1", + message: { content: [] }, + } as unknown as SDKMessage, + "task-1", + ); + + expect(events[0]).toEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: "task-1", + title: "repo_researcher", + description: "Inspect the repository", + status: "running", + toolCallId: "task-1", + }, + }); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index aef9e91b60..d5257e3d1c 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -22,6 +22,7 @@ interface SubAgentActionEntry { } interface SubAgentActivityState { + name?: string; subAgentType?: string; description?: string; actions: SubAgentActionEntry[]; @@ -130,7 +131,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id: parentToolUseId, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status: "running", toolCallId: parentToolUseId, @@ -163,7 +164,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -185,7 +186,7 @@ export class ClaudeSidechainTracker { event: { type: "upsert", id, - title: state.subAgentType ?? "Claude subagent", + title: state.name ?? state.subAgentType ?? "Claude subagent", description: state.description ?? null, status, toolCallId: id, @@ -267,10 +268,15 @@ export class ClaudeSidechainTracker { parentToolUseId: string, ): boolean { const taskInput = this.getToolInput(parentToolUseId); + const nextName = this.normalizeSubAgentText(taskInput?.name); const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type); const nextDescription = this.normalizeSubAgentText(taskInput?.description); let changed = false; + if (nextName && nextName !== state.name) { + state.name = nextName; + changed = true; + } if (nextSubAgentType && nextSubAgentType !== state.subAgentType) { state.subAgentType = nextSubAgentType; changed = true; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index 6c8919e522..bf68a7b572 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => { id: `activity-${kind}`, kind, agentThreadId: "child-thread-1", - agentPath: "/root/investigator", + agentPath: "/root/research/investigator", }); expect(item).toEqual({ @@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => { error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", - description: "/root/investigator", + subAgentType: "research/investigator", + description: "/root/research/investigator", log: "", actions: [], }, diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 967b6b43aa..64f25323d5 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -993,6 +993,7 @@ function mapCollabAgentToolCallItem( function mapSubAgentActivityItem( item: z.infer, ): ToolCallTimelineItem { + const nativeName = item.agentPath.replace(/^\/root(?:\/|$)/, "") || "Sub-agent"; return { type: "tool_call", callId: item.id, @@ -1001,7 +1002,7 @@ function mapSubAgentActivityItem( error: null, detail: { type: "sub_agent", - subAgentType: "Sub-agent", + subAgentType: nativeName, description: item.agentPath, log: "", actions: [], diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index 5fc5113729..9a05788542 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -110,6 +110,7 @@ export function extractTimestamps(record: StoredAgentRecord): { lastUserMessageAt: Date | null; labels?: Record; workspaceId?: string; + dismissedProviderSubagentIds?: string[]; } { return { createdAt: new Date(record.createdAt), @@ -117,6 +118,7 @@ export function extractTimestamps(record: StoredAgentRecord): { lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, labels: record.labels, workspaceId: record.workspaceId, + dismissedProviderSubagentIds: record.dismissedProviderSubagentIds, }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 69df4cd150..bd2ffbaa5a 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1486,6 +1486,8 @@ export class Session { return this.handleFetchAgentTimelineRequest(msg); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); + case "agent.subagents.archive_finished.request": + return this.handleArchiveFinishedSubagentsRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg); case "agent.fork_context.request": @@ -5448,6 +5450,34 @@ export class Session { } } + private async handleArchiveFinishedSubagentsRequest( + msg: Extract, + ): Promise { + try { + const result = await this.agentManager.archiveFinishedSubagents(msg.parentAgentId); + this.emit({ + type: "agent.subagents.archive_finished.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + ...result, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.subagents.archive_finished.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + archivedAgentIds: [], + dismissedProviderSubagentIds: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleProviderSubagentTimelineRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 05ce7e886b..1258f3a1c0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1262,6 +1262,8 @@ export class VoiceAssistantWebSocketServer { agentForkContext: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(archiveFinishedSubagents): added in v0.1.108, remove gate after 2027-01-14. + archiveFinishedSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. workspacePinning: true, // COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.