From efb7bfc4ff0d3b1941246d7aff891927c1f43296 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 26 Jul 2026 21:56:47 -0400 Subject: [PATCH 01/34] feat: add Hermes-powered T3 Work --- apps/mobile/src/components/ProviderIcon.tsx | 38 +- apps/mobile/src/features/home/HomeHeader.tsx | 86 +- .../src/features/home/HomeRouteScreen.tsx | 88 +- apps/mobile/src/features/home/HomeScreen.tsx | 12 +- .../home/usePendingTaskListActions.ts | 1 + .../threads/NewTaskDraftRouteScreen.tsx | 31 +- .../features/threads/NewTaskDraftScreen.tsx | 95 +- .../threads/ThreadNavigationSidebar.tsx | 142 +- .../threads/new-task-flow-provider.tsx | 14 +- .../threads/sidebar-native-header-items.ts | 27 + .../features/threads/thread-list-v2-items.tsx | 28 +- .../features/threads/use-project-actions.ts | 2 + apps/mobile/src/lib/mobileWorkspace.test.ts | 182 + apps/mobile/src/lib/mobileWorkspace.ts | 128 + .../src/lib/projectThreadStartTurn.test.ts | 54 + apps/mobile/src/lib/projectThreadStartTurn.ts | 3 + .../src/persistence/mobile-preferences.ts | 5 + apps/mobile/src/state/preferences.ts | 20 + apps/mobile/src/state/thread-outbox-model.ts | 2 + .../src/state/use-thread-outbox-drain.ts | 1 + apps/server/src/assets/AssetAccess.test.ts | 88 + apps/server/src/assets/AssetAccess.ts | 125 +- apps/server/src/attachmentStore.ts | 29 +- apps/server/src/config.ts | 13 + .../hermes/HermesConnectionSecurity.test.ts | 116 + .../src/hermes/HermesConnectionSecurity.ts | 189 + apps/server/src/hermes/HermesCron.test.ts | 101 + apps/server/src/hermes/HermesCron.ts | 520 +++ .../src/hermes/HermesGatewayClient.test.ts | 1102 ++++++ apps/server/src/hermes/HermesGatewayClient.ts | 1513 ++++++++ .../hermes/HermesHistoryNormalization.test.ts | 294 ++ .../src/hermes/HermesHistoryNormalization.ts | 601 +++ .../src/hermes/HermesOperational.test.ts | 166 + apps/server/src/hermes/HermesOperational.ts | 279 ++ .../HermesProactiveEventRepository.test.ts | 330 ++ .../hermes/HermesProactiveEventRepository.ts | 769 ++++ .../src/hermes/HermesServeRuntime.test.ts | 119 + apps/server/src/hermes/HermesServeRuntime.ts | 311 ++ .../HermesSessionBindingRepository.test.ts | 722 ++++ .../hermes/HermesSessionBindingRepository.ts | 1363 +++++++ .../server/src/hermes/HermesSessionCatalog.ts | 79 + .../hermes/HermesSessionImportService.test.ts | 598 +++ .../src/hermes/HermesSessionImportService.ts | 587 +++ apps/server/src/mcp/McpHttpServer.test.ts | 150 +- apps/server/src/mcp/McpHttpServer.ts | 15 +- .../src/mcp/McpInvocationContext.test.ts | 2 + apps/server/src/mcp/McpInvocationContext.ts | 3 + apps/server/src/mcp/McpProviderSession.ts | 5 + .../server/src/mcp/McpSessionRegistry.test.ts | 57 +- .../src/mcp/McpSessionRegistry.testkit.ts | 12 + apps/server/src/mcp/McpSessionRegistry.ts | 217 +- ...OrchestratorMcpToolkit.integration.test.ts | 2 + .../src/mcp/PreviewAutomationBroker.test.ts | 2 + .../server/src/mcp/WorktreeMcpService.test.ts | 2 + .../src/mcp/toolkits/preview/handlers.ts | 191 +- apps/server/src/mcp/toolkits/preview/tools.ts | 23 +- .../toolkits/worktree/registration.test.ts | 7 + .../orchestration-v2/Adapters/AcpAdapterV2.ts | 9 +- .../Adapters/CodexAdapterV2.testkit.ts | 3 + .../Adapters/CursorAdapterV2.testkit.ts | 3 + .../Adapters/HermesAcpAdapterV2.test.ts | 109 + .../Adapters/HermesAcpAdapterV2.ts | 155 + .../Adapters/HermesServeAdapterV2.test.ts | 1958 ++++++++++ .../Adapters/HermesServeAdapterV2.ts | 3411 +++++++++++++++++ .../Adapters/OpenClawAdapterV2.test.ts | 21 + .../Adapters/OpenClawAdapterV2.ts | 139 + .../src/orchestration-v2/EffectWorker.test.ts | 137 +- .../src/orchestration-v2/EffectWorker.ts | 64 +- .../src/orchestration-v2/Orchestrator.ts | 157 + .../src/orchestration-v2/ProjectionStore.ts | 52 +- .../src/orchestration-v2/ProviderAdapter.ts | 8 + .../orchestration-v2/ProviderEventIngestor.ts | 12 + .../ProviderSessionManager.test.ts | 139 +- .../ProviderSessionManager.ts | 64 +- .../ProviderTurnStartService.ts | 138 + .../RunExecutionService.test.ts | 67 + .../orchestration-v2/RunExecutionService.ts | 35 +- .../ThreadLaunchService.test.ts | 32 + .../orchestration-v2/ThreadLaunchService.ts | 10 +- .../builtInProviderAdapterDrivers.ts | 18 + .../src/orchestration-v2/runtimeLayer.test.ts | 310 +- .../testkit/ProviderReplayHarness.ts | 3 + apps/server/src/persistence/Migrations.ts | 10 + .../035_036_OrchestrationV2.test.ts | 14 +- .../044_HermesSessionBindings.test.ts | 149 + .../Migrations/044_HermesSessionBindings.ts | 149 + .../Migrations/045_HermesProactiveEvents.ts | 131 + .../046_HermesTitleBranchLineage.ts | 44 + .../047_HermesSessionImports.test.ts | 70 + .../Migrations/047_HermesSessionImports.ts | 43 + .../048_HermesImportProjectScope.test.ts | 109 + .../048_HermesImportProjectScope.ts | 87 + .../provider/Drivers/HermesAcpDriver.test.ts | 17 + .../src/provider/Drivers/HermesAcpDriver.ts | 139 + .../src/provider/Drivers/HermesDriver.test.ts | 328 ++ .../src/provider/Drivers/HermesDriver.ts | 644 ++++ .../provider/Drivers/OpenClawDriver.test.ts | 15 + .../src/provider/Drivers/OpenClawDriver.ts | 139 + .../provider/Layers/HermesAcpProvider.test.ts | 81 + .../src/provider/Layers/HermesAcpProvider.ts | 196 + .../provider/Layers/OpenClawProvider.test.ts | 110 + .../src/provider/Layers/OpenClawProvider.ts | 195 + .../ProviderInstanceRegistryHydration.test.ts | 106 + .../ProviderInstanceRegistryHydration.ts | 104 +- .../ProviderInstanceRegistryLive.test.ts | 5 +- apps/server/src/provider/ProviderDriver.ts | 6 + .../src/provider/acp/HermesAcpSupport.test.ts | 27 + .../src/provider/acp/HermesAcpSupport.ts | 73 + .../src/provider/acp/OpenClawSupport.test.ts | 56 + .../src/provider/acp/OpenClawSupport.ts | 74 + apps/server/src/provider/builtInDrivers.ts | 9 + apps/server/src/server.ts | 29 +- apps/server/src/ws.ts | 41 +- apps/web/public/hermes-agent-logo.png | Bin 0 -> 41903 bytes apps/web/src/components/ChatMarkdown.tsx | 49 + .../web/src/components/ChatView.logic.test.ts | 158 +- apps/web/src/components/ChatView.logic.ts | 116 +- apps/web/src/components/ChatView.tsx | 310 +- apps/web/src/components/CommandPalette.tsx | 145 +- .../HermesImportOnboarding.logic.test.ts | 120 + .../HermesImportOnboarding.logic.ts | 96 + .../src/components/HermesImportOnboarding.tsx | 295 ++ apps/web/src/components/Icons.tsx | 60 + apps/web/src/components/Sidebar.logic.test.ts | 196 +- apps/web/src/components/Sidebar.logic.ts | 112 +- apps/web/src/components/SidebarV2.tsx | 653 +++- apps/web/src/components/chat/ChatComposer.tsx | 136 +- .../src/components/chat/DraftHeroHeadline.tsx | 8 +- .../chat/ExpandedImageDialog.test.tsx | 24 + .../components/chat/ExpandedImageDialog.tsx | 32 +- .../src/components/chat/MarkdownMedia.test.ts | 68 + .../web/src/components/chat/MarkdownMedia.tsx | 185 + .../chat/MessagesTimeline.logic.test.ts | 48 + .../components/chat/MessagesTimeline.logic.ts | 37 +- .../components/chat/MessagesTimeline.test.tsx | 202 + .../src/components/chat/MessagesTimeline.tsx | 339 +- .../components/chat/ThreadDetailsPanel.tsx | 189 +- .../src/components/chat/ThreadErrorBanner.tsx | 8 +- .../chat/composerAttachmentValidation.test.ts | 86 + .../chat/composerAttachmentValidation.ts | 103 + .../chat/providerErrorPresentation.test.ts | 41 + .../chat/providerErrorPresentation.ts | 48 + .../components/chat/providerIconUtils.test.ts | 24 + .../src/components/chat/providerIconUtils.ts | 13 +- .../settings/AddProviderInstanceDialog.tsx | 5 +- .../settings/HermesCronSettings.tsx | 513 +++ .../ProviderInstanceCard.comingSoon.test.tsx | 39 + .../settings/ProviderInstanceCard.tsx | 46 +- .../settings/ProviderModelsSection.tsx | 6 +- .../settings/ProviderSettingsForm.test.ts | 85 + .../settings/SettingsPanels.logic.test.ts | 70 + .../settings/SettingsPanels.logic.ts | 20 +- .../components/settings/SettingsPanels.tsx | 132 +- .../settings/SettingsSidebarNav.tsx | 3 + .../components/settings/providerDriverMeta.ts | 74 + .../src/components/sidebar/SidebarChrome.tsx | 10 +- apps/web/src/composerDraftStore.ts | 44 +- apps/web/src/hooks/useHandleNewThread.ts | 28 +- apps/web/src/index.css | 10 +- apps/web/src/lib/messageReply.test.ts | 46 + apps/web/src/lib/messageReply.ts | 37 + apps/web/src/routeTree.gen.ts | 21 + .../src/routes/-settings.hermes-cron.test.ts | 18 + apps/web/src/routes/settings.hermes-cron.tsx | 32 + apps/web/src/routes/settings.providers.tsx | 2 +- apps/web/src/session-logic.test.ts | 171 + apps/web/src/session-logic.ts | 74 +- apps/web/src/state/hermes.ts | 5 + apps/web/src/t3WorkProject.test.ts | 40 + apps/web/src/t3WorkProject.ts | 21 + apps/web/src/types.ts | 7 +- docs/integrations/hermes-acp.md | 45 + .../hermes-conformance-evidence.template.md | 32 + docs/integrations/hermes-conformance.md | 150 + package.json | 1 + packages/client-runtime/package.json | 4 + .../src/operations/commands.test.ts | 163 + .../client-runtime/src/operations/commands.ts | 41 +- packages/client-runtime/src/state/hermes.ts | 45 + packages/client-runtime/src/state/models.ts | 6 + .../state/orchestrationV2Projection.test.ts | 35 + .../src/state/orchestrationV2Projection.ts | 14 + packages/client-runtime/src/state/server.ts | 8 + .../src/state/threadWorkflows.ts | 3 +- packages/contracts/src/assets.ts | 18 +- packages/contracts/src/chatAttachment.ts | 83 +- .../src/fixtures/hermesGateway.sanitized.json | 181 + packages/contracts/src/hermesGateway.test.ts | 136 + packages/contracts/src/hermesGateway.ts | 765 ++++ .../contracts/src/hermesProactive.test.ts | 75 + packages/contracts/src/hermesProactive.ts | 115 + packages/contracts/src/hermesSessions.test.ts | 159 + packages/contracts/src/hermesSessions.ts | 137 + packages/contracts/src/index.ts | 3 + packages/contracts/src/model.ts | 8 + packages/contracts/src/orchestrationV2.ts | 40 + packages/contracts/src/preview.test.ts | 15 + packages/contracts/src/previewAutomation.ts | 42 + packages/contracts/src/rpc.ts | 58 + packages/contracts/src/server.ts | 2 + packages/contracts/src/settings.test.ts | 156 + packages/contracts/src/settings.ts | 271 ++ packages/shared/src/filePreview.test.ts | 9 + packages/shared/src/filePreview.ts | 12 +- scripts/dev-runner.test.ts | 2 +- scripts/dev-runner.ts | 12 +- scripts/fixtures/hermes-conformance/README.md | 10 + scripts/hermes-conformance.ts | 1655 ++++++++ scripts/lib/hermes-conformance.test.ts | 184 + scripts/lib/hermes-conformance.ts | 277 ++ 210 files changed, 31672 insertions(+), 675 deletions(-) create mode 100644 apps/mobile/src/lib/mobileWorkspace.test.ts create mode 100644 apps/mobile/src/lib/mobileWorkspace.ts create mode 100644 apps/mobile/src/lib/projectThreadStartTurn.test.ts create mode 100644 apps/server/src/hermes/HermesConnectionSecurity.test.ts create mode 100644 apps/server/src/hermes/HermesConnectionSecurity.ts create mode 100644 apps/server/src/hermes/HermesCron.test.ts create mode 100644 apps/server/src/hermes/HermesCron.ts create mode 100644 apps/server/src/hermes/HermesGatewayClient.test.ts create mode 100644 apps/server/src/hermes/HermesGatewayClient.ts create mode 100644 apps/server/src/hermes/HermesHistoryNormalization.test.ts create mode 100644 apps/server/src/hermes/HermesHistoryNormalization.ts create mode 100644 apps/server/src/hermes/HermesOperational.test.ts create mode 100644 apps/server/src/hermes/HermesOperational.ts create mode 100644 apps/server/src/hermes/HermesProactiveEventRepository.test.ts create mode 100644 apps/server/src/hermes/HermesProactiveEventRepository.ts create mode 100644 apps/server/src/hermes/HermesServeRuntime.test.ts create mode 100644 apps/server/src/hermes/HermesServeRuntime.ts create mode 100644 apps/server/src/hermes/HermesSessionBindingRepository.test.ts create mode 100644 apps/server/src/hermes/HermesSessionBindingRepository.ts create mode 100644 apps/server/src/hermes/HermesSessionCatalog.ts create mode 100644 apps/server/src/hermes/HermesSessionImportService.test.ts create mode 100644 apps/server/src/hermes/HermesSessionImportService.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts create mode 100644 apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts create mode 100644 apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts create mode 100644 apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts create mode 100644 apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts create mode 100644 apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts create mode 100644 apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts create mode 100644 apps/server/src/persistence/Migrations/047_HermesSessionImports.ts create mode 100644 apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts create mode 100644 apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts create mode 100644 apps/server/src/provider/Drivers/HermesAcpDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/HermesAcpDriver.ts create mode 100644 apps/server/src/provider/Drivers/HermesDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/HermesDriver.ts create mode 100644 apps/server/src/provider/Drivers/OpenClawDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/OpenClawDriver.ts create mode 100644 apps/server/src/provider/Layers/HermesAcpProvider.test.ts create mode 100644 apps/server/src/provider/Layers/HermesAcpProvider.ts create mode 100644 apps/server/src/provider/Layers/OpenClawProvider.test.ts create mode 100644 apps/server/src/provider/Layers/OpenClawProvider.ts create mode 100644 apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts create mode 100644 apps/server/src/provider/acp/HermesAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/HermesAcpSupport.ts create mode 100644 apps/server/src/provider/acp/OpenClawSupport.test.ts create mode 100644 apps/server/src/provider/acp/OpenClawSupport.ts create mode 100644 apps/web/public/hermes-agent-logo.png create mode 100644 apps/web/src/components/HermesImportOnboarding.logic.test.ts create mode 100644 apps/web/src/components/HermesImportOnboarding.logic.ts create mode 100644 apps/web/src/components/HermesImportOnboarding.tsx create mode 100644 apps/web/src/components/chat/ExpandedImageDialog.test.tsx create mode 100644 apps/web/src/components/chat/MarkdownMedia.test.ts create mode 100644 apps/web/src/components/chat/MarkdownMedia.tsx create mode 100644 apps/web/src/components/chat/composerAttachmentValidation.test.ts create mode 100644 apps/web/src/components/chat/composerAttachmentValidation.ts create mode 100644 apps/web/src/components/chat/providerErrorPresentation.test.ts create mode 100644 apps/web/src/components/chat/providerErrorPresentation.ts create mode 100644 apps/web/src/components/chat/providerIconUtils.test.ts create mode 100644 apps/web/src/components/settings/HermesCronSettings.tsx create mode 100644 apps/web/src/components/settings/ProviderInstanceCard.comingSoon.test.tsx create mode 100644 apps/web/src/lib/messageReply.test.ts create mode 100644 apps/web/src/lib/messageReply.ts create mode 100644 apps/web/src/routes/-settings.hermes-cron.test.ts create mode 100644 apps/web/src/routes/settings.hermes-cron.tsx create mode 100644 apps/web/src/state/hermes.ts create mode 100644 apps/web/src/t3WorkProject.test.ts create mode 100644 apps/web/src/t3WorkProject.ts create mode 100644 docs/integrations/hermes-acp.md create mode 100644 docs/integrations/hermes-conformance-evidence.template.md create mode 100644 docs/integrations/hermes-conformance.md create mode 100644 packages/client-runtime/src/state/hermes.ts create mode 100644 packages/contracts/src/fixtures/hermesGateway.sanitized.json create mode 100644 packages/contracts/src/hermesGateway.test.ts create mode 100644 packages/contracts/src/hermesGateway.ts create mode 100644 packages/contracts/src/hermesProactive.test.ts create mode 100644 packages/contracts/src/hermesProactive.ts create mode 100644 packages/contracts/src/hermesSessions.test.ts create mode 100644 packages/contracts/src/hermesSessions.ts create mode 100644 scripts/fixtures/hermes-conformance/README.md create mode 100644 scripts/hermes-conformance.ts create mode 100644 scripts/lib/hermes-conformance.test.ts create mode 100644 scripts/lib/hermes-conformance.ts diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index 6c1b1038698..3e8d9d65221 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -1,5 +1,5 @@ import { useColorScheme } from "react-native"; -import { Path, Svg } from "react-native-svg"; +import { Circle, Path, Rect, Svg } from "react-native-svg"; type ProviderIconProps = { readonly provider: string | null | undefined; @@ -10,6 +10,42 @@ export function ProviderIcon(props: ProviderIconProps) { const isDarkMode = useColorScheme() === "dark"; const size = props.size ?? 16; + if (props.provider === "hermes") { + const color = isDarkMode ? "#f4f4f5" : "#18181b"; + return ( + + + + + ); + } + + if (props.provider === "openclaw") { + return ( + + + + + + + + + ); + } + if (props.provider === "claudeAgent") { return ( diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index 36cead9f8cc..9c94262f229 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -17,6 +17,7 @@ import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; +import type { MobileWorkspace } from "../../lib/mobileWorkspace"; import { buildHomeListFilterMenu, type HomeListFilterMenuEnvironment, @@ -32,6 +33,7 @@ export type HomeHeaderEnvironment = HomeListFilterMenuEnvironment; export function HomeHeader(props: { readonly environments: ReadonlyArray; + readonly workspace: MobileWorkspace; readonly projects: ReadonlyArray; readonly searchQuery: string; readonly selectedEnvironmentId: EnvironmentId | null; @@ -40,6 +42,7 @@ export function HomeHeader(props: { readonly threadSortOrder: SidebarThreadSortOrder; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; + readonly onWorkspaceChange: (workspace: MobileWorkspace) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -59,6 +62,23 @@ function checkedMenuState(checked: boolean) { return checked ? ("on" as const) : undefined; } +function workspaceMenuActions(props: HomeHeaderProps): MenuAction[] { + return [ + { + id: "workspace:work", + title: "T3 Work", + subtitle: "Create, learn, and explore", + state: checkedMenuState(props.workspace === "work"), + }, + { + id: "workspace:code", + title: "T3 Code", + subtitle: "Build, debug, and ship", + state: checkedMenuState(props.workspace === "code"), + }, + ]; +} + /** Thread List v2 lays the list out in fixed creation order, so the sort/group filter controls would be silently ignored — hide them and key the "customized" icon state off the environment filter alone. */ @@ -148,6 +168,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { threadListV2Enabled, ], ); + const workspaceActions = useMemo(() => workspaceMenuActions(props), [props.workspace]); const handleMenuAction = useCallback( (event: { nativeEvent: { event: string } }) => { const id = event.nativeEvent.event; @@ -196,6 +217,13 @@ function AndroidHomeHeader(props: HomeHeaderProps) { }, [props], ); + const handleWorkspaceAction = useCallback( + ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { + if (nativeEvent.event === "workspace:work") props.onWorkspaceChange("work"); + if (nativeEvent.event === "workspace:code") props.onWorkspaceChange("code"); + }, + [props], + ); return ( <> @@ -208,18 +236,29 @@ function AndroidHomeHeader(props: HomeHeaderProps) { > - - {/* Mirrors the desktop SidebarBrand: T3 mark + muted "Code". */} - - - Code - - - - Alpha + + + + + {props.workspace === "work" ? "Work" : "Code"} - - + + + + Alpha + + + + )} + {Platform.OS === "ios" ? ( + + + props.onWorkspaceChange("work")} + subtitle="Create, learn, and explore" + > + T3 Work + + props.onWorkspaceChange("code")} + subtitle="Build, debug, and ship" + > + T3 Code + + + + ) : null} ); } diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 9fa179f4c76..7463637239e 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -2,9 +2,17 @@ import * as Arr from "effect/Array"; import * as Order from "effect/Order"; import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; +import { Alert } from "react-native"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { useProjects, useThreadShells } from "../../state/entities"; +import { useProjects, useServerConfigs, useThreadShells } from "../../state/entities"; +import { + buildProviderDriverMap, + isHermesProviderInstance, + isMobileWorkspaceThread, + resolveHermesConversationTarget, +} from "../../lib/mobileWorkspace"; +import { useMobileWorkspace } from "../../state/preferences"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; @@ -25,13 +33,35 @@ export function HomeRouteScreen() { const { layout } = useAdaptiveWorkspaceLayout(); const projects = useProjects(); const threads = useThreadShells(); + const serverConfigs = useServerConfigs(); + const [workspace, setWorkspace] = useMobileWorkspace(); + const providerDrivers = useMemo(() => buildProviderDriverMap(serverConfigs), [serverConfigs]); + const visibleThreads = useMemo( + () => threads.filter((thread) => isMobileWorkspaceThread(thread, workspace, providerDrivers)), + [providerDrivers, threads, workspace], + ); const { state: catalogState } = useWorkspaceState(); const { savedConnectionsById } = useSavedRemoteConnections(); const navigation = useNavigation(); const [searchQuery, setSearchQuery] = useState(""); const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); - const pendingTasks = usePendingNewTasks(); + const allPendingTasks = usePendingNewTasks(); + const pendingTasks = useMemo( + () => + workspace === "code" + ? allPendingTasks + : allPendingTasks.filter( + (task) => + task.message.modelSelection !== undefined && + isHermesProviderInstance( + task.message.environmentId, + task.message.modelSelection.instanceId, + providerDrivers, + ), + ), + [allPendingTasks, providerDrivers, workspace], + ); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -79,6 +109,35 @@ export function HomeRouteScreen() { setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); + const startNewTask = () => { + if (workspace === "code") { + navigation.navigate("NewTaskSheet", { screen: "NewTask" }); + return; + } + const target = resolveHermesConversationTarget({ + projects, + serverConfigs, + requiredEnvironmentId: selectedEnvironmentId, + }); + if (!target) { + Alert.alert( + "Hermes is not ready", + "Enable and configure Hermes on a connected environment before starting a Work conversation.", + ); + return; + } + navigation.navigate("NewTaskSheet", { + screen: "NewTaskDraft", + params: { + environmentId: String(target.project.environmentId), + projectId: String(target.project.id), + title: "Hermes", + workspace: "work", + providerInstanceId: String(target.modelSelection.instanceId), + model: target.modelSelection.model, + }, + }); + }; // In split layouts the persistent sidebar IS the thread list — Home becomes // an empty detail pane so selecting a thread never transitions layouts. @@ -91,38 +150,36 @@ export function HomeRouteScreen() { navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onPress={startNewTask} /> } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - /> + ); } return ( - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} - > + <> {/* Restore the compact title in case the split branch blanked it. */} navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} onThreadSortOrderChange={setThreadSortOrder} /> @@ -164,7 +221,7 @@ export function HomeRouteScreen() { }, }); }} - onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} + onStartNewTask={startNewTask} onThreadSortOrderChange={setThreadSortOrder} pendingTasks={pendingTasks} projectGroupingMode={listOptions.projectGroupingMode} @@ -173,8 +230,9 @@ export function HomeRouteScreen() { savedConnectionsById={savedConnectionsById} searchQuery={searchQuery} selectedEnvironmentId={selectedEnvironmentId} - selectedProjectKey={selectedProjectKey} - threads={threads} + selectedProjectKey={workspace === "work" ? null : selectedProjectKey} + threads={visibleThreads} + workspace={workspace} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 8c308135f61..fa3b77c63ae 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -25,6 +25,7 @@ import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; import { scopedProjectKey } from "../../lib/scopedEntities"; +import type { MobileWorkspace } from "../../lib/mobileWorkspace"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -35,7 +36,7 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "../threads/thread-list-items"; -import { ThreadListV2Row } from "../threads/thread-list-v2-items"; +import { ThreadListV2InboxHeader, ThreadListV2Row } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, @@ -77,6 +78,7 @@ interface HomeScreenProps { readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly projectGroupingMode: SidebarProjectGroupingMode; + readonly workspace: MobileWorkspace; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; @@ -182,8 +184,9 @@ export function HomeScreen(props: HomeScreenProps) { >(() => new Map()); const preferencesResult = useAtomValue(mobilePreferencesAtom); const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; + props.workspace === "work" || + (AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true); const savePreferences = useAtomSet(updateMobilePreferencesAtom); const openSwipeableRef = useRef(null); const listRef = useRef(null); @@ -736,7 +739,7 @@ export function HomeScreen(props: HomeScreenProps) { ) : null; - if (!hasAnyThreads) { + if (!hasAnyThreads && props.workspace === "code") { return ( ))} + {props.workspace === "work" ? : null} ); diff --git a/apps/mobile/src/features/home/usePendingTaskListActions.ts b/apps/mobile/src/features/home/usePendingTaskListActions.ts index 3f0867ba0e2..bb5df0cdc30 100644 --- a/apps/mobile/src/features/home/usePendingTaskListActions.ts +++ b/apps/mobile/src/features/home/usePendingTaskListActions.ts @@ -20,6 +20,7 @@ export function usePendingTaskListActions(): { environmentId: String(pendingTask.message.environmentId), projectId: String(pendingTask.creation.projectId), pendingTaskId: String(pendingTask.message.messageId), + workspace: pendingTask.creation.prepareWorkspace === false ? "work" : "code", }, }); }, diff --git a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx index 8e6819378a6..2787849f41b 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftRouteScreen.tsx @@ -1,4 +1,5 @@ import type { StaticScreenProps } from "@react-navigation/native"; +import { ProviderInstanceId } from "@t3tools/contracts"; import { useMemo } from "react"; import { NativeStackScreenOptions } from "../../native/StackHeader"; @@ -10,6 +11,9 @@ type NewTaskDraftRouteParams = { readonly title?: string | string[]; readonly pendingTaskId?: string | string[]; readonly incomingShareId?: string | string[]; + readonly workspace?: string | string[]; + readonly providerInstanceId?: string | string[]; + readonly model?: string | string[]; }; export function NewTaskDraftRouteScreen({ route }: StaticScreenProps) { @@ -32,7 +36,12 @@ export function NewTaskDraftRouteScreen({ route }: StaticScreenProps ); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e328bcef00e..842f754cc6c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -7,7 +7,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, type ModelSelection } from "@t3tools/contracts"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -72,6 +72,8 @@ export function NewTaskDraftScreen(props: { readonly pendingTaskId?: string; /** Durable native share inbox item to merge into this project draft. */ readonly incomingShareId?: string; + readonly workspace?: "work" | "code"; + readonly initialModelSelection?: ModelSelection; }) { const projects = useProjects(); const createProjectThread = useCreateProjectThread(); @@ -110,6 +112,7 @@ export function NewTaskDraftScreen(props: { const shareImportMountedRef = useRef(true); const latestDraftKeyRef = useRef(flow.draftKey); const latestIncomingShareIdRef = useRef(props.incomingShareId); + const isWorkConversation = props.workspace === "work"; latestDraftKeyRef.current = flow.draftKey; latestIncomingShareIdRef.current = props.incomingShareId; const isImportingShare = importingShareKey !== null; @@ -226,6 +229,36 @@ export function NewTaskDraftScreen(props: { // A new navigation to this mounted screen delivers a fresh initialProjectRef // reference — treat it as a new request and let it apply again. const lastInitialProjectRefRef = useRef(props.initialProjectRef); + const initializedWorkDraftRef = useRef(null); + + useEffect(() => { + flow.setDraftScope(isWorkConversation ? "work" : "project"); + }, [flow.setDraftScope, isWorkConversation]); + + useEffect(() => { + if ( + !isWorkConversation || + flow.draftScope !== "work" || + !flow.draftKey || + !props.initialModelSelection + ) { + return; + } + if (initializedWorkDraftRef.current === flow.draftKey) { + return; + } + initializedWorkDraftRef.current = flow.draftKey; + clearComposerDraftContent(flow.draftKey); + flow.setSelectedModelKey( + `${props.initialModelSelection.instanceId}:${props.initialModelSelection.model}`, + ); + }, [ + flow.draftKey, + flow.draftScope, + flow.setSelectedModelKey, + isWorkConversation, + props.initialModelSelection, + ]); useEffect(() => { // Pending-task editing owns project selection (and must not fall through @@ -304,8 +337,10 @@ export function NewTaskDraftScreen(props: { return; } loadedBranchesProjectKeyRef.current = projectKey; - void flow.loadBranches(); - }, [flow.loadBranches, selectedProject]); + if (!isWorkConversation) { + void flow.loadBranches(); + } + }, [flow.loadBranches, isWorkConversation, selectedProject]); useEffect(() => { const shareId = props.incomingShareId; @@ -860,7 +895,7 @@ export function NewTaskDraftScreen(props: { // finds no work and ends the card within seconds. armAgentAwarenessLiveActivityForLocalWork({ threadTitle: deriveThreadTitleFromPrompt(initialMessageText), - projectTitle: selectedProject.title, + projectTitle: isWorkConversation ? "Hermes" : selectedProject.title, }); const result = await createProjectThread({ project: selectedProject, @@ -869,6 +904,7 @@ export function NewTaskDraftScreen(props: { branch: selectedBranchName, worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, startFromOrigin, + prepareWorkspace: isWorkConversation ? false : undefined, runtimeMode, interactionMode, initialMessageText, @@ -956,7 +992,11 @@ export function NewTaskDraftScreen(props: { onFocus={() => setIsComposerFocused(true)} onBlur={() => setIsComposerFocused(false)} onPasteImages={(uris) => void handleNativePasteImages(uris)} - placeholder={`Describe a coding task in ${selectedProject.title}`} + placeholder={ + isWorkConversation + ? "Ask Hermes anything" + : `Describe a coding task in ${selectedProject.title}` + } // Same collapsed centering as ThreadComposer: native vertical gravity // in a pill-height box. singleLineCentered={!isExpanded} @@ -1017,24 +1057,36 @@ export function NewTaskDraftScreen(props: { label={selectedEnvironmentLabel} /> - handleWorkspaceMenuAction(nativeEvent.event)} - > - - + {isWorkConversation ? null : ( + handleWorkspaceMenuAction(nativeEvent.event)} + > + + + )} ); const startButton = ( void handleStart()} @@ -1051,7 +1103,10 @@ export function NewTaskDraftScreen(props: { return ( - navigation.goBack()} /> + navigation.goBack()} + /> @@ -1126,7 +1181,9 @@ export function NewTaskDraftScreen(props: { return ( - + {promptEditor} diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 7872f312493..2f4086d529e 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -23,9 +23,14 @@ import { SymbolView } from "../../components/AppSymbol"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; +import { + buildProviderDriverMap, + isHermesProviderInstance, + isMobileWorkspaceThread, +} from "../../lib/mobileWorkspace"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; -import { mobilePreferencesAtom } from "../../state/preferences"; +import { mobilePreferencesAtom, useMobileWorkspace } from "../../state/preferences"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks, type PendingNewTask } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -63,7 +68,7 @@ import { ThreadListRow, ThreadListShowMoreRow, } from "./thread-list-items"; -import { ThreadListV2Row } from "./thread-list-v2-items"; +import { ThreadListV2InboxHeader, ThreadListV2Row } from "./thread-list-v2-items"; import { buildThreadListV2Items, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, @@ -82,6 +87,7 @@ type SidebarListItem = readonly pendingTask: PendingNewTask; readonly isLast: boolean; } + | { readonly type: "v2-inbox"; readonly key: string } | { readonly type: "v2-thread"; readonly key: string; readonly item: ThreadListV2Item } | { readonly type: "v2-show-more"; readonly key: string; readonly hiddenCount: number }; @@ -197,10 +203,33 @@ function ThreadNavigationSidebarPane( const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const preferencesResult = useAtomValue(mobilePreferencesAtom); + const [workspace, setWorkspace] = useMobileWorkspace(); const threadListV2Enabled = - AsyncResult.isSuccess(preferencesResult) && - preferencesResult.value.threadListV2Enabled === true; - const pendingTasks = usePendingNewTasks(); + workspace === "work" || + (AsyncResult.isSuccess(preferencesResult) && + preferencesResult.value.threadListV2Enabled === true); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const providerDrivers = useMemo(() => buildProviderDriverMap(serverConfigs), [serverConfigs]); + const visibleThreads = useMemo( + () => threads.filter((thread) => isMobileWorkspaceThread(thread, workspace, providerDrivers)), + [providerDrivers, threads, workspace], + ); + const allPendingTasks = usePendingNewTasks(); + const pendingTasks = useMemo( + () => + workspace === "code" + ? allPendingTasks + : allPendingTasks.filter( + (task) => + task.message.modelSelection !== undefined && + isHermesProviderInstance( + task.message.environmentId, + task.message.modelSelection.instanceId, + providerDrivers, + ), + ), + [allPendingTasks, providerDrivers, workspace], + ); const { openPendingTask, confirmDeletePendingTask } = usePendingTaskListActions(); const environments = useMemo( () => @@ -289,11 +318,11 @@ function ThreadNavigationSidebarPane( const scopedThreads = useMemo( () => selectedProjectRefs === null - ? threads - : threads.filter((thread) => + ? visibleThreads + : visibleThreads.filter((thread) => selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), - [selectedProjectRefs, threads], + [selectedProjectRefs, visibleThreads], ); const scopedPendingTasks = useMemo( () => @@ -414,7 +443,6 @@ function ThreadNavigationSidebarPane( }, [threadListV2Enabled]); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). - const serverConfigs = useAtomValue(environmentServerConfigsAtom); const settlementEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -437,9 +465,12 @@ function ThreadNavigationSidebarPane( if (!threadListV2Enabled) return { items: [], hiddenSettledCount: 0, snoozedCount: 0, nextSnoozeWakeAt: null }; return buildThreadListV2Items({ - threads: threads.filter((thread) => thread.archivedAt === null), + threads: visibleThreads, environmentId: options.selectedEnvironmentId, - projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, + projectRefs: + workspace === "work" || selectedProjectScope === null + ? null + : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, @@ -458,7 +489,8 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, - threads, + visibleThreads, + workspace, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -487,19 +519,29 @@ function ThreadNavigationSidebarPane( (pendingTask) => (options.selectedEnvironmentId === null || pendingTask.message.environmentId === options.selectedEnvironmentId) && - (selectedProjectRefs === null || + (workspace === "work" || + selectedProjectRefs === null || selectedProjectRefs.has( scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); - const items: SidebarListItem[] = v2PendingTasks.map((pendingTask, index) => ({ - type: "v2-pending-task" as const, - key: `v2-pending:${pendingTask.message.messageId}`, - pendingTask, - isLast: index === v2PendingTasks.length - 1, - })); + const items: SidebarListItem[] = []; + if (workspace === "work") { + items.push({ + type: "v2-inbox", + key: "v2-inbox", + }); + } + items.push( + ...v2PendingTasks.map((pendingTask, index) => ({ + type: "v2-pending-task" as const, + key: `v2-pending:${pendingTask.message.messageId}`, + pendingTask, + isLast: index === v2PendingTasks.length - 1, + })), + ); for (const item of threadListV2Layout.items) { items.push({ type: "v2-thread" as const, @@ -527,6 +569,24 @@ function ThreadNavigationSidebarPane( const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ + { + id: "workspace", + title: "Workspace", + subactions: [ + { + id: "workspace:work", + title: "T3 Work", + subtitle: "Create, learn, and explore", + state: workspace === "work" ? "on" : "off", + }, + { + id: "workspace:code", + title: "T3 Code", + subtitle: "Build, debug, and ship", + state: workspace === "code" ? "on" : "off", + }, + ], + }, { id: "environment", title: "Environment", @@ -547,7 +607,7 @@ function ThreadNavigationSidebarPane( })), ], }, - ...(projectFilterOptions.length === 0 + ...(workspace === "work" || projectFilterOptions.length === 0 ? [] : ([ { @@ -594,11 +654,26 @@ function ThreadNavigationSidebarPane( }, ] satisfies MenuAction[])), ], - [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], + [ + environments, + options, + projectFilterOptions, + selectedProjectKey, + threadListV2Enabled, + workspace, + ], ); const handleListMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { const event = nativeEvent.event; + if (event === "workspace:work") { + setWorkspace("work"); + return; + } + if (event === "workspace:code") { + setWorkspace("code"); + return; + } if (event === "environment:all") { setSelectedEnvironmentId(null); return; @@ -642,6 +717,7 @@ function ThreadNavigationSidebarPane( setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, + setWorkspace, ], ); @@ -715,6 +791,9 @@ function ThreadNavigationSidebarPane( if (previous.type === "v2-show-more" && item.type === "v2-show-more") { return previous.hiddenCount === item.hiddenCount; } + if (previous.type === "v2-inbox" && item.type === "v2-inbox") { + return true; + } if (previous.type === "v2-pending-task" && item.type === "v2-pending-task") { return previous.pendingTask === item.pendingTask && previous.isLast === item.isLast; } @@ -722,9 +801,11 @@ function ThreadNavigationSidebarPane( previous.type === "v2-thread" || previous.type === "v2-show-more" || previous.type === "v2-pending-task" || + previous.type === "v2-inbox" || item.type === "v2-thread" || item.type === "v2-show-more" || - item.type === "v2-pending-task" + item.type === "v2-pending-task" || + item.type === "v2-inbox" ) { return false; } @@ -752,6 +833,8 @@ function ThreadNavigationSidebarPane( const renderListItem = useCallback( ({ item }: { readonly item: SidebarListItem }) => { switch (item.type) { + case "v2-inbox": + return ; case "v2-pending-task": return ( buildHomeListFilterMenu({ environments, - projects: projectFilterOptions, + projects: workspace === "work" ? [] : projectFilterOptions, selectedEnvironmentId: options.selectedEnvironmentId, - selectedProjectKey, + selectedProjectKey: workspace === "work" ? null : selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onEnvironmentChange: setSelectedEnvironmentId, @@ -951,6 +1035,7 @@ function ThreadNavigationSidebarPane( setSelectedEnvironmentId, setThreadSortOrder, threadListV2Enabled, + workspace, ], ); const nativeHeaderItems = useMemo( @@ -958,9 +1043,11 @@ function ThreadNavigationSidebarPane( createSidebarHeaderItems({ filterIcon, filterMenu, + workspace, + onWorkspaceChange: setWorkspace, onOpenSettings: props.onOpenSettings, }), - [filterIcon, filterMenu, props.onOpenSettings], + [filterIcon, filterMenu, props.onOpenSettings, setWorkspace, workspace], ); // "No threads yet" over an inbox that is merely all-snoozed reads as // data loss; name the snoozed threads instead. @@ -993,6 +1080,7 @@ function ThreadNavigationSidebarPane( - Threads + T3 {workspace === "work" ? "Work" : "Code"} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 74fe2f4852a..e819499ab0b 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -57,6 +57,7 @@ import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; type WorkspaceMode = "local" | "worktree"; +type DraftScope = "project" | "work"; const EMPTY_BRANCH_REFS: ReadonlyArray = []; @@ -127,6 +128,7 @@ type NewTaskFlowContextValue = { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly expandedProvider: string | null; + readonly draftScope: DraftScope; readonly environments: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -163,6 +165,7 @@ type NewTaskFlowContextValue = { value: ReadonlyArray | undefined, ) => void; readonly setExpandedProvider: (value: string | null) => void; + readonly setDraftScope: (scope: DraftScope) => void; }; const NewTaskFlowContext = React.createContext(null); @@ -211,6 +214,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const [submitting, setSubmitting] = useState(false); const [branchQuery, setBranchQuery] = useState(""); const [expandedProvider, setExpandedProvider] = useState(null); + const [draftScope, setDraftScope] = useState("project"); const [editingPendingTask, setEditingPendingTask] = useState(null); // Mirrors `editingPendingTask` synchronously so the unmount flush cannot act // on a task whose editing session already ended this render. @@ -342,7 +346,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraftKey = editingPendingTask ? pendingTaskDraftKey(editingPendingTask.messageId) : selectedProject - ? `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` + ? draftScope === "work" + ? `new-task:work:${selectedProject.environmentId}` + : `new-task:${scopedProjectKey(selectedProject.environmentId, selectedProject.id)}` : null; const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; @@ -715,6 +721,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ...((workspaceSelection?.startFromOrigin ?? startFromOrigin) ? { startFromOrigin: true } : {}), + ...(draftScope === "work" ? { prepareWorkspace: false } : {}), }, createdAt: metadata.createdAt, }; @@ -726,6 +733,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProject, selectedProjectDraftKey, startFromOrigin, + draftScope, ], ); @@ -840,6 +848,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode, interactionMode, expandedProvider, + draftScope, environments, selectedProject, modelOptions, @@ -871,6 +880,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { setInteractionMode, setSelectedModelOptions, setExpandedProvider, + setDraftScope, }), [ attachments, @@ -883,6 +893,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { editingPendingTask, environments, expandedProvider, + draftScope, filteredBranches, finishEditingPendingTask, interactionMode, @@ -902,6 +913,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { selectedProjectDraftKey, selectedProviderSkills, setSelectedModelOptions, + setDraftScope, selectedProject, selectedProjectKey, selectedWorktreePath, diff --git a/apps/mobile/src/features/threads/sidebar-native-header-items.ts b/apps/mobile/src/features/threads/sidebar-native-header-items.ts index b80fffda057..b7a09d1c955 100644 --- a/apps/mobile/src/features/threads/sidebar-native-header-items.ts +++ b/apps/mobile/src/features/threads/sidebar-native-header-items.ts @@ -39,9 +39,36 @@ function toNativeHeaderMenuItems(items: HomeListFilterMenu["items"]): NativeHead export function createSidebarHeaderItems(input: { readonly filterIcon: string; readonly filterMenu: HomeListFilterMenu; + readonly workspace: "work" | "code"; + readonly onWorkspaceChange: (workspace: "work" | "code") => void; readonly onOpenSettings: () => void; }): NativeStackHeaderItem[] { return [ + withNativeGlassHeaderItem({ + type: "menu", + label: "", + accessibilityLabel: `Switch workspace. Current workspace: T3 ${input.workspace === "work" ? "Work" : "Code"}`, + icon: sfSymbolIcon("rectangle.2.swap"), + menu: { + title: "Workspace", + items: [ + { + type: "action" as const, + label: "T3 Work", + description: "Create, learn, and explore", + state: input.workspace === "work" ? ("on" as const) : undefined, + onPress: () => input.onWorkspaceChange("work"), + }, + { + type: "action" as const, + label: "T3 Code", + description: "Build, debug, and ship", + state: input.workspace === "code" ? ("on" as const) : undefined, + onPress: () => input.onWorkspaceChange("code"), + }, + ], + }, + }), withNativeGlassHeaderItem({ type: "menu", label: "", diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 90af04eb0ca..91015fbea04 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -85,6 +85,23 @@ export const ThreadListV2SettledDivider = memo(function ThreadListV2SettledDivid ); }); +export const ThreadListV2InboxHeader = memo(function ThreadListV2InboxHeader(props: { + readonly pane?: "screen" | "sidebar"; +}) { + const borderColor = useThemeColor("--color-border"); + return ( + + Inbox + + + ); +}); + export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly thread: EnvironmentThreadShell; readonly variant: "card" | "slim"; @@ -154,6 +171,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const selectedBackgroundColor = useThemeColor("--color-user-bubble"); const sidebarPane = props.pane === "sidebar"; const selected = props.selected === true; + const isHermes = props.providerDriver === "hermes"; const status = resolveThreadListV2Status(thread); const statusLabel = STATUS_LABEL_BY_STATUS[status]; @@ -216,7 +234,9 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const cardContent = ( <> - {props.project ? ( + {isHermes ? ( + + ) : props.project ? ( - {props.projectTitle ?? props.project?.title ?? ""} + {isHermes ? "Hermes" : (props.projectTitle ?? props.project?.title ?? "")} )} - {pr ? ( + {!isHermes && pr ? ( ) : null} - {props.providerDriver ? ( + {props.providerDriver && !isHermes ? ( diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a9..db11e838e00 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -33,6 +33,7 @@ export function useCreateProjectThread() { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly prepareWorkspace?: boolean; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly initialMessageText: string; @@ -74,6 +75,7 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, + prepareWorkspace: input.prepareWorkspace, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/mobile/src/lib/mobileWorkspace.test.ts b/apps/mobile/src/lib/mobileWorkspace.test.ts new file mode 100644 index 00000000000..242062ba57d --- /dev/null +++ b/apps/mobile/src/lib/mobileWorkspace.test.ts @@ -0,0 +1,182 @@ +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ServerConfig, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildProviderDriverMap, + isMobileWorkspaceThread, + mobileProviderInstanceKey, + resolveHermesConversationTarget, +} from "./mobileWorkspace"; + +const environmentId = EnvironmentId.make("environment:local"); +const hermesInstanceId = ProviderInstanceId.make("hermes-primary"); +const rootThreadId = ThreadId.make("thread:root"); + +function project( + id: string, + workspaceRoot: string, + projectEnvironmentId = environmentId, +): EnvironmentProject { + return { + environmentId: projectEnvironmentId, + id: ProjectId.make(id), + title: id, + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-07-26T00:00:00.000Z", + updatedAt: "2026-07-26T00:00:00.000Z", + }; +} + +function serverConfig(overrides: Partial = {}): ServerConfig { + return { + t3WorkDirectory: "/private/t3-work", + providers: [ + { + instanceId: hermesInstanceId, + driver: ProviderDriverKind.make("hermes"), + enabled: true, + installed: true, + status: "ready", + models: [ + { + slug: "default", + name: "Default", + isCustom: false, + capabilities: null, + }, + ], + }, + ], + ...overrides, + } as unknown as ServerConfig; +} + +describe("mobile workspace routing", () => { + it("recognizes custom Hermes instance ids from provider metadata", () => { + const configs = new Map([[environmentId, serverConfig()]]); + const drivers = buildProviderDriverMap(configs); + const thread: Parameters[0] = { + environmentId, + archivedAt: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId, + }, + providerInstanceId: hermesInstanceId, + modelSelection: { instanceId: hermesInstanceId, model: "default" }, + runtime: null, + }; + + expect(drivers.get(mobileProviderInstanceKey(environmentId, hermesInstanceId))).toBe("hermes"); + expect(isMobileWorkspaceThread(thread, "work", drivers)).toBe(true); + expect(isMobileWorkspaceThread(thread, "code", drivers)).toBe(true); + }); + + it("excludes archived and subagent threads from both workspaces", () => { + const drivers = buildProviderDriverMap(new Map([[environmentId, serverConfig()]])); + const base: Parameters[0] = { + environmentId, + archivedAt: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId, + }, + providerInstanceId: hermesInstanceId, + modelSelection: { instanceId: hermesInstanceId, model: "default" }, + runtime: null, + }; + + expect( + isMobileWorkspaceThread({ ...base, archivedAt: "2026-07-26T00:00:00.000Z" }, "code", drivers), + ).toBe(false); + expect( + isMobileWorkspaceThread( + { + ...base, + lineage: { + ...base.lineage, + parentThreadId: rootThreadId, + relationshipToParent: "subagent", + }, + }, + "work", + drivers, + ), + ).toBe(false); + }); + + it("routes new Work conversations through the private backing project", () => { + const ordinaryProject = project("project:ordinary", "/workspace/repo"); + const backingProject = project("project:t3-work", "/private/t3-work"); + + expect( + resolveHermesConversationTarget({ + projects: [ordinaryProject, backingProject], + serverConfigs: new Map([[environmentId, serverConfig()]]), + requiredEnvironmentId: null, + }), + ).toEqual({ + project: backingProject, + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + }); + }); + + it("routes new Work conversations through the selected environment", () => { + const otherEnvironmentId = EnvironmentId.make("environment:other"); + const firstBackingProject = project( + "project:first-t3-work", + "/private/t3-work", + otherEnvironmentId, + ); + const selectedBackingProject = project("project:selected-t3-work", "/private/t3-work"); + + expect( + resolveHermesConversationTarget({ + projects: [firstBackingProject, selectedBackingProject], + serverConfigs: new Map([ + [otherEnvironmentId, serverConfig()], + [environmentId, serverConfig()], + ]), + requiredEnvironmentId: environmentId, + }), + ).toEqual({ + project: selectedBackingProject, + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + }); + }); + + it("does not attach Work conversations to an arbitrary project while setup is incomplete", () => { + expect( + resolveHermesConversationTarget({ + projects: [project("project:ordinary", "/workspace/repo")], + serverConfigs: new Map([[environmentId, serverConfig()]]), + requiredEnvironmentId: null, + }), + ).toBeNull(); + expect( + resolveHermesConversationTarget({ + projects: [project("project:t3-work", "/private/t3-work")], + serverConfigs: new Map([[environmentId, serverConfig({ t3WorkDirectory: undefined })]]), + requiredEnvironmentId: null, + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/lib/mobileWorkspace.ts b/apps/mobile/src/lib/mobileWorkspace.ts new file mode 100644 index 00000000000..b06154d7e80 --- /dev/null +++ b/apps/mobile/src/lib/mobileWorkspace.ts @@ -0,0 +1,128 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/shell"; +import { + isProviderAvailable, + type EnvironmentId, + type ModelSelection, + type ProviderDriverKind, + type ProviderInstanceId, + type ServerConfig, +} from "@t3tools/contracts"; + +export type MobileWorkspace = "work" | "code"; + +export function mobileProviderInstanceKey( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, +): string { + return `${environmentId}\u0000${providerInstanceId}`; +} + +export function buildProviderDriverMap( + serverConfigs: ReadonlyMap, +): ReadonlyMap { + const drivers = new Map(); + for (const [environmentId, config] of serverConfigs) { + for (const provider of config.providers) { + drivers.set(mobileProviderInstanceKey(environmentId, provider.instanceId), provider.driver); + } + } + return drivers; +} + +export function isHermesThread( + thread: Pick< + EnvironmentThreadShell, + "environmentId" | "providerInstanceId" | "modelSelection" | "runtime" + >, + providerDrivers: ReadonlyMap, +): boolean { + const providerInstanceId = + thread.runtime?.providerInstanceId ?? + thread.providerInstanceId ?? + thread.modelSelection.instanceId; + const driver = providerDrivers.get( + mobileProviderInstanceKey(thread.environmentId, providerInstanceId), + ); + // Cached shells can arrive before server config. The canonical legacy + // instance id is a safe fallback; custom instance ids wait for metadata. + return driver === "hermes" || (driver === undefined && providerInstanceId === "hermes"); +} + +export function isHermesProviderInstance( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, + providerDrivers: ReadonlyMap, +): boolean { + const driver = providerDrivers.get(mobileProviderInstanceKey(environmentId, providerInstanceId)); + return driver === "hermes" || (driver === undefined && providerInstanceId === "hermes"); +} + +export function isMobileWorkspaceThread( + thread: Pick< + EnvironmentThreadShell, + "archivedAt" | "environmentId" | "lineage" | "providerInstanceId" | "modelSelection" | "runtime" + >, + workspace: MobileWorkspace, + providerDrivers: ReadonlyMap, +): boolean { + if (thread.archivedAt !== null || thread.lineage.relationshipToParent === "subagent") { + return false; + } + return workspace === "code" || isHermesThread(thread, providerDrivers); +} + +export interface HermesConversationTarget { + readonly project: EnvironmentProject; + readonly modelSelection: ModelSelection; +} + +/** + * Resolves the existing project shell used only to route a Hermes launch. + * Work UI never exposes this backing project, and `prepareWorkspace: false` + * prevents project/worktree setup from leaking into the conversation. + */ +export function resolveHermesConversationTarget(input: { + readonly projects: ReadonlyArray; + readonly serverConfigs: ReadonlyMap; + readonly requiredEnvironmentId: EnvironmentId | null; +}): HermesConversationTarget | null { + for (const [environmentId, config] of input.serverConfigs) { + if (input.requiredEnvironmentId !== null && environmentId !== input.requiredEnvironmentId) { + continue; + } + const provider = config.providers.find( + (candidate) => + candidate.driver === "hermes" && + candidate.enabled && + candidate.installed && + candidate.status === "ready" && + isProviderAvailable(candidate), + ); + if (!provider) continue; + const workDirectory = config.t3WorkDirectory; + const project = + workDirectory === undefined + ? undefined + : input.projects.find( + (candidate) => + candidate.environmentId === environmentId && + candidate.workspaceRoot === workDirectory, + ); + const model = + provider.models.find((candidate) => candidate.slug === "default") ?? + provider.models.find((candidate) => candidate.isDefault === true) ?? + provider.models[0]; + if (!project || !model) continue; + return { + project, + modelSelection: { + instanceId: provider.instanceId, + model: model.slug, + }, + }; + } + return null; +} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.test.ts b/apps/mobile/src/lib/projectThreadStartTurn.test.ts new file mode 100644 index 00000000000..31301d08d4c --- /dev/null +++ b/apps/mobile/src/lib/projectThreadStartTurn.test.ts @@ -0,0 +1,54 @@ +import { ProjectId, ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("./uuid", () => ({ + uuidv4: () => "unused", +})); + +import { buildProjectThreadStartTurnInput } from "./projectThreadStartTurn"; + +const baseSpec = { + projectId: ProjectId.make("project:t3-work"), + projectCwd: "/private/t3-work", + threadId: "thread:work", + commandId: "command:work", + messageId: "message:work", + createdAt: "2026-07-26T00:00:00.000Z", + text: "Summarize my messages", + attachments: [], + modelSelection: { + instanceId: ProviderInstanceId.make("hermes-primary"), + model: "default", + }, + runtimeMode: "full-access", + interactionMode: "default", + workspaceMode: "local", + branch: null, + worktreePath: null, + startFromOrigin: false, + worktreeBranchName: "unused", +} as const; + +describe("project thread start turn", () => { + it("marks projectless Work launches to skip backing-project preparation", () => { + const input = buildProjectThreadStartTurnInput({ + ...baseSpec, + prepareWorkspace: false, + }); + + expect(input.bootstrap).toMatchObject({ + prepareWorkspace: false, + createThread: { + projectId: ProjectId.make("project:t3-work"), + worktreePath: null, + }, + }); + expect(input.bootstrap).not.toHaveProperty("prepareWorktree"); + }); + + it("omits the workspace override for ordinary project launches", () => { + expect(buildProjectThreadStartTurnInput(baseSpec).bootstrap).not.toHaveProperty( + "prepareWorkspace", + ); + }); +}); diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 310f1818590..da367a68da0 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -36,6 +36,8 @@ export interface ProjectThreadStartTurnSpec { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin: boolean; + /** False for conversation providers whose backing project is routing-only. */ + readonly prepareWorkspace?: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; } @@ -63,6 +65,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, bootstrap: { + ...(spec.prepareWorkspace === undefined ? {} : { prepareWorkspace: spec.prepareWorkspace }), createThread: { projectId: spec.projectId, title, diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 4e576bb2fe1..359e35668b0 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -14,6 +14,7 @@ const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; export interface Preferences { + readonly workspace?: "work" | "code"; readonly liveActivitiesEnabled?: boolean; readonly baseFontSize?: number; readonly terminalFontSize?: number | null; @@ -80,6 +81,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { collapsedProjectGroups?: readonly string[]; projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; + workspace?: "work" | "code"; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -112,6 +114,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } + if (parsed.workspace === "work" || parsed.workspace === "code") { + preferences.workspace = parsed.workspace; + } return preferences; } diff --git a/apps/mobile/src/state/preferences.ts b/apps/mobile/src/state/preferences.ts index d173cf55be5..6cf57bef815 100644 --- a/apps/mobile/src/state/preferences.ts +++ b/apps/mobile/src/state/preferences.ts @@ -1,7 +1,10 @@ import * as Effect from "effect/Effect"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useAtomSet, useAtomValue } from "@effect/atom-react"; +import { useCallback } from "react"; import { MobilePreferencesStore, type Preferences } from "../persistence/mobile-preferences"; +import type { MobileWorkspace } from "../lib/mobileWorkspace"; import * as Runtime from "../lib/runtime"; export { @@ -122,3 +125,20 @@ export const mobilePreferencesState = createMobilePreferencesState(mobilePrefere export const mobilePreferencesAtom = mobilePreferencesState.preferencesAtom; export const updateMobilePreferencesAtom = mobilePreferencesState.updatePreferencesAtom; + +export function useMobileWorkspace(): readonly [ + MobileWorkspace, + (workspace: MobileWorkspace) => void, +] { + const preferences = useAtomValue(mobilePreferencesAtom); + const updatePreferences = useAtomSet(updateMobilePreferencesAtom); + const workspace = + AsyncResult.isSuccess(preferences) && preferences.value.workspace === "work" ? "work" : "code"; + const setWorkspace = useCallback( + (nextWorkspace: MobileWorkspace) => { + updatePreferences({ workspace: nextWorkspace }); + }, + [updatePreferences], + ); + return [workspace, setWorkspace] as const; +} diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be3872..85318c9a853 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -34,6 +34,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), + prepareWorkspace: Schema.optional(Schema.Boolean), }); export const QueuedThreadMessageSchema = Schema.Struct({ @@ -64,6 +65,7 @@ export interface QueuedThreadCreation { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly prepareWorkspace?: boolean; } export interface QueuedThreadMessage { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 20eda747315..429f3fd36e8 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -272,6 +272,7 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, + prepareWorkspace: creation.prepareWorkspace, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 42fd3f900e5..f3e582cb301 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -70,6 +70,94 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); + it.effect("issues exact-file workspace URLs for video files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-asset-video-" }); + const videoPath = path.join(root, "recordings", "demo.webm"); + yield* fileSystem.makeDirectory(path.join(root, "recordings"), { recursive: true }); + yield* fileSystem.writeFileString(videoPath, "webm-bytes"); + const canonicalVideoPath = yield* fileSystem.realPath(videoPath); + + const result = yield* issueAssetUrl({ + resource: { + _tag: "workspace-file", + threadId: ThreadId.make("thread-1"), + path: "recordings/demo.webm", + }, + workspaceRoot: root, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + + expect(yield* resolveAsset(token, "demo.webm")).toEqual({ + kind: "file", + path: canonicalVideoPath, + }); + expect(yield* resolveAsset(token, "other.webm")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + + it.effect("issues browser artifact URLs by safe media file name only", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + const artifactPath = path.join(config.browserArtifactsDir, "browser-recording-demo.webm"); + yield* fileSystem.writeFileString(artifactPath, "webm-bytes"); + const canonicalArtifactPath = yield* fileSystem.realPath(artifactPath); + yield* fileSystem.writeFileString( + path.join(config.browserArtifactsDir, "notes.txt"), + "not media", + ); + + const result = yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "browser-recording-demo.webm" }, + }); + const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); + const token = suffix.slice(0, suffix.indexOf("/")); + expect(yield* resolveAsset(token, "browser-recording-demo.webm")).toEqual({ + kind: "file", + path: canonicalArtifactPath, + }); + + expect( + (yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "notes.txt" }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + expect( + (yield* issueAssetUrl({ + resource: { _tag: "browser-artifact", fileName: "../state.sqlite" }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-browser-artifact-outside-", + }); + const outsideVideo = path.join(outside, "outside.webm"); + const artifactSymlink = path.join( + config.browserArtifactsDir, + "browser-recording-escape.webm", + ); + yield* fileSystem.writeFileString(outsideVideo, "outside-webm"); + yield* fileSystem.symlink(outsideVideo, artifactSymlink); + expect( + (yield* issueAssetUrl({ + resource: { + _tag: "browser-artifact", + fileName: "browser-recording-escape.webm", + }, + }).pipe(Effect.flip))._tag, + ).toBe("AssetBrowserArtifactNotFoundError"); + + yield* fileSystem.remove(artifactPath); + yield* fileSystem.symlink(outsideVideo, artifactPath); + expect(yield* resolveAsset(token, "browser-recording-demo.webm")).toBeNull(); + }).pipe(Effect.provide(testLayer)), + ); + it.effect("rejects workspace files outside the authorized root", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b469e0e315b..aed42285693 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -1,6 +1,7 @@ import type { AssetResource } from "@t3tools/contracts"; import { AssetAttachmentNotFoundError, + AssetBrowserArtifactNotFoundError, AssetPreviewTypeValidationError, AssetProjectFaviconInspectionError, AssetProjectFaviconNotFoundError, @@ -16,8 +17,10 @@ import { import { isWorkspaceImagePreviewPath, isWorkspacePreviewEntryPath, + isWorkspaceVideoPreviewPath, WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, + WORKSPACE_VIDEO_PREVIEW_EXTENSIONS, } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; @@ -47,6 +50,7 @@ const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, + ...WORKSPACE_VIDEO_PREVIEW_EXTENSIONS, ".css", ".js", ".mjs", @@ -77,6 +81,12 @@ const AssetClaimsSchema = Schema.Union([ attachmentId: Schema.String, expiresAt: Schema.Number, }), + Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("browser-artifact"), + fileName: Schema.String, + expiresAt: Schema.Number, + }), Schema.Struct({ version: Schema.Literal(1), kind: Schema.Literal("project-favicon"), @@ -120,6 +130,55 @@ const optionOnNotFound = ( }), ); +function normalizeBrowserArtifactFileName(fileName: string): string | null { + const trimmed = fileName.trim(); + if ( + trimmed.length === 0 || + trimmed.includes("/") || + trimmed.includes("\\") || + trimmed.includes("\0") || + trimmed.startsWith(".") + ) { + return null; + } + if (!isWorkspaceImagePreviewPath(trimmed) && !isWorkspaceVideoPreviewPath(trimmed)) { + return null; + } + return trimmed; +} + +const resolveBrowserArtifactPath = Effect.fn("AssetAccess.resolveBrowserArtifactPath")(function* ( + browserArtifactsDir: string, + fileName: string, +) { + const normalizedFileName = normalizeBrowserArtifactFileName(fileName); + if (normalizedFileName === null) return null; + + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const canonicalRoot = yield* fileSystem.realPath(browserArtifactsDir).pipe( + Effect.map((value): string | null => value), + Effect.orElseSucceed(() => null), + ); + const canonicalFile = yield* fileSystem + .realPath(path.join(browserArtifactsDir, normalizedFileName)) + .pipe( + Effect.map((value): string | null => value), + Effect.orElseSucceed(() => null), + ); + if (canonicalRoot === null || canonicalFile === null) return null; + + const relative = path.relative(canonicalRoot, canonicalFile); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + return null; + } + const info = yield* fileSystem.stat(canonicalFile).pipe( + Effect.map((value) => Option.some(value)), + Effect.orElseSucceed(() => Option.none()), + ); + return Option.isSome(info) && info.value.type === "File" ? canonicalFile : null; +}); + const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWorkspaceFile")( function* (input: { readonly workspaceRoot: string; readonly relativePath: string }) { const fileSystem = yield* FileSystem.FileSystem; @@ -234,21 +293,23 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - claims = isWorkspaceImagePreviewPath(resolved.relativePath) - ? { - version: 1, - kind: "workspace-file-exact", - workspaceRoot: canonicalWorkspaceRoot, - relativePath: resolved.relativePath, - expiresAt, - } - : { - version: 1, - kind: "workspace-file", - workspaceRoot: canonicalWorkspaceRoot, - baseRelativePath: path.dirname(resolved.relativePath), - expiresAt, - }; + claims = + isWorkspaceImagePreviewPath(resolved.relativePath) || + isWorkspaceVideoPreviewPath(resolved.relativePath) + ? { + version: 1, + kind: "workspace-file-exact", + workspaceRoot: canonicalWorkspaceRoot, + relativePath: resolved.relativePath, + expiresAt, + } + : { + version: 1, + kind: "workspace-file", + workspaceRoot: canonicalWorkspaceRoot, + baseRelativePath: path.dirname(resolved.relativePath), + expiresAt, + }; fileName = path.basename(resolved.relativePath); break; } @@ -272,6 +333,27 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i fileName = path.basename(attachmentPath); break; } + case "browser-artifact": { + const config = yield* ServerConfig.ServerConfig; + const artifactFileName = normalizeBrowserArtifactFileName(input.resource.fileName); + const artifactPath = + artifactFileName === null + ? null + : yield* resolveBrowserArtifactPath(config.browserArtifactsDir, artifactFileName); + if (artifactFileName === null || artifactPath === null) { + return yield* new AssetBrowserArtifactNotFoundError({ + resource: input.resource, + }); + } + claims = { + version: 1, + kind: "browser-artifact", + fileName: artifactFileName, + expiresAt, + }; + fileName = artifactFileName; + break; + } case "project-favicon": { const workspaceRoot = yield* workspacePaths.normalizeWorkspaceRoot(input.resource.cwd).pipe( Effect.mapError( @@ -388,6 +470,19 @@ export const resolveAsset = Effect.fn("AssetAccess.resolveAsset")(function* ( : null; } + if (claims.kind === "browser-artifact") { + const config = yield* ServerConfig.ServerConfig; + const artifactFileName = normalizeBrowserArtifactFileName(claims.fileName); + if (!artifactFileName) return null; + const artifactPath = yield* resolveBrowserArtifactPath( + config.browserArtifactsDir, + artifactFileName, + ); + return artifactPath !== null + ? ({ kind: "file", path: artifactPath } satisfies ResolvedAsset) + : null; + } + if (claims.kind === "project-favicon") { if (claims.relativePath === null) return null; const faviconPath = yield* resolveCanonicalWorkspaceFileForRequest({ diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 597c75dd91b..233868f3279 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -10,7 +10,20 @@ import { } from "./attachmentPaths.ts"; import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; -const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const ATTACHMENT_FILENAME_EXTENSIONS = [ + ...SAFE_IMAGE_FILE_EXTENSIONS, + ".pdf", + ".mp4", + ".webm", + ".mov", + ".mkv", + ".txt", + ".json", + ".csv", + ".md", + ".zip", + ".bin", +]; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -74,6 +87,20 @@ export function attachmentRelativePath(attachment: ChatAttachment): string { }); return `${attachment.id}${extension}`; } + case "pdf": + return `${attachment.id}.pdf`; + case "video": + return `${attachment.id}${ + attachment.mimeType === "video/mp4" + ? ".mp4" + : attachment.mimeType === "video/webm" + ? ".webm" + : attachment.mimeType === "video/quicktime" + ? ".mov" + : ".bin" + }`; + case "file": + return `${attachment.id}.bin`; } } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5a16e144ee4..c2a56e8070f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -32,7 +32,13 @@ export interface ServerDerivedPaths { readonly settingsPath: string; readonly providerStatusCacheDir: string; readonly worktreesDir: string; + readonly t3WorkDir?: string; readonly attachmentsDir: string; + /** + * Browser evidence artifacts (screenshots/recordings). The desktop app + * writes recordings here too because both derive from the runtime state dir. + */ + readonly browserArtifactsDir: string; readonly logsDir: string; readonly serverLogPath: string; readonly serverTracePath: string; @@ -105,6 +111,7 @@ export const deriveServerPaths = Effect.fn(function* ( ); const dbPath = join(stateDir, "state.sqlite"); const attachmentsDir = join(stateDir, "attachments"); + const browserArtifactsDir = join(stateDir, "browser-artifacts"); const logsDir = join(stateDir, "logs"); const providerLogsDir = join(logsDir, "provider"); const providerStatusCacheDir = join(baseDir, "caches"); @@ -115,7 +122,9 @@ export const deriveServerPaths = Effect.fn(function* ( settingsPath: join(stateDir, "settings.json"), providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), + t3WorkDir: join(baseDir, "t3-work"), attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), @@ -140,7 +149,11 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server fs.makeDirectory(derivedPaths.providerLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.terminalLogsDir, { recursive: true }), fs.makeDirectory(derivedPaths.attachmentsDir, { recursive: true }), + fs.makeDirectory(derivedPaths.browserArtifactsDir, { recursive: true }), fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }), + ...(derivedPaths.t3WorkDir === undefined + ? [] + : [fs.makeDirectory(derivedPaths.t3WorkDir, { recursive: true })]), fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }), fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }), fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }), diff --git a/apps/server/src/hermes/HermesConnectionSecurity.test.ts b/apps/server/src/hermes/HermesConnectionSecurity.test.ts new file mode 100644 index 00000000000..7a0118f59cc --- /dev/null +++ b/apps/server/src/hermes/HermesConnectionSecurity.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, +} from "./HermesConnectionSecurity.ts"; + +const fingerprint = "ab:".repeat(31) + "ab"; + +const assess = (overrides: Partial[0]> = {}) => + assessHermesConnectionSecurity({ + endpoint: "ws://127.0.0.1:9119/api/ws", + gatewayToken: "local-token", + remoteGloballyEnabled: false, + remoteInstanceEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + ...overrides, + }); + +describe("Hermes connection security", () => { + it("preserves authenticated loopback ws behavior", () => { + expect(assess()).toMatchObject({ + status: "ready", + scope: "loopback", + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "local-token", + }); + }); + + it.each([ + "http://gateway.example.com/api/ws", + "https://gateway.example.com/api/ws", + "ws://gateway.example.com/api/ws", + "wss://user:secret@gateway.example.com/api/ws", + "wss://gateway.example.com/api/ws?TOKEN=secret", + ])("rejects an insecure or credential-bearing remote endpoint: %s", (endpoint) => { + expect( + assess({ + endpoint, + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "invalid_endpoint" }); + }); + + it("requires independent global and instance remote opt-ins", () => { + expect(assess({ endpoint: "wss://gateway.example.com/api/ws" })).toMatchObject({ + status: "blocked", + code: "remote_disabled", + }); + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "remote_instance_disabled" }); + }); + + it("requires dedicated pairing and explicit certificate trust material", () => { + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + }), + ).toMatchObject({ status: "blocked", code: "remote_pairing_required" }); + + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "local-token", + }), + ).toMatchObject({ status: "blocked", code: "remote_credential_reuse" }); + + expect( + assess({ + endpoint: "wss://gateway.example.com/api/ws", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: "not-a-fingerprint", + }), + ).toMatchObject({ status: "blocked", code: "remote_trust_required" }); + }); + + it("reports remote unsupported before creating a transport even when fully configured", () => { + const result = assess({ + endpoint: "wss://gateway.example.com/api/ws?tenant=private", + remoteGloballyEnabled: true, + remoteInstanceEnabled: true, + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: fingerprint, + }); + + expect(result).toMatchObject({ + status: "unsupported", + code: "remote_verification_unsupported", + diagnosticEndpoint: "wss://gateway.example.com/api/ws?tenant=%3Credacted%3E", + }); + expect(JSON.stringify(result)).not.toContain("private"); + expect(JSON.stringify(result)).not.toContain("dedicated-pairing-token"); + expect(JSON.stringify(result)).not.toContain(fingerprint); + }); + + it("sanitizes all query values, userinfo, and fragments in diagnostics", () => { + const sanitized = sanitizeHermesEndpoint( + "wss://user:password@gateway.example.com/api/ws?token=secret&workspace=private#fragment", + ); + expect(sanitized).toBe( + "wss://gateway.example.com/api/ws?token=%3Credacted%3E&workspace=%3Credacted%3E", + ); + }); +}); diff --git a/apps/server/src/hermes/HermesConnectionSecurity.ts b/apps/server/src/hermes/HermesConnectionSecurity.ts new file mode 100644 index 00000000000..c5a8ccb6e2f --- /dev/null +++ b/apps/server/src/hermes/HermesConnectionSecurity.ts @@ -0,0 +1,189 @@ +export const HERMES_REMOTE_PAIRING_TOKEN_ENV = "HERMES_REMOTE_PAIRING_TOKEN"; +export const HERMES_REMOTE_TLS_CERT_SHA256_ENV = "HERMES_REMOTE_TLS_CERT_SHA256"; + +export type HermesEndpointScope = "loopback" | "remote"; + +export type HermesConnectionSecurityCode = + | "invalid_endpoint" + | "remote_disabled" + | "remote_instance_disabled" + | "remote_pairing_required" + | "remote_trust_required" + | "remote_credential_reuse" + | "remote_verification_unsupported"; + +export type HermesConnectionSecurityAssessment = + | { + readonly status: "ready"; + readonly scope: "loopback"; + readonly endpoint: string; + readonly diagnosticEndpoint: string; + readonly authToken: string; + } + | { + readonly status: "blocked" | "unsupported"; + readonly scope: HermesEndpointScope | undefined; + readonly code: HermesConnectionSecurityCode; + readonly diagnosticEndpoint: string; + readonly message: string; + }; + +export interface HermesConnectionSecurityInput { + readonly endpoint: string; + readonly gatewayToken: string | undefined; + readonly remoteGloballyEnabled: boolean; + readonly remoteInstanceEnabled: boolean; + readonly remotePairingToken: string | undefined; + readonly remoteTlsCertificateSha256: string | undefined; +} + +const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]); +const SHA256_FINGERPRINT = /^(?:[0-9a-f]{64}|(?:[0-9a-f]{2}:){31}[0-9a-f]{2})$/iu; + +export function sanitizeHermesEndpoint(endpoint: string): string { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + return ""; + } + parsed.username = ""; + parsed.password = ""; + parsed.hash = ""; + for (const key of new Set(parsed.searchParams.keys())) { + parsed.searchParams.set(key, ""); + } + return parsed.toString(); +} + +export function hermesEndpointScope(endpoint: string): HermesEndpointScope | undefined { + try { + return LOOPBACK_HOSTS.has(new URL(endpoint).hostname) ? "loopback" : "remote"; + } catch { + return undefined; + } +} + +export function isRemoteHermesEndpoint(endpoint: string): boolean { + return hermesEndpointScope(endpoint) === "remote"; +} + +export function assessHermesConnectionSecurity( + input: HermesConnectionSecurityInput, +): HermesConnectionSecurityAssessment { + const diagnosticEndpoint = sanitizeHermesEndpoint(input.endpoint); + let endpoint: URL; + try { + endpoint = new URL(input.endpoint); + } catch { + return blocked( + "invalid_endpoint", + undefined, + diagnosticEndpoint, + "Hermes endpoint must be a valid WebSocket URL.", + ); + } + + const scope: HermesEndpointScope = LOOPBACK_HOSTS.has(endpoint.hostname) ? "loopback" : "remote"; + const hasQueryCredential = [...endpoint.searchParams.keys()].some( + (key) => key.toLowerCase() === "token", + ); + if ( + endpoint.username || + endpoint.password || + endpoint.hash || + hasQueryCredential || + (scope === "loopback" ? endpoint.protocol !== "ws:" : endpoint.protocol !== "wss:") + ) { + return blocked( + "invalid_endpoint", + scope, + diagnosticEndpoint, + scope === "loopback" + ? "Loopback Hermes endpoints must use credential-free ws://." + : "Remote Hermes endpoints must use credential-free wss://.", + ); + } + + if (scope === "loopback") { + const gatewayToken = input.gatewayToken?.trim(); + if (!gatewayToken) { + return blocked( + "invalid_endpoint", + scope, + diagnosticEndpoint, + "Loopback Hermes requires a sensitive HERMES_GATEWAY_TOKEN.", + ); + } + return { + status: "ready", + scope, + endpoint: endpoint.toString(), + diagnosticEndpoint, + authToken: gatewayToken, + }; + } + + if (!input.remoteGloballyEnabled) { + return blocked( + "remote_disabled", + scope, + diagnosticEndpoint, + "Remote Hermes is disabled by the independent server kill switch.", + ); + } + if (!input.remoteInstanceEnabled) { + return blocked( + "remote_instance_disabled", + scope, + diagnosticEndpoint, + "This Hermes instance has not explicitly enabled remote access.", + ); + } + + const pairingToken = input.remotePairingToken?.trim(); + if (!pairingToken) { + return blocked( + "remote_pairing_required", + scope, + diagnosticEndpoint, + `Remote Hermes requires a dedicated sensitive ${HERMES_REMOTE_PAIRING_TOKEN_ENV}.`, + ); + } + if (pairingToken === input.gatewayToken?.trim()) { + return blocked( + "remote_credential_reuse", + scope, + diagnosticEndpoint, + "Remote Hermes pairing material must be distinct from the local gateway credential.", + ); + } + + const fingerprint = input.remoteTlsCertificateSha256?.trim(); + if (!fingerprint || !SHA256_FINGERPRINT.test(fingerprint)) { + return blocked( + "remote_trust_required", + scope, + diagnosticEndpoint, + `Remote Hermes requires an explicit SHA-256 certificate fingerprint in ${HERMES_REMOTE_TLS_CERT_SHA256_ENV}.`, + ); + } + + return { + status: "unsupported", + scope, + code: "remote_verification_unsupported", + diagnosticEndpoint, + message: + "Remote Hermes is configured but unsupported: the current gateway/WebSocket transport cannot prove scoped pairing or verify the configured TLS certificate fingerprint.", + }; +} + +function blocked( + code: HermesConnectionSecurityCode, + scope: HermesEndpointScope | undefined, + diagnosticEndpoint: string, + message: string, +): HermesConnectionSecurityAssessment { + return { status: "blocked", scope, code, diagnosticEndpoint, message }; +} diff --git a/apps/server/src/hermes/HermesCron.test.ts b/apps/server/src/hermes/HermesCron.test.ts new file mode 100644 index 00000000000..401c4466cb1 --- /dev/null +++ b/apps/server/src/hermes/HermesCron.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { projectHermesCronCapabilities, projectHermesCronJob } from "./HermesCron.ts"; + +describe("HermesCron projection", () => { + it("keeps pinned legacy gateways limited to evidenced operations", () => { + expect( + projectHermesCronCapabilities({ + status: "legacy", + protocol: null, + inventory: null, + capabilities: ["cron.read", "cron.manage"], + reason: "legacy", + }), + ).toEqual({ + inventory: true, + create: true, + edit: false, + pause: false, + resume: false, + delete: true, + runNow: false, + }); + }); + + it("enables extension mutations only from advertised granular operations", () => { + expect( + projectHermesCronCapabilities({ + status: "supported", + protocol: { major: 1, minor: 2 }, + inventory: { + "cron.read": "supported", + "cron.manage": { operations: ["add", "remove", "update", "pause", "resume", "run"] }, + }, + capabilities: ["cron.read", "cron.manage"], + reason: "supported", + }), + ).toEqual({ + inventory: true, + create: true, + edit: true, + pause: true, + resume: true, + delete: true, + runNow: true, + }); + }); + + it("projects provenance and deterministically deduplicates cron executions", () => { + const job = projectHermesCronJob( + "hermes_work", + "work", + { + id: "job-1", + name: "Daily check", + schedule: "0 9 * * *", + prompt: "Check status", + enabled: true, + executions: [ + { run_id: "run-1", cursor: 4, status: "complete", started_at: "2026-01-01" }, + { run_id: "run-1", cursor: 4, status: "complete", started_at: "2026-01-01" }, + { status: "failed", started_at: "2026-01-02" }, + ], + }, + 0, + ); + + expect(job.identity).toBe("job-1"); + expect(job.executions).toHaveLength(2); + expect(job.executions[0]).toMatchObject({ + dedupeKey: "hermes-run:run-1", + provenance: { + scheduler: "hermes", + providerInstanceId: "hermes_work", + profileKey: "work", + jobIdentity: "job-1", + upstreamRunId: "run-1", + upstreamCursor: 4, + identityStrength: "upstream", + }, + }); + expect(job.executions[1]?.dedupeKey).toMatch(/^hermes-derived:/u); + }); + + it("marks jobs without upstream id or name as unaddressable", () => { + const first = projectHermesCronJob( + "hermes", + "default", + { schedule: "0 0 * * *", prompt: "x" }, + 2, + ); + const second = projectHermesCronJob( + "hermes", + "default", + { schedule: "0 0 * * *", prompt: "x" }, + 2, + ); + expect(first.identityStrength).toBe("missing"); + expect(first.identity).toBe(second.identity); + }); +}); diff --git a/apps/server/src/hermes/HermesCron.ts b/apps/server/src/hermes/HermesCron.ts new file mode 100644 index 00000000000..859f9eaf9f6 --- /dev/null +++ b/apps/server/src/hermes/HermesCron.ts @@ -0,0 +1,520 @@ +import { + HermesCronError, + HermesSettings, + type HermesCronCapabilities, + type HermesCronExecution, + type HermesCronJob, + type HermesCronListResult, + type HermesCronMutationInput, + type HermesCronMutationResponse, + type HermesCronProviderProjection, + type HermesGatewayCompatibility, + type HermesGatewayCronJob, + type HermesGatewayCronListResult, + type HermesGatewayCronMutationResult, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + HermesGatewayClient, + HermesGatewayMutationIndeterminateError, + type HermesGatewayMutationOptions, + type HermesGatewayReadOptions, +} from "./HermesGatewayClient.ts"; + +interface HermesCronGatewayClient { + readonly compatibility: HermesGatewayCompatibility | undefined; + connect(): Promise; + hasCapability(capability: string): boolean; + listCronJobs( + options?: Omit, + ): Promise; + manageCron( + params: Record, + options: Omit, + ): Promise; + close(): void; +} + +interface HermesCronProviderConfig { + readonly providerInstanceId: string; + readonly displayName: string; + readonly profileKey: string; + readonly endpoint: string; + readonly token: string; +} + +export interface HermesCronOptions { + readonly clientFactory?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => HermesCronGatewayClient; +} + +export interface HermesCronShape { + readonly list: () => Effect.Effect; + readonly mutate: ( + input: HermesCronMutationInput, + ) => Effect.Effect; +} + +export class HermesCron extends Context.Service()( + "t3/hermes/HermesCron", +) {} + +const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings); +const isHermesCronError = Schema.is(HermesCronError); +const digest = (value: unknown): string => + NodeCrypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); + +const record = (value: unknown): Record | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +const string = (value: unknown): string | undefined => + typeof value === "string" && value.length > 0 ? value : undefined; +const stringOrNumber = (value: unknown): string | number | undefined => + typeof value === "string" || typeof value === "number" ? value : undefined; + +function actionInventory(compatibility: HermesGatewayCompatibility): ReadonlySet { + const actions = new Set(); + const inventory = record(compatibility.inventory); + const manage = record(inventory?.["cron.manage"]); + const candidates = [manage?.actions, manage?.operations]; + for (const candidate of candidates) { + if (!Array.isArray(candidate)) continue; + for (const action of candidate) { + if (typeof action === "string") actions.add(action.toLowerCase()); + } + } + return actions; +} + +export function projectHermesCronCapabilities( + compatibility: HermesGatewayCompatibility, +): HermesCronCapabilities { + const capabilities = new Set(compatibility.capabilities); + const actions = actionInventory(compatibility); + const supports = (operation: string, ...aliases: ReadonlyArray) => + aliases.some((capability) => capabilities.has(capability)) || + actions.has(operation) || + aliases.some((alias) => actions.has(alias.replace(/^cron[._]/u, ""))); + const legacy = compatibility.status === "legacy"; + return { + inventory: legacy || capabilities.has("cron.read"), + create: legacy || capabilities.has("cron.manage") || supports("add", "cron.create"), + edit: supports("update", "cron.update", "cron.edit"), + pause: supports("pause", "cron.pause"), + resume: supports("resume", "cron.resume"), + delete: legacy || capabilities.has("cron.manage") || supports("remove", "cron.delete"), + runNow: supports("run", "cron.run_now", "cron.run-now"), + }; +} + +function projectExecution( + input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly jobIdentity: string; + }, + value: unknown, +): HermesCronExecution | null { + const row = record(value); + if (!row) return null; + const upstreamRunId = string(row.run_id) ?? string(row.id) ?? null; + const upstreamCursor = stringOrNumber(row.cursor) ?? stringOrNumber(row.sequence) ?? null; + const startedAt = + stringOrNumber(row.started_at) ?? + stringOrNumber(row.startedAt) ?? + stringOrNumber(row.created_at) ?? + null; + const completedAt = + stringOrNumber(row.completed_at) ?? + stringOrNumber(row.completedAt) ?? + stringOrNumber(row.finished_at) ?? + null; + const status = string(row.status) ?? null; + const stableFields = { + jobIdentity: input.jobIdentity, + upstreamRunId, + upstreamCursor, + startedAt, + completedAt, + status, + }; + return { + dedupeKey: upstreamRunId + ? `hermes-run:${upstreamRunId}` + : `hermes-derived:${digest(stableFields)}`, + status, + startedAt, + completedAt, + provenance: { + scheduler: "hermes", + providerInstanceId: input.providerInstanceId, + profileKey: input.profileKey, + jobIdentity: input.jobIdentity, + upstreamRunId, + upstreamCursor, + identityStrength: upstreamRunId || upstreamCursor !== null ? "upstream" : "derived", + }, + }; +} + +export function projectHermesCronJob( + providerInstanceId: string, + profileKey: string, + job: HermesGatewayCronJob, + ordinal: number, +): HermesCronJob { + const id = job.id?.trim() || null; + const name = job.name?.trim() || null; + const identity = id ?? name ?? `unaddressable:${digest([job.schedule, job.prompt, ordinal])}`; + const executionRows = job.executions ?? job.runs ?? job.history ?? []; + const deduped = new Map(); + for (const value of executionRows) { + const projected = projectExecution( + { providerInstanceId, profileKey, jobIdentity: identity }, + value, + ); + if (projected) deduped.set(projected.dedupeKey, projected); + } + return { + identity, + identityStrength: id ? "id" : name ? "name" : "missing", + id, + name, + schedule: job.schedule ?? null, + prompt: job.prompt ?? null, + enabled: job.enabled ?? (job.paused === undefined ? null : !job.paused), + nextRunAt: job.next_run_at ?? null, + lastRunAt: job.last_run_at ?? null, + executions: [...deduped.values()], + }; +} + +function projectProvider(input: { + readonly config: HermesCronProviderConfig; + readonly compatibility: HermesGatewayCompatibility; + readonly result: HermesGatewayCronListResult; +}): HermesCronProviderProjection { + const diagnostics: string[] = []; + const jobs = input.result.jobs.map((job, index) => + projectHermesCronJob(input.config.providerInstanceId, input.config.profileKey, job, index), + ); + const missingIdentity = jobs.filter((job) => job.identityStrength === "missing").length; + if (missingIdentity > 0) { + diagnostics.push( + `${missingIdentity} cron job(s) have no upstream id or name and cannot be safely mutated.`, + ); + } + if (!jobs.some((job) => job.executions.some((run) => run.provenance.upstreamCursor !== null))) { + diagnostics.push("Hermes does not expose a durable global cron execution cursor."); + } + if (input.compatibility.status === "legacy") { + diagnostics.push( + "Gateway capabilities are not negotiated; only pinned list/add/remove operations are enabled.", + ); + } + return { + providerInstanceId: input.config.providerInstanceId, + displayName: input.config.displayName, + profileKey: input.config.profileKey, + status: "ready", + protocolClassification: input.compatibility.status, + capabilities: projectHermesCronCapabilities(input.compatibility), + jobs, + diagnostics, + }; +} + +const unavailableProjection = ( + providerInstanceId: string, + displayName: string, + profileKey: string, + diagnostic: string, + status: "unavailable" | "error" = "unavailable", +): HermesCronProviderProjection => ({ + providerInstanceId, + displayName, + profileKey, + status, + protocolClassification: null, + capabilities: { + inventory: false, + create: false, + edit: false, + pause: false, + resume: false, + delete: false, + runNow: false, + }, + jobs: [], + diagnostics: [diagnostic], +}); + +const tokenFromEnvironment = ( + environment: ReadonlyArray<{ + readonly name: string; + readonly value: string; + readonly sensitive: boolean; + }>, +) => + environment.find( + (variable) => + variable.name === "HERMES_GATEWAY_TOKEN" && + variable.sensitive && + variable.value.trim().length > 0, + )?.value; + +function mutationCapability( + capabilities: HermesCronCapabilities, + operation: HermesCronMutationInput["operation"], +): boolean { + switch (operation) { + case "create": + return capabilities.create; + case "edit": + return capabilities.edit; + case "pause": + return capabilities.pause; + case "resume": + return capabilities.resume; + case "delete": + return capabilities.delete; + case "run_now": + return capabilities.runNow; + } +} + +function mutationParams(input: HermesCronMutationInput): Record { + switch (input.operation) { + case "create": + return { action: "add", name: input.name, schedule: input.schedule, prompt: input.prompt }; + case "edit": + return { + action: "update", + name: input.jobIdentity, + ...(input.name === undefined ? {} : { new_name: input.name }), + ...(input.schedule === undefined ? {} : { schedule: input.schedule }), + ...(input.prompt === undefined ? {} : { prompt: input.prompt }), + }; + case "pause": + return { action: "pause", name: input.jobIdentity }; + case "resume": + return { action: "resume", name: input.jobIdentity }; + case "delete": + return { action: "remove", name: input.jobIdentity }; + case "run_now": + return { action: "run", name: input.jobIdentity }; + } +} + +export const makeHermesCron = Effect.fn("HermesCron.make")(function* ( + options: HermesCronOptions = {}, +) { + const settingsService = yield* ServerSettings.ServerSettingsService; + const clientFactory = + options.clientFactory ?? + ((input: { readonly endpoint: string; readonly authToken: string }) => + new HermesGatewayClient(input)); + + const configuredProviders = Effect.fn("HermesCron.configuredProviders")(function* () { + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + () => + new HermesCronError({ + code: "gateway_error", + message: "Could not read Hermes provider settings.", + }), + ), + ); + const instances = deriveProviderInstanceConfigMap(settings); + const ready: HermesCronProviderConfig[] = []; + const unavailable: HermesCronProviderProjection[] = []; + for (const [providerInstanceId, instance] of Object.entries(instances)) { + if (instance.driver !== "hermes") continue; + let config: HermesSettings; + try { + config = decodeHermesSettings(instance.config ?? {}); + } catch { + unavailable.push( + unavailableProjection( + providerInstanceId, + instance.displayName ?? providerInstanceId, + "unknown", + "Hermes provider settings are invalid.", + ), + ); + continue; + } + const displayName = instance.displayName ?? providerInstanceId; + const token = tokenFromEnvironment(instance.environment ?? []); + if (instance.enabled !== true || !settings.enableHermes) { + unavailable.push( + unavailableProjection( + providerInstanceId, + displayName, + config.profileKey, + "Hermes is disabled.", + ), + ); + } else if (!config.endpoint || !token) { + unavailable.push( + unavailableProjection( + providerInstanceId, + displayName, + config.profileKey, + "Hermes gateway endpoint or sensitive token is not configured.", + ), + ); + } else { + ready.push({ + providerInstanceId, + displayName, + profileKey: config.profileKey, + endpoint: config.endpoint, + token, + }); + } + } + return { ready, unavailable }; + }); + + const loadProvider = Effect.fn("HermesCron.loadProvider")(function* ( + config: HermesCronProviderConfig, + ) { + const client = clientFactory({ endpoint: config.endpoint, authToken: config.token }); + return yield* Effect.tryPromise({ + try: async () => { + try { + const compatibility = await client.connect(); + const capabilities = projectHermesCronCapabilities(compatibility); + if (!capabilities.inventory) { + return unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + "Gateway does not advertise cron.read.", + ); + } + const result = await client.listCronJobs(); + return projectProvider({ config, compatibility, result }); + } finally { + client.close(); + } + }, + catch: () => + new HermesCronError({ + code: "gateway_error", + providerInstanceId: config.providerInstanceId, + message: "Could not read native Hermes cron inventory.", + }), + }).pipe( + Effect.catch((error) => + Effect.succeed( + unavailableProjection( + config.providerInstanceId, + config.displayName, + config.profileKey, + error.message, + "error", + ), + ), + ), + ); + }); + + const list: HermesCronShape["list"] = Effect.fn("HermesCron.list")(function* () { + const configured = yield* configuredProviders(); + const available = yield* Effect.forEach(configured.ready, loadProvider, { concurrency: 4 }); + return { providers: [...available, ...configured.unavailable] }; + }); + + const mutate: HermesCronShape["mutate"] = Effect.fn("HermesCron.mutate")(function* (input) { + if ( + !input.operationId.trim() || + (input.operation === "create" && + (!input.name?.trim() || !input.schedule?.trim() || !input.prompt?.trim())) || + (input.operation !== "create" && !input.jobIdentity?.trim()) + ) { + return yield* new HermesCronError({ + code: "invalid_input", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Cron mutation is missing required identity or job fields.", + }); + } + const configured = yield* configuredProviders(); + const config = configured.ready.find( + (candidate) => candidate.providerInstanceId === input.providerInstanceId, + ); + if (!config) { + const known = configured.unavailable.some( + (candidate) => candidate.providerInstanceId === input.providerInstanceId, + ); + return yield* new HermesCronError({ + code: known ? "provider_unavailable" : "provider_not_found", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: known ? "Hermes provider is unavailable." : "Hermes provider was not found.", + }); + } + + const client = clientFactory({ endpoint: config.endpoint, authToken: config.token }); + return yield* Effect.tryPromise({ + try: async () => { + try { + const compatibility = await client.connect(); + const capabilities = projectHermesCronCapabilities(compatibility); + if (!mutationCapability(capabilities, input.operation)) { + throw new HermesCronError({ + code: "unsupported_operation", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: `Hermes gateway does not support cron ${input.operation}.`, + }); + } + const result = await client.manageCron(mutationParams(input), { + operationId: input.operationId, + }); + const inventory = await client.listCronJobs(); + return { + provider: projectProvider({ config, compatibility, result: inventory }), + upstreamJobId: result.job_id ?? result.job?.id ?? null, + upstreamRunId: result.run_id ?? null, + }; + } finally { + client.close(); + } + }, + catch: (cause) => { + if (isHermesCronError(cause)) return cause; + if (cause instanceof HermesGatewayMutationIndeterminateError) { + return new HermesCronError({ + code: "indeterminate", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Hermes cron mutation outcome is indeterminate; automatic replay is disabled.", + }); + } + return new HermesCronError({ + code: "gateway_error", + providerInstanceId: input.providerInstanceId, + operation: input.operation, + message: "Hermes cron gateway operation failed.", + }); + }, + }); + }); + + return HermesCron.of({ list, mutate }); +}); + +export const layer = Layer.effect(HermesCron, makeHermesCron()); diff --git a/apps/server/src/hermes/HermesGatewayClient.test.ts b/apps/server/src/hermes/HermesGatewayClient.test.ts new file mode 100644 index 00000000000..513082ae131 --- /dev/null +++ b/apps/server/src/hermes/HermesGatewayClient.test.ts @@ -0,0 +1,1102 @@ +// @effect-diagnostics globalDate:off globalTimers:off - Transport tests use short deterministic waits. +import { describe, expect, it } from "vite-plus/test"; + +import { + HermesGatewayCapabilityError, + HermesGatewayClient, + HermesGatewayConfigurationError, + HermesGatewayMutationIndeterminateError, + HermesGatewayMutationsBlockedError, + classifyHermesGatewayReady, + type HermesGatewayLogEvent, + type HermesGatewaySocket, + type HermesGatewaySocketEvent, +} from "./HermesGatewayClient.ts"; + +class FakeSocket implements HermesGatewaySocket { + readyState = 0; + readonly sent: string[] = []; + readonly closeCalls: Array<{ readonly code: number; readonly reason?: string }> = []; + readonly endpoint: string; + private readonly listeners = new Map< + "open" | "message" | "close" | "error", + Array<{ readonly listener: (event: HermesGatewaySocketEvent) => void; readonly once: boolean }> + >(); + + constructor(endpoint: string) { + this.endpoint = endpoint; + } + + send(data: string): void { + if (this.readyState !== 1) throw new Error("socket is not open"); + this.sent.push(data); + } + + close(code = 1000, reason?: string): void { + this.closeCalls.push({ code, ...(reason === undefined ? {} : { reason }) }); + if (this.readyState === 3) return; + this.readyState = 3; + this.emit("close", { code }); + } + + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: HermesGatewaySocketEvent) => void, + options?: { readonly once?: boolean }, + ): void { + const entries = this.listeners.get(type) ?? []; + entries.push({ listener, once: options?.once === true }); + this.listeners.set(type, entries); + } + + open(): void { + this.readyState = 1; + this.emit("open", {}); + } + + receive(frame: unknown): void { + this.emit("message", { data: JSON.stringify(frame) }); + } + + fail(): void { + this.emit("error", {}); + } + + private emit( + type: "open" | "message" | "close" | "error", + event: HermesGatewaySocketEvent, + ): void { + const entries = [...(this.listeners.get(type) ?? [])]; + this.listeners.set( + type, + entries.filter((entry) => !entry.once), + ); + for (const entry of entries) entry.listener(event); + } +} + +class FakeSocketFactory { + readonly sockets: FakeSocket[] = []; + + readonly create = (endpoint: string): FakeSocket => { + const socket = new FakeSocket(endpoint); + this.sockets.push(socket); + return socket; + }; +} + +const legacyReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { skin: "default" }, + }, +} as const; + +const stableMutationReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: { + "mutation.stable_ids": "durable-v1", + "session.lifecycle": "supported", + "turn.interrupt": "supported", + "turn.prompt": "supported", + }, + }, + }, + }, +} as const; + +const fullyNegotiatedReady = { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: Object.fromEntries( + [ + "session.lifecycle", + "session.history", + "session.title", + "session.branch.latest", + "turn.prompt", + "turn.interrupt", + "commands.catalog", + "models.inventory", + "reasoning.effective_state", + "attachments.image", + "attachments.file", + "attachments.pdf", + "cron.read", + "cron.manage", + "profile.import", + ].map((capability) => [capability, "supported"]), + ), + }, + }, + }, +} as const; + +function success(id: string, result: unknown): unknown { + return { jsonrpc: "2.0", id, result }; +} + +function sentFrames(socket: FakeSocket): Array<{ + readonly id: string; + readonly method: string; + readonly params: Record; +}> { + return socket.sent.map((frame) => JSON.parse(frame)); +} + +async function openClient( + factory: FakeSocketFactory, + options: Partial[0]> = {}, + readyFrame: unknown = fullyNegotiatedReady, +): Promise<{ readonly client: HermesGatewayClient; readonly socket: FakeSocket }> { + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + ...options, + }); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive(readyFrame); + await connecting; + return { client, socket }; +} + +describe("HermesGatewayClient transport security", () => { + it("registers and revokes an ephemeral session MCP lease", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient( + factory, + {}, + { + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 0, + capabilities: { + session_mcp: "ephemeral-lease-v1", + }, + }, + }, + }, + }, + ); + + const replacing = client.replaceSessionMcp( + { + session_id: "live-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-token" }, + }, + }, + }, + { operationId: "mcp-replace" }, + ); + let frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "session.mcp.replace", + params: { + session_id: "live-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-token" }, + }, + }, + }, + }); + socket.receive( + success(frame.id, { + lease_id: "lease-1", + generation: 1, + servers: [{ name: "t3-code", runtime_name: "tui_session_lease_t3_code" }], + tool_names: ["mcp__tui_session_lease_t3_code__delegate_task"], + scope: { session_id: "live-1", session_key: "stored-1" }, + persisted: false, + history_recorded: false, + }), + ); + await expect(replacing).resolves.toMatchObject({ lease_id: "lease-1", generation: 1 }); + + const revoking = client.revokeSessionMcp("live-1", { operationId: "mcp-revoke" }); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "session.mcp.revoke", + params: { session_id: "live-1" }, + }); + socket.receive( + success(frame.id, { + revoked: true, + lease_id: "lease-1", + persisted: false, + }), + ); + await expect(revoking).resolves.toEqual({ + revoked: true, + lease_id: "lease-1", + persisted: false, + }); + client.close(); + }); + + it("requires authenticated loopback ws by default and never logs credentials", async () => { + expect( + () => + new HermesGatewayClient({ + endpoint: "ws://example.com/api/ws", + authToken: "private-token", + }), + ).toThrow(HermesGatewayConfigurationError); + expect( + () => + new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws", + authToken: "", + }), + ).toThrow(HermesGatewayConfigurationError); + + const logs: HermesGatewayLogEvent[] = []; + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + logger: (event) => logs.push(event), + }); + const request = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "PRIVATE PROMPT" }, + { operationId: "operation-1" }, + ); + const frame = sentFrames(socket)[0]!; + socket.receive(success(frame.id, { text: "PRIVATE RESULT" })); + await request; + + const serializedLogs = JSON.stringify(logs); + expect(serializedLogs).not.toContain("private-token"); + expect(serializedLogs).not.toContain("PRIVATE PROMPT"); + expect(serializedLogs).not.toContain("PRIVATE RESULT"); + expect(serializedLogs).toContain("%3Credacted%3E"); + client.close(); + }); +}); + +describe("HermesGatewayClient protocol and ordering", () => { + it("does not manufacture optional or mutating capabilities for legacy gateways", () => { + expect(classifyHermesGatewayReady(legacyReady).capabilities).toEqual([]); + }); + + it("publishes negotiated version, capability, and reconnect health", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws?label=private-value", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + const health: unknown[] = []; + client.onHealthChange((snapshot) => health.push(snapshot)); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive({ + ...stableMutationReady, + params: { + ...stableMutationReady.params, + payload: { + ...stableMutationReady.params.payload, + server_version: "1.3.0", + }, + }, + }); + await connecting; + + expect(client.health).toMatchObject({ + state: "ready", + reconnectAttempt: 0, + protocolStatus: "supported", + protocolMajor: 1, + protocolMinor: 0, + serverVersion: "1.3.0", + writesBlocked: false, + indeterminateMutationCount: 0, + }); + expect(client.health.capabilities).toEqual([ + "mutation.stable_ids", + "session.lifecycle", + "turn.interrupt", + "turn.prompt", + ]); + expect(health).toHaveLength(3); + client.close(); + }); + + it("uses the pinned cron.manage list/add/remove wire protocol", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const listing = client.listCronJobs(); + let frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ method: "cron.manage", params: { action: "list" } }); + socket.receive(success(frame.id, { success: true, jobs: [] })); + await expect(listing).resolves.toEqual({ success: true, jobs: [] }); + + const adding = client.manageCron( + { action: "add", name: "job", schedule: "0 0 * * *", prompt: "check" }, + { operationId: "cron-add-1" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "cron.manage", + params: { action: "add", name: "job", schedule: "0 0 * * *", prompt: "check" }, + }); + socket.receive(success(frame.id, { success: true, job_id: "job-1" })); + await expect(adding).resolves.toEqual({ success: true, job_id: "job-1" }); + client.close(); + }); + + it("correlates out-of-order responses while serializing events in wire order", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const observed: string[] = []; + let releaseFirst!: () => void; + const firstMayFinish = new Promise((resolve) => { + releaseFirst = resolve; + }); + client.onEvent(async (event) => { + observed.push(`start:${event.sessionSequence}:${event.frame.params.type}`); + if (event.frame.params.type === "message.delta") await firstMayFinish; + observed.push(`end:${event.sessionSequence}:${event.frame.params.type}`); + }); + + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "message.delta", + session_id: "session-1", + payload: { text: "first" }, + }, + }); + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "message.complete", + session_id: "session-1", + payload: { text: "second" }, + }, + }); + await eventually(() => observed.length === 1); + expect(observed).toEqual(["start:1:message.delta"]); + releaseFirst(); + await eventually(() => observed.length === 4); + expect(observed).toEqual([ + "start:1:message.delta", + "end:1:message.delta", + "start:2:message.complete", + "end:2:message.complete", + ]); + + const sessions = client.read("session.list", {}); + const history = client.read("session.history", { session_id: "session-1" }); + const frames = sentFrames(socket); + socket.receive(success(frames[1]!.id, { count: 3 })); + socket.receive(success(frames[0]!.id, { sessions: [] })); + await expect(history).resolves.toEqual({ count: 3 }); + await expect(sessions).resolves.toEqual({ sessions: [] }); + client.close(); + }); + + it("preserves negotiated event identities and rejects unsupported protocol majors", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://localhost:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + const events: unknown[] = []; + client.onEvent((event) => { + events.push(event); + }); + const connecting = client.connect(); + await Promise.resolve(); + const socket = factory.sockets[0]!; + socket.open(); + await Promise.resolve(); + socket.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 1, + minor: 4, + build_revision: "upstream-revision", + capabilities: { + version: "1", + "session.lifecycle": "supported", + "event.stable_ids": "supported", + "attachments.pdf": "unsupported", + branching: { mode: "latest", stable_boundaries: false }, + }, + }, + }, + event_id: "ready-event", + event_sequence: 7, + emitted_at: "2026-07-24T00:00:00Z", + session_key: "durable-1", + run_id: "run-1", + message_id: "message-1", + }, + }); + const compatibility = await connecting; + expect(compatibility.status).toBe("supported"); + expect(client.hasCapability("event.stable_ids")).toBe(true); + expect(client.hasCapability("attachments.pdf")).toBe(false); + expect(client.hasCapability("branching")).toBe(true); + expect(compatibility.inventory).toMatchObject({ + version: "1", + branching: { mode: "latest", stable_boundaries: false }, + }); + await eventually(() => events.length === 1); + expect(events[0]).toMatchObject({ + eventId: "ready-event", + eventSequence: 7, + cursor: 7, + emittedAt: "2026-07-24T00:00:00Z", + sessionKey: "durable-1", + runId: "run-1", + messageId: "message-1", + }); + client.close(); + + const rejectedFactory = new FakeSocketFactory(); + const rejected = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: rejectedFactory.create, + reconnect: { maxAttempts: 0 }, + }); + const rejection = rejected.connect(); + await Promise.resolve(); + rejectedFactory.sockets[0]!.open(); + await Promise.resolve(); + rejectedFactory.sockets[0]!.receive({ + jsonrpc: "2.0", + method: "event", + params: { + type: "gateway.ready", + payload: { + protocol: { + major: 2, + minor: 0, + build_revision: "future", + capabilities: { + version: "1", + "session.lifecycle": "supported", + }, + }, + }, + }, + }); + await expect(rejection).rejects.toThrow("Unsupported Hermes gateway protocol major 2"); + expect(rejectedFactory.sockets[0]!.closeCalls).toEqual([ + { code: 4002, reason: "gateway handshake failed" }, + ]); + }); + + it("degrades a missing optional RPC independently", async () => { + const logs: HermesGatewayLogEvent[] = []; + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + logger: (event) => logs.push(event), + }); + const commands = client.read("commands.catalog", {}); + const frame = sentFrames(socket)[0]!; + socket.receive({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32601, message: "PRIVATE METHOD ERROR" }, + }); + await expect(commands).rejects.toMatchObject({ code: -32601 }); + expect(client.hasCapability("commands.catalog")).toBe(false); + expect(client.hasCapability("session.history")).toBe(true); + await expect(client.read("commands.catalog", {})).rejects.toBeInstanceOf( + HermesGatewayCapabilityError, + ); + expect(JSON.stringify(logs)).not.toContain("PRIVATE METHOD ERROR"); + client.close(); + }); + + it("exposes typed H4 session and prompt helpers", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const createdPromise = client.createSession( + { source: "t3-work", close_on_disconnect: false }, + { operationId: "create-operation" }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + session_id: "live-1", + stored_session_id: "durable-1", + message_count: 0, + messages: [], + info: { model: "test-model", lazy: true }, + }), + ); + await expect(createdPromise).resolves.toMatchObject({ + session_id: "live-1", + stored_session_id: "durable-1", + }); + + const resumedPromise = client.resumeSession( + { session_id: "durable-1", close_on_disconnect: false }, + { operationId: "resume-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + session_id: "live-2", + resumed: "durable-1", + message_count: 1, + messages: [{ message_id: "message-restored", role: "assistant", text: "restored" }], + info: { + model: "test-model", + title_revision: 3, + title_origin: "agent", + }, + running: false, + session_key: "durable-1", + started_at: 1, + status: "idle", + }), + ); + await expect(resumedPromise).resolves.toMatchObject({ + session_id: "live-2", + session_key: "durable-1", + messages: [{ message_id: "message-restored" }], + info: { title_revision: 3, title_origin: "agent" }, + }); + + const statusPromise = client.readSessionStatus({ session_id: "live-2" }); + frame = sentFrames(socket).at(-1)!; + socket.receive(success(frame.id, { output: "sanitized status" })); + await expect(statusPromise).resolves.toEqual({ output: "sanitized status" }); + + const historyPromise = client.readSessionHistory({ session_id: "live-2" }); + frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + count: 1, + messages: [{ role: "assistant", text: "restored" }], + }), + ); + await expect(historyPromise).resolves.toMatchObject({ count: 1 }); + + const imagePromise = client.attachImageBytes( + { + session_id: "live-2", + content_base64: "iVBORw==", + filename: "image.png", + }, + { operationId: "image-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "image.attach_bytes", + params: { + session_id: "live-2", + content_base64: "iVBORw==", + filename: "image.png", + }, + }); + socket.receive(success(frame.id, { attached: true, count: 1 })); + await expect(imagePromise).resolves.toEqual({ attached: true, count: 1 }); + + const filePromise = client.attachFile( + { session_id: "live-2", name: "notes.txt", data_url: "data:text/plain;base64,YQ==" }, + { operationId: "file-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "file.attach", + params: { + session_id: "live-2", + name: "notes.txt", + data_url: "data:text/plain;base64,YQ==", + }, + }); + socket.receive(success(frame.id, { attached: true })); + await expect(filePromise).resolves.toEqual({ attached: true }); + + const pdfPromise = client.attachPdf( + { session_id: "live-2", filename: "report.pdf", content_base64: "JVBERg==" }, + { operationId: "pdf-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "pdf.attach", + params: { + session_id: "live-2", + filename: "report.pdf", + content_base64: "JVBERg==", + }, + }); + socket.receive(success(frame.id, { attached: true })); + await expect(pdfPromise).resolves.toEqual({ attached: true }); + + const promptPromise = client.submitPrompt( + { session_id: "live-2", text: "private" }, + { operationId: "prompt-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame.method).toBe("prompt.submit"); + socket.receive( + success(frame.id, { + status: "streaming", + run_id: "run-1", + user_message_id: "message-user", + assistant_message_id: "message-assistant", + mutation_id: "mutation-1", + replayed: false, + mutation_status: "admitted", + }), + ); + await expect(promptPromise).resolves.toEqual({ + status: "streaming", + run_id: "run-1", + user_message_id: "message-user", + assistant_message_id: "message-assistant", + mutation_id: "mutation-1", + replayed: false, + mutation_status: "admitted", + }); + + const interruptPromise = client.interruptSession( + { session_id: "live-2" }, + { operationId: "interrupt-operation" }, + ); + frame = sentFrames(socket).at(-1)!; + expect(frame.method).toBe("session.interrupt"); + socket.receive(success(frame.id, { status: "interrupted" })); + await expect(interruptPromise).resolves.toEqual({ status: "interrupted" }); + client.close(); + }); + + it("decodes profile-scoped durable session discovery", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + + const listed = client.listSessions({ profile: "work", limit: 20 }); + const request = sentFrames(socket).at(-1)!; + expect(request).toMatchObject({ + method: "session.list", + params: { profile: "work", limit: 20 }, + }); + socket.receive( + success(request.id, { + sessions: [ + { + id: "stored-1", + title: "Imported", + preview: "hello", + started_at: 123, + message_count: 2, + source: "tui", + }, + ], + }), + ); + + await expect(listed).resolves.toEqual({ + sessions: [ + { + id: "stored-1", + title: "Imported", + preview: "hello", + started_at: 123, + message_count: 2, + source: "tui", + }, + ], + }); + client.close(); + }); +}); + +describe("HermesGatewayClient recovery", () => { + it("coalesces concurrent initial connects onto one socket", async () => { + const factory = new FakeSocketFactory(); + const client = new HermesGatewayClient({ + endpoint: "ws://127.0.0.1:9119/api/ws", + authToken: "private-token", + socketFactory: factory.create, + reconnect: { maxAttempts: 0 }, + }); + + const first = client.connect(); + const second = client.connect(); + await Promise.resolve(); + expect(factory.sockets).toHaveLength(1); + factory.sockets[0]!.open(); + await Promise.resolve(); + factory.sockets[0]!.receive(legacyReady); + + await expect(Promise.all([first, second])).resolves.toHaveLength(2); + expect(factory.sockets).toHaveLength(1); + client.close(); + }); + + it("queues reconnect-time reads and permits a known-unsent mutation retry", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + socket.close(1006); + + const read = client.read("session.list", {}); + await expect( + client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "retry-after-reconnect" }, + ), + ).rejects.toThrow("not ready"); + expect(client.mutationRecord("retry-after-reconnect")?.state).toBe("not_sent"); + + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(fullyNegotiatedReady); + await eventually(() => replacement.sent.length === 1); + const replayedRead = sentFrames(replacement)[0]!; + expect(replayedRead.method).toBe("session.list"); + replacement.receive(success(replayedRead.id, { sessions: [] })); + await expect(read).resolves.toEqual({ sessions: [] }); + + const retried = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "retry-after-reconnect" }, + ); + const retriedFrame = sentFrames(replacement)[1]!; + expect(retriedFrame.method).toBe("prompt.submit"); + replacement.receive(success(retriedFrame.id, { status: "streaming" })); + await expect(retried).resolves.toEqual({ status: "streaming" }); + expect(client.mutationRecord("retry-after-reconnect")?.state).toBe("confirmed"); + client.close(); + }); + + it("uses mutation.status to release only an authoritatively completed local fence", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-recovery-operation", + mutationId: "prompt-recovery-mutation", + }, + ); + let frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: "prompt-recovery-mutation", + mutation_status: "indeterminate", + run_id: "run-recovery", + replayed: true, + }), + ); + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + + const reconciliation = client.reconcileMutation( + "prompt-recovery-operation", + "prompt-recovery-mutation", + ); + frame = sentFrames(socket).at(-1)!; + expect(frame).toMatchObject({ + method: "mutation.status", + params: { mutation_id: "prompt-recovery-mutation" }, + }); + socket.receive(success(frame.id, { mutation_status: "completed" })); + + await expect(reconciliation).resolves.toEqual({ mutation_status: "completed" }); + expect(client.mutationRecord("prompt-recovery-operation")).toBeUndefined(); + expect(client.writesBlocked).toBe(false); + client.close(); + }); + + it("marks a status-less indeterminate replay and blocks later mutations", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId: "prompt-recovery-operation", + mutationId: "prompt-recovery-mutation", + }, + ); + const frame = sentFrames(socket).at(-1)!; + expect(frame.params.mutation_id).toBe("prompt-recovery-mutation"); + socket.receive( + success(frame.id, { + mutation_id: "prompt-recovery-mutation", + mutation_status: "indeterminate", + run_id: "run-recovery", + replayed: true, + }), + ); + + await expect(prompt).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord("prompt-recovery-operation")?.state).toBe("indeterminate"); + expect(client.writesBlocked).toBe(true); + await expect( + client.interrupt("session-1", { operationId: "blocked-interrupt" }), + ).rejects.toBeInstanceOf(HermesGatewayMutationsBlockedError); + expect(sentFrames(socket)).toHaveLength(1); + client.close(); + }); + + it.each(["complete", "interrupted", "error"] as const)( + "accepts and confirms a terminal %s prompt replay", + async (status) => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const operationId = `prompt-${status}-operation`; + const prompt = client.submitPrompt( + { session_id: "session-1", text: "private" }, + { + operationId, + mutationId: `prompt-${status}-mutation`, + }, + ); + const frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + status, + mutation_id: `prompt-${status}-mutation`, + mutation_status: "completed", + run_id: `run-${status}`, + message_id: `message-${status}`, + }), + ); + + await expect(prompt).resolves.toMatchObject({ + status, + mutation_status: "completed", + run_id: `run-${status}`, + }); + expect(client.mutationRecord(operationId)?.state).toBe("confirmed"); + expect(client.writesBlocked).toBe(false); + client.close(); + }, + ); + + it("handles indeterminate replays before decoding create, resume, and interrupt results", async () => { + const cases = [ + { + operationId: "create-recovery-operation", + mutationId: "create-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.createSession( + { source: "t3-code" }, + { + operationId: "create-recovery-operation", + mutationId: "create-recovery-mutation", + }, + ), + }, + { + operationId: "resume-recovery-operation", + mutationId: "resume-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.resumeSession( + { session_id: "stored-session-1" }, + { + operationId: "resume-recovery-operation", + mutationId: "resume-recovery-mutation", + }, + ), + }, + { + operationId: "interrupt-recovery-operation", + mutationId: "interrupt-recovery-mutation", + invoke: (client: HermesGatewayClient) => + client.interrupt("session-1", { + operationId: "interrupt-recovery-operation", + mutationId: "interrupt-recovery-mutation", + }), + }, + ] as const; + + for (const testCase of cases) { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, {}, stableMutationReady); + const mutation = testCase.invoke(client); + const frame = sentFrames(socket).at(-1)!; + socket.receive( + success(frame.id, { + mutation_id: testCase.mutationId, + mutation_status: "indeterminate", + run_id: "", + replayed: true, + }), + ); + + await expect(mutation).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord(testCase.operationId)?.state).toBe("indeterminate"); + client.close(); + } + }); + + it("does not confirm an undecodable successful mutation response", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory); + const created = client.createSession( + { source: "t3-code" }, + { operationId: "malformed-create-operation" }, + ); + const frame = sentFrames(socket).at(-1)!; + socket.receive(success(frame.id, { unexpected: true })); + + await expect(created).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.mutationRecord("malformed-create-operation")?.state).toBe("indeterminate"); + expect(client.writesBlocked).toBe(true); + client.close(); + }); + + it("replays reads after bounded reconnect but never replays an indeterminate mutation", async () => { + const factory = new FakeSocketFactory(); + const { client, socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }); + const read = client.read("session.list", {}); + const mutation = client.mutate( + "prompt.submit", + { session_id: "session-1", text: "private" }, + { operationId: "prompt-operation" }, + ); + expect(sentFrames(socket).map((frame) => frame.method)).toEqual([ + "session.list", + "prompt.submit", + ]); + + socket.close(1006); + await expect(mutation).rejects.toBeInstanceOf(HermesGatewayMutationIndeterminateError); + expect(client.writesBlocked).toBe(true); + await eventually(() => factory.sockets.length === 2); + const replacement = factory.sockets[1]!; + replacement.open(); + await Promise.resolve(); + replacement.receive(fullyNegotiatedReady); + await eventually(() => replacement.sent.length === 1); + const replayed = sentFrames(replacement); + expect(replayed.map((frame) => frame.method)).toEqual(["session.list"]); + replacement.receive(success(replayed[0]!.id, { sessions: [] })); + await expect(read).resolves.toEqual({ sessions: [] }); + await expect( + client.mutate( + "prompt.submit", + { session_id: "session-1", text: "must not send" }, + { operationId: "prompt-operation-2" }, + ), + ).rejects.toBeInstanceOf(HermesGatewayMutationsBlockedError); + expect(replacement.sent).toHaveLength(1); + + client.acknowledgeIndeterminate("prompt-operation"); + const interrupt = client.interrupt("session-1", { operationId: "interrupt-operation" }); + const interruptFrame = sentFrames(replacement)[1]!; + expect(interruptFrame).toMatchObject({ + method: "session.interrupt", + params: { session_id: "session-1" }, + }); + replacement.receive(success(interruptFrame.id, { status: "interrupted" })); + await expect(interrupt).resolves.toEqual({ status: "interrupted" }); + client.close(); + }); + + it("bounds reconnect attempts and exposes process supervision hooks", async () => { + const factory = new FakeSocketFactory(); + const callbacks: string[] = []; + let exhausted!: () => void; + const exhaustedPromise = new Promise((resolve) => { + exhausted = resolve; + }); + const { socket } = await openClient(factory, { + reconnect: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + supervisor: { + beforeConnect: ({ attempt, reconnect }) => { + callbacks.push(`before:${attempt}:${reconnect}`); + }, + onConnected: ({ attempt }) => { + callbacks.push(`connected:${attempt}`); + }, + onDisconnected: ({ reconnecting }) => { + callbacks.push(`disconnected:${reconnecting}`); + }, + onReconnectExhausted: ({ attempts }) => { + callbacks.push(`exhausted:${attempts}`); + exhausted(); + }, + }, + socketFactory: (endpoint) => { + const candidate = factory.create(endpoint); + if (factory.sockets.length > 1) { + queueMicrotask(() => candidate.fail()); + } + return candidate; + }, + }); + socket.close(1006); + await exhaustedPromise; + + expect(factory.sockets).toHaveLength(3); + expect(callbacks).toEqual([ + "before:0:false", + "connected:0", + "disconnected:true", + "before:1:true", + "disconnected:true", + "before:2:true", + "disconnected:true", + "exhausted:2", + ]); + }); +}); + +async function eventually(predicate: () => boolean, timeoutMs = 1_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() >= deadline) throw new Error("Timed out waiting for test condition."); + await new Promise((resolve) => setTimeout(resolve, 1)); + } +} diff --git a/apps/server/src/hermes/HermesGatewayClient.ts b/apps/server/src/hermes/HermesGatewayClient.ts new file mode 100644 index 00000000000..acc45ba9b53 --- /dev/null +++ b/apps/server/src/hermes/HermesGatewayClient.ts @@ -0,0 +1,1513 @@ +// @effect-diagnostics globalTimers:off - This transport owns bounded WebSocket timers. +import { + HermesGatewayEvent, + HermesGatewayCronListResult, + HermesGatewayCronMutationResult, + HermesGatewayApprovalRespondResult, + HermesGatewayClarificationRespondResult, + HermesGatewayCommandsCatalogResult, + HermesGatewayInboundFrame, + HermesGatewayInterruptResult, + HermesGatewayMutationOutcome, + HermesGatewayMutationStatusResult, + HermesGatewayModelOptionsResult, + HermesGatewayPromptSubmitResult, + HermesGatewaySessionCreateResult, + HermesGatewaySessionHistoryResult, + HermesGatewaySessionListResult, + HermesGatewaySessionBranchResult, + HermesGatewaySessionMcpLeaseResult, + HermesGatewaySessionMcpRevokeResult, + HermesGatewaySessionResumeResult, + HermesGatewaySessionStatusResult, + HermesGatewaySessionTitleResult, + HermesGatewayReasoningConfigResult, + HermesGatewayFastConfigResult, + type HermesGatewayApprovalRespondParams, + type HermesGatewayApprovalRespondResult as HermesGatewayApprovalRespondResultType, + type HermesGatewayCapabilityName, + type HermesGatewayCronListResult as HermesGatewayCronListResultType, + type HermesGatewayCronMutationResult as HermesGatewayCronMutationResultType, + type HermesGatewayCompatibility, + type HermesGatewayClarificationRespondParams, + type HermesGatewayClarificationRespondResult as HermesGatewayClarificationRespondResultType, + type HermesGatewayCommandsCatalogResult as HermesGatewayCommandsCatalogResultType, + type HermesGatewayEvent as HermesGatewayEventFrame, + type HermesGatewayInterruptParams, + type HermesGatewayInterruptResult as HermesGatewayInterruptResultType, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult as HermesGatewayPromptSubmitResultType, + type HermesGatewayReadyEvent, + type HermesGatewayResponse, + type HermesGatewayMutationStatusResult as HermesGatewayMutationStatusResultType, + type HermesGatewayModelOptionsResult as HermesGatewayModelOptionsResultType, + type HermesGatewayReasoningConfigResult as HermesGatewayReasoningConfigResultType, + type HermesGatewayFastConfigResult as HermesGatewayFastConfigResultType, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult as HermesGatewaySessionCreateResultType, + type HermesGatewaySessionHandleParams, + type HermesGatewaySessionHistoryResult as HermesGatewaySessionHistoryResultType, + type HermesGatewaySessionListParams, + type HermesGatewaySessionListResult as HermesGatewaySessionListResultType, + type HermesGatewaySessionMcpLeaseResult as HermesGatewaySessionMcpLeaseResultType, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionMcpRevokeResult as HermesGatewaySessionMcpRevokeResultType, + type HermesGatewaySessionBranchParams, + type HermesGatewaySessionBranchResult as HermesGatewaySessionBranchResultType, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult as HermesGatewaySessionResumeResultType, + type HermesGatewaySessionStatusResult as HermesGatewaySessionStatusResultType, + type HermesGatewaySessionTitleParams, + type HermesGatewaySessionTitleResult as HermesGatewaySessionTitleResultType, + type HermesGatewayUnknownRecord, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, +} from "./HermesConnectionSecurity.ts"; + +export const HERMES_GATEWAY_SUPPORTED_PROTOCOL_MAJOR = 1; + +export const HERMES_GATEWAY_LEGACY_CAPABILITIES = + [] as const satisfies ReadonlyArray; + +export type HermesGatewayConnectionState = + | "disconnected" + | "connecting" + | "ready" + | "reconnecting" + | "closed"; + +export type HermesGatewayMutationState = "pending" | "confirmed" | "indeterminate" | "not_sent"; + +export interface HermesGatewayMutationRecord { + readonly operationId: string; + readonly method: string; + readonly state: HermesGatewayMutationState; + readonly mutationId?: string; +} + +export interface HermesGatewayHealth { + readonly state: HermesGatewayConnectionState; + readonly reconnectAttempt: number; + readonly protocolStatus: HermesGatewayCompatibility["status"] | "unknown"; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly serverVersion: string | null; + readonly capabilities: ReadonlyArray; + readonly writesBlocked: boolean; + readonly indeterminateMutationCount: number; +} + +export interface HermesGatewayOrderedEvent { + readonly transportSequence: number; + readonly sessionSequence: number; + readonly sessionId: string | undefined; + readonly eventId: string | undefined; + readonly eventSequence: number | undefined; + readonly emittedAt: string | undefined; + readonly sessionKey: string | undefined; + readonly runId: string | undefined; + readonly messageId: string | undefined; + readonly cursor: string | number | undefined; + readonly mutationId: string | undefined; + readonly frame: HermesGatewayEventFrame; +} + +export type HermesGatewayLogEvent = + | { + readonly type: "connection"; + readonly state: HermesGatewayConnectionState; + readonly endpoint: string; + readonly attempt: number; + } + | { + readonly type: "request"; + readonly method: string; + readonly requestId: string; + readonly operation: "read" | "mutation"; + } + | { + readonly type: "response"; + readonly method: string; + readonly requestId: string; + readonly outcome: "success" | "rpc_error"; + readonly errorCode?: number; + } + | { + readonly type: "protocol"; + readonly outcome: "invalid_frame" | "unknown_notification" | "capability_degraded"; + readonly method?: string; + readonly capability?: string; + } + | { + readonly type: "mutation"; + readonly operationId: string; + readonly method: string; + readonly state: HermesGatewayMutationState; + }; + +export interface HermesGatewaySupervisor { + readonly beforeConnect?: (context: { + readonly attempt: number; + readonly reconnect: boolean; + }) => void | Promise; + readonly onConnected?: (context: { + readonly attempt: number; + readonly reconnect: boolean; + readonly compatibility: HermesGatewayCompatibility; + }) => void | Promise; + readonly onDisconnected?: (context: { + readonly reconnecting: boolean; + readonly pendingReads: number; + readonly indeterminateMutations: ReadonlyArray; + }) => void | Promise; + readonly onReconnectExhausted?: (context: { readonly attempts: number }) => void | Promise; +} + +export interface HermesGatewaySocketEvent { + readonly data?: unknown; + readonly code?: number; +} + +export interface HermesGatewaySocket { + readonly readyState: number; + send(data: string): void; + close(code?: number, reason?: string): void; + addEventListener( + type: "open" | "message" | "close" | "error", + listener: (event: HermesGatewaySocketEvent) => void, + options?: { readonly once?: boolean }, + ): void; +} + +export type HermesGatewaySocketFactory = (endpoint: string) => HermesGatewaySocket; + +export interface HermesGatewayClientOptions { + readonly endpoint: string; + readonly authToken: string; + readonly socketFactory?: HermesGatewaySocketFactory; + readonly requestTimeoutMs?: number; + readonly readyTimeoutMs?: number; + readonly reconnect?: { + readonly maxAttempts?: number; + readonly baseDelayMs?: number; + readonly maxDelayMs?: number; + }; + readonly criticalCapabilities?: ReadonlyArray; + readonly logger?: (event: HermesGatewayLogEvent) => void; + readonly supervisor?: HermesGatewaySupervisor; +} + +export interface HermesGatewayReadOptions { + readonly signal?: AbortSignal; + readonly requiredCapability?: string; + readonly retryOnReconnect?: boolean; + readonly timeoutMs?: number; +} + +export interface HermesGatewayMutationOptions { + readonly operationId: string; + readonly mutationId?: string; + readonly signal?: AbortSignal; + readonly requiredCapability?: string; + readonly timeoutMs?: number; +} + +interface PendingRequest { + readonly id: string; + readonly method: string; + readonly params: HermesGatewayUnknownRecord; + readonly operation: "read" | "mutation"; + readonly operationId: string | undefined; + readonly mutationId: string | undefined; + readonly retryOnReconnect: boolean; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly signal: AbortSignal | undefined; + readonly abortListener: (() => void) | undefined; + timeout: ReturnType | undefined; + sent: boolean; + awaitingReconnect: boolean; + timeoutMs: number; +} + +interface ReadyWaiter { + readonly resolve: (event: HermesGatewayReadyEvent) => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; +} + +export class HermesGatewayConfigurationError extends Error { + override readonly name = "HermesGatewayConfigurationError"; +} + +export class HermesGatewayConnectionError extends Error { + override readonly name = "HermesGatewayConnectionError"; +} + +export class HermesGatewayProtocolError extends Error { + override readonly name = "HermesGatewayProtocolError"; +} + +export class HermesGatewayCapabilityError extends Error { + override readonly name = "HermesGatewayCapabilityError"; + readonly capability: string; + constructor(capability: string) { + super(`Hermes gateway capability is unavailable: ${capability}`); + this.capability = capability; + } +} + +export class HermesGatewayRpcError extends Error { + override readonly name = "HermesGatewayRpcError"; + readonly code: number; + readonly method: string; + readonly disposition: "retryable" | "indeterminate" | "fatal" | undefined; + constructor( + code: number, + method: string, + disposition: "retryable" | "indeterminate" | "fatal" | undefined, + ) { + super(`Hermes gateway RPC ${method} failed with code ${code}.`); + this.code = code; + this.method = method; + this.disposition = disposition; + } +} + +export class HermesGatewayRequestCancelledError extends Error { + override readonly name = "HermesGatewayRequestCancelledError"; +} + +export class HermesGatewayMutationIndeterminateError extends Error { + override readonly name = "HermesGatewayMutationIndeterminateError"; + readonly operationId: string; + readonly method: string; + constructor(operationId: string, method: string) { + super(`Hermes mutation ${operationId} (${method}) has an indeterminate outcome.`); + this.operationId = operationId; + this.method = method; + } +} + +export class HermesGatewayMutationsBlockedError extends Error { + override readonly name = "HermesGatewayMutationsBlockedError"; + readonly operationIds: ReadonlyArray; + constructor(operationIds: ReadonlyArray) { + super("Hermes mutations are blocked until indeterminate operations are reconciled."); + this.operationIds = operationIds; + } +} + +const decodeInboundFrame = Schema.decodeUnknownSync(HermesGatewayInboundFrame); +const decodeGatewayEvent = Schema.decodeUnknownSync(HermesGatewayEvent); +const decodeMutationOutcome = Schema.decodeUnknownSync(HermesGatewayMutationOutcome); + +const METHOD_CAPABILITIES: Readonly> = { + "session.create": "session.lifecycle", + "session.list": "session.lifecycle", + "session.resume": "session.lifecycle", + "session.history": "session.history", + "session.title": "session.title", + "session.branch": "session.branch.latest", + "session.mcp.register": "session_mcp", + "session.mcp.replace": "session_mcp", + "session.mcp.revoke": "session_mcp", + "session.interrupt": "turn.interrupt", + "prompt.submit": "turn.prompt", + "mutation.status": "mutation.stable_ids", + "commands.catalog": "commands.catalog", + "model.options": "models.inventory", + "config.get": "reasoning.effective_state", + "image.attach_bytes": "attachments.image", + "file.attach": "attachments.file", + "pdf.attach": "attachments.pdf", + "approval.respond": "events.approvals", + "clarify.respond": "events.clarification", + "cron.manage": "cron.manage", +}; + +const SOCKET_OPEN = 1; + +export function classifyHermesGatewayReady( + event: HermesGatewayReadyEvent, +): HermesGatewayCompatibility { + const protocol = event.params.payload.protocol ?? null; + const advertised = protocol?.capabilities ?? event.params.payload.capabilities; + const capabilities = + advertised === undefined + ? [...HERMES_GATEWAY_LEGACY_CAPABILITIES] + : Array.isArray(advertised) + ? [...advertised] + : Object.entries(advertised) + .filter(([capability, value]) => capability !== "version" && capabilityEnabled(value)) + .map(([capability]) => capability) + .toSorted(); + + if (protocol === null) { + return { + status: "legacy", + protocol: null, + capabilities, + inventory: advertised ?? null, + reason: "Gateway did not advertise a negotiated protocol version.", + ...(event.params.payload.server_version === undefined + ? {} + : { serverVersion: event.params.payload.server_version }), + ...(event.params.payload.revision === undefined + ? {} + : { revision: event.params.payload.revision }), + }; + } + if (protocol.major !== HERMES_GATEWAY_SUPPORTED_PROTOCOL_MAJOR) { + return { + status: "unsupported", + protocol, + capabilities, + inventory: advertised ?? null, + reason: `Unsupported Hermes gateway protocol major ${protocol.major}.`, + ...(event.params.payload.server_version === undefined + ? {} + : { serverVersion: event.params.payload.server_version }), + ...(event.params.payload.revision === undefined + ? {} + : { revision: event.params.payload.revision }), + }; + } + return { + status: "supported", + protocol, + capabilities, + inventory: advertised ?? null, + reason: `Hermes gateway protocol ${protocol.major}.${protocol.minor} is supported.`, + ...(event.params.payload.server_version === undefined + ? {} + : { serverVersion: event.params.payload.server_version }), + ...(event.params.payload.revision === undefined + ? {} + : { revision: event.params.payload.revision }), + }; +} + +export function sanitizeHermesGatewayEndpoint(endpoint: string): string { + return sanitizeHermesEndpoint(endpoint); +} + +export class HermesGatewayClient { + private readonly endpoint: string; + private readonly endpointLabel: string; + private readonly socketFactory: HermesGatewaySocketFactory; + private readonly requestTimeoutMs: number; + private readonly readyTimeoutMs: number; + private readonly maxReconnectAttempts: number; + private readonly reconnectBaseDelayMs: number; + private readonly reconnectMaxDelayMs: number; + private readonly criticalCapabilities: ReadonlyArray; + private readonly logger: ((event: HermesGatewayLogEvent) => void) | undefined; + private readonly supervisor: HermesGatewaySupervisor | undefined; + + private socket: HermesGatewaySocket | undefined; + private stateValue: HermesGatewayConnectionState = "disconnected"; + private compatibilityValue: HermesGatewayCompatibility | undefined; + private capabilities = new Set(); + private nextRequestId = 1; + private transportSequence = 0; + private readonly sessionSequences = new Map(); + private readonly pending = new Map(); + private readonly mutations = new Map(); + private readonly eventListeners = new Set< + (event: HermesGatewayOrderedEvent) => void | Promise + >(); + private eventDispatch = Promise.resolve(); + private readyWaiter: ReadyWaiter | undefined; + private connectTask: Promise | undefined; + private reconnectTask: Promise | undefined; + private manuallyClosed = false; + private connectionGeneration = 0; + private reconnectAttempt = 0; + private readonly healthListeners = new Set<(health: HermesGatewayHealth) => void>(); + + constructor(options: HermesGatewayClientOptions) { + this.endpoint = authenticatedEndpoint(options); + this.endpointLabel = sanitizeHermesGatewayEndpoint(this.endpoint); + this.socketFactory = options.socketFactory ?? defaultSocketFactory; + this.requestTimeoutMs = positive(options.requestTimeoutMs, 15_000); + this.readyTimeoutMs = positive(options.readyTimeoutMs, 10_000); + this.maxReconnectAttempts = nonNegative(options.reconnect?.maxAttempts, 3); + this.reconnectBaseDelayMs = positive(options.reconnect?.baseDelayMs, 100); + this.reconnectMaxDelayMs = positive(options.reconnect?.maxDelayMs, 2_000); + this.criticalCapabilities = options.criticalCapabilities ?? []; + this.logger = options.logger; + this.supervisor = options.supervisor; + } + + get state(): HermesGatewayConnectionState { + return this.stateValue; + } + + get compatibility(): HermesGatewayCompatibility | undefined { + return this.compatibilityValue; + } + + get writesBlocked(): boolean { + return this.indeterminateOperationIds().length > 0; + } + + get health(): HermesGatewayHealth { + return { + state: this.stateValue, + reconnectAttempt: this.reconnectAttempt, + protocolStatus: this.compatibilityValue?.status ?? "unknown", + protocolMajor: this.compatibilityValue?.protocol?.major ?? null, + protocolMinor: this.compatibilityValue?.protocol?.minor ?? null, + serverVersion: this.compatibilityValue?.serverVersion ?? null, + capabilities: [...this.capabilities].toSorted(), + writesBlocked: this.writesBlocked, + indeterminateMutationCount: this.indeterminateOperationIds().length, + }; + } + + hasCapability(capability: string): boolean { + return this.capabilities.has(capability); + } + + mutationRecord(operationId: string): HermesGatewayMutationRecord | undefined { + return this.mutations.get(operationId); + } + + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void { + this.eventListeners.add(listener); + return () => this.eventListeners.delete(listener); + } + + onHealthChange(listener: (health: HermesGatewayHealth) => void): () => void { + this.healthListeners.add(listener); + listener(this.health); + return () => this.healthListeners.delete(listener); + } + + async connect(): Promise { + if (this.stateValue === "ready" && this.compatibilityValue) { + return this.compatibilityValue; + } + if (this.stateValue === "closed") { + throw new HermesGatewayConnectionError("Hermes gateway client is closed."); + } + if (this.connectTask) return this.connectTask; + if (this.reconnectTask) { + await this.reconnectTask; + if (this.stateValue === "ready" && this.compatibilityValue) { + return this.compatibilityValue; + } + throw new HermesGatewayConnectionError("Hermes gateway reconnect did not recover."); + } + this.manuallyClosed = false; + const task = this.connectAttempt(0, false).finally(() => { + if (this.connectTask === task) this.connectTask = undefined; + }); + this.connectTask = task; + return task; + } + + async read( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayReadOptions = {}, + ): Promise { + this.requireCapability(options.requiredCapability ?? METHOD_CAPABILITIES[method]); + return this.sendRequest(method, params, { + operation: "read", + operationId: undefined, + mutationId: undefined, + signal: options.signal, + retryOnReconnect: options.retryOnReconnect ?? true, + timeoutMs: positive(options.timeoutMs, this.requestTimeoutMs), + }); + } + + async mutate( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayMutationOptions, + ): Promise { + return this.mutateDecoded(method, params, options, (value) => value); + } + + private async mutateDecoded( + method: string, + params: HermesGatewayUnknownRecord, + options: HermesGatewayMutationOptions, + decode: (value: unknown) => Result, + ): Promise { + this.requireCapability(options.requiredCapability ?? METHOD_CAPABILITIES[method]); + const blocked = this.indeterminateOperationIds(); + if (blocked.length > 0) { + throw new HermesGatewayMutationsBlockedError(blocked); + } + if (!options.operationId.trim()) { + throw new HermesGatewayConfigurationError("Hermes mutation operationId is required."); + } + const existing = this.mutations.get(options.operationId); + const retryingKnownUnsent = + existing?.state === "not_sent" && + existing.method === method && + existing.mutationId === options.mutationId; + if (existing !== undefined && !retryingKnownUnsent) { + throw new HermesGatewayConfigurationError( + `Hermes mutation operationId has already been used: ${options.operationId}`, + ); + } + if (options.mutationId && !this.hasCapability("mutation.stable_ids")) { + throw new HermesGatewayCapabilityError("mutation.stable_ids"); + } + + const wireParams = + options.mutationId === undefined + ? params + : { + ...params, + mutation_id: options.mutationId, + }; + this.setMutation({ + operationId: options.operationId, + method, + state: "pending", + ...(options.mutationId === undefined ? {} : { mutationId: options.mutationId }), + }); + + let responseReceived = false; + try { + const result = await this.sendRequest(method, wireParams, { + operation: "mutation", + operationId: options.operationId, + mutationId: options.mutationId, + signal: options.signal, + retryOnReconnect: false, + timeoutMs: positive(options.timeoutMs, this.requestTimeoutMs), + }); + responseReceived = true; + const outcome = decodeOptionalMutationOutcome(result); + if (outcome?.mutation_status === "indeterminate") { + this.markMutationIndeterminate(options.operationId); + throw new HermesGatewayMutationIndeterminateError(options.operationId, method); + } + if (outcome?.mutation_status === "completed") { + this.confirmMutation(options.operationId); + } + const decoded = decode(result); + this.confirmMutation(options.operationId); + return decoded; + } catch (error) { + const record = this.mutations.get(options.operationId); + if (record?.state === "pending") { + this.setMutation({ + ...record, + state: responseReceived ? "indeterminate" : "not_sent", + }); + if (responseReceived) { + throw new HermesGatewayMutationIndeterminateError(options.operationId, method); + } + } + throw error; + } + } + + async interrupt( + sessionId: string, + options: Omit, + ): Promise { + return this.interruptSession({ session_id: sessionId }, options); + } + + async createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.create", + params, + { + ...options, + requiredCapability: "session.lifecycle", + }, + (result) => decodeResult(HermesGatewaySessionCreateResult, result, "session.create"), + ); + } + + async resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.resume", + params, + { + ...options, + requiredCapability: "session.lifecycle", + }, + (result) => decodeResult(HermesGatewaySessionResumeResult, result, "session.resume"), + ); + } + + async registerSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.register", + params, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpLeaseResult, result, "session.mcp.register"), + ); + } + + async replaceSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.replace", + params, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpLeaseResult, result, "session.mcp.replace"), + ); + } + + async revokeSessionMcp( + sessionId: string, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.mcp.revoke", + { session_id: sessionId }, + { ...options, requiredCapability: "session_mcp" }, + (result) => decodeResult(HermesGatewaySessionMcpRevokeResult, result, "session.mcp.revoke"), + ); + } + + async readSessionStatus( + params: HermesGatewaySessionHandleParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.status", params, { + ...options, + requiredCapability: "session.lifecycle", + }); + return decodeResult(HermesGatewaySessionStatusResult, result, "session.status"); + } + + async readSessionHistory( + params: HermesGatewaySessionHandleParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.history", params, { + ...options, + requiredCapability: "session.history", + }); + return decodeResult(HermesGatewaySessionHistoryResult, result, "session.history"); + } + + async readSessionTitle( + params: Pick, + options: Omit = {}, + ): Promise { + const result = await this.read("session.title", params, { + ...options, + requiredCapability: "session.title", + }); + return decodeResult(HermesGatewaySessionTitleResult, result, "session.title"); + } + + async updateSessionTitle( + params: HermesGatewaySessionTitleParams & { readonly title: string }, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.title", + params, + { + ...options, + requiredCapability: "session.title", + }, + (result) => decodeResult(HermesGatewaySessionTitleResult, result, "session.title"), + ); + } + + async branchSession( + params: HermesGatewaySessionBranchParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.branch", + params, + { + ...options, + requiredCapability: "session.branch.latest", + }, + (result) => decodeResult(HermesGatewaySessionBranchResult, result, "session.branch"), + ); + } + + async listSessions( + params: HermesGatewaySessionListParams, + options: Omit = {}, + ): Promise { + const result = await this.read("session.list", params, { + ...options, + requiredCapability: "session.lifecycle", + retryOnReconnect: true, + }); + return decodeResult(HermesGatewaySessionListResult, result, "session.list"); + } + + async listCronJobs( + options: Omit = {}, + ): Promise { + const result = await this.read( + "cron.manage", + { action: "list" }, + { + ...options, + requiredCapability: "cron.read", + }, + ); + return decodeResult(HermesGatewayCronListResult, result, "cron.manage/list"); + } + + async manageCron( + params: HermesGatewayUnknownRecord, + options: Omit, + ): Promise { + return this.mutateDecoded( + "cron.manage", + params, + { ...options, requiredCapability: "cron.manage" }, + (result) => decodeResult(HermesGatewayCronMutationResult, result, "cron.manage"), + ); + } + + async readCommandsCatalog( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "commands.catalog", + sessionId === undefined ? {} : { session_id: sessionId }, + { ...options, requiredCapability: "commands.catalog" }, + ); + return decodeResult(HermesGatewayCommandsCatalogResult, result, "commands.catalog"); + } + + async readModelOptions( + params: { + readonly session_id?: string; + readonly explicit_only?: boolean; + readonly include_unconfigured?: boolean; + } = {}, + options: Omit = {}, + ): Promise { + const result = await this.read("model.options", params, { + ...options, + requiredCapability: "models.inventory", + }); + return decodeResult(HermesGatewayModelOptionsResult, result, "model.options"); + } + + async readReasoningConfig( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "config.get", + { + key: "reasoning", + ...(sessionId === undefined ? {} : { session_id: sessionId }), + }, + { ...options, requiredCapability: "reasoning.effective_state" }, + ); + return decodeResult(HermesGatewayReasoningConfigResult, result, "config.get"); + } + + async readFastConfig( + sessionId?: string, + options: Omit = {}, + ): Promise { + const result = await this.read( + "config.get", + { + key: "fast", + ...(sessionId === undefined ? {} : { session_id: sessionId }), + }, + { ...options, requiredCapability: "models.inventory" }, + ); + return decodeResult(HermesGatewayFastConfigResult, result, "config.get"); + } + + async reconcileMutation( + operationId: string, + mutationId: string = operationId, + ): Promise { + const result = await this.read( + "mutation.status", + { mutation_id: mutationId }, + { + requiredCapability: "mutation.stable_ids", + retryOnReconnect: true, + }, + ); + const outcome = decodeResult(HermesGatewayMutationStatusResult, result, "mutation.status"); + const existing = this.mutations.get(operationId); + if (outcome.mutation_status === "indeterminate") { + this.setMutation({ + operationId, + method: existing?.method ?? "unknown", + state: "indeterminate", + mutationId: existing?.mutationId ?? operationId, + }); + } else { + this.mutations.delete(operationId); + } + return outcome; + } + + async submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "prompt.submit", + params, + { + ...options, + requiredCapability: "turn.prompt", + }, + (result) => decodeResult(HermesGatewayPromptSubmitResult, result, "prompt.submit"), + ); + } + + async attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise { + return this.mutate("image.attach_bytes", params, { + ...options, + requiredCapability: "attachments.image", + }); + } + + async respondToApproval( + params: HermesGatewayApprovalRespondParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "approval.respond", + params, + { ...options, requiredCapability: "events.approvals" }, + (result) => decodeResult(HermesGatewayApprovalRespondResult, result, "approval.respond"), + ); + } + + async respondToClarification( + params: HermesGatewayClarificationRespondParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "clarify.respond", + params, + { ...options, requiredCapability: "events.clarification" }, + (result) => decodeResult(HermesGatewayClarificationRespondResult, result, "clarify.respond"), + ); + } + + async attachFile( + params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }, + options: Omit, + ): Promise { + return this.mutate("file.attach", params, { + ...options, + requiredCapability: "attachments.file", + }); + } + + async attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise { + return this.mutate("pdf.attach", params, { + ...options, + requiredCapability: "attachments.pdf", + }); + } + + async interruptSession( + params: HermesGatewayInterruptParams, + options: Omit, + ): Promise { + return this.mutateDecoded( + "session.interrupt", + params, + { + ...options, + requiredCapability: "turn.interrupt", + }, + (result) => decodeResult(HermesGatewayInterruptResult, result, "session.interrupt"), + ); + } + + acknowledgeIndeterminate(operationId: string): void { + const record = this.mutations.get(operationId); + if (record?.state !== "indeterminate") { + throw new HermesGatewayConfigurationError( + `Hermes mutation is not indeterminate: ${operationId}`, + ); + } + this.mutations.delete(operationId); + this.emitHealth(); + } + + close(): void { + if (this.stateValue === "closed") return; + this.manuallyClosed = true; + this.setState("closed", 0); + this.rejectReady(new HermesGatewayConnectionError("Hermes gateway client closed.")); + const socket = this.socket; + this.socket = undefined; + socket?.close(1000, "client closed"); + for (const pending of this.pending.values()) { + this.rejectPending( + pending, + new HermesGatewayConnectionError("Hermes gateway client closed."), + pending.operation === "mutation" && pending.sent, + ); + } + } + + private async connectAttempt( + attempt: number, + reconnect: boolean, + ): Promise { + this.setState(reconnect ? "reconnecting" : "connecting", attempt); + await this.supervisor?.beforeConnect?.({ attempt, reconnect }); + const generation = ++this.connectionGeneration; + const socket = this.socketFactory(this.endpoint); + this.socket = socket; + + const opened = new Promise((resolve, reject) => { + socket.addEventListener("open", () => resolve(), { once: true }); + socket.addEventListener( + "error", + () => reject(new HermesGatewayConnectionError("Hermes gateway connection failed.")), + { once: true }, + ); + }); + socket.addEventListener("message", (event) => this.handleMessage(event, generation)); + socket.addEventListener("close", (event) => this.handleClose(event, generation), { + once: true, + }); + + try { + await opened; + const ready = await this.waitForReady(); + const compatibility = classifyHermesGatewayReady(ready); + this.compatibilityValue = compatibility; + if (compatibility.status === "unsupported") { + throw new HermesGatewayProtocolError(compatibility.reason); + } + this.capabilities = new Set(compatibility.capabilities); + for (const capability of this.criticalCapabilities) { + this.requireCapability(capability); + } + this.setState("ready", attempt); + await this.supervisor?.onConnected?.({ attempt, reconnect, compatibility }); + this.replayPendingReads(); + return compatibility; + } catch (error) { + if (this.socket === socket) this.socket = undefined; + // The WHATWG WebSocket API only permits callers to send code 1000 or + // private-use codes in the 3000-4999 range. Undici correctly rejects + // reserved protocol code 1002 with InvalidAccessError, which would mask + // the actual handshake/connection failure we are trying to report. + socket.close(4002, "gateway handshake failed"); + this.rejectReady( + error instanceof Error + ? error + : new HermesGatewayConnectionError("Hermes gateway handshake failed."), + ); + throw error; + } + } + + private waitForReady(): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.readyWaiter = undefined; + reject(new HermesGatewayProtocolError("Timed out waiting for gateway.ready.")); + }, this.readyTimeoutMs); + this.readyWaiter = { resolve, reject, timer }; + }); + } + + private resolveReady(event: HermesGatewayReadyEvent): void { + const waiter = this.readyWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.readyWaiter = undefined; + waiter.resolve(event); + } + + private rejectReady(error: Error): void { + const waiter = this.readyWaiter; + if (!waiter) return; + clearTimeout(waiter.timer); + this.readyWaiter = undefined; + waiter.reject(error); + } + + private handleMessage(event: HermesGatewaySocketEvent, generation: number): void { + if (generation !== this.connectionGeneration || typeof event.data !== "string") return; + let decoded: HermesGatewayInboundFrame; + try { + decoded = decodeInboundFrame(JSON.parse(event.data)); + } catch { + this.logger?.({ type: "protocol", outcome: "invalid_frame" }); + return; + } + + if ("method" in decoded) { + if (decoded.method !== "event") { + this.logger?.({ + type: "protocol", + outcome: "unknown_notification", + method: decoded.method, + }); + return; + } + const gatewayEvent = decodeGatewayEvent(decoded); + if (gatewayEvent.params.type === "gateway.ready") { + this.resolveReady(decoded as HermesGatewayReadyEvent); + } + this.enqueueEvent(gatewayEvent); + return; + } + this.handleResponse(decoded); + } + + private handleResponse(response: HermesGatewayResponse): void { + if (response.id === null) return; + const requestId = String(response.id); + const pending = this.pending.get(requestId); + if (!pending) return; + this.pending.delete(requestId); + this.clearPendingResources(pending); + + if ("error" in response) { + const disposition = response.error.data?.disposition; + const capability = METHOD_CAPABILITIES[pending.method]; + if (response.error.code === -32601 && capability && this.capabilities.delete(capability)) { + this.logger?.({ + type: "protocol", + outcome: "capability_degraded", + method: pending.method, + capability, + }); + } + if (pending.operationId && disposition === "indeterminate") { + const record = this.mutations.get(pending.operationId); + if (record) this.setMutation({ ...record, state: "indeterminate" }); + } else if (pending.operationId) { + this.confirmMutation(pending.operationId); + } + this.logger?.({ + type: "response", + method: pending.method, + requestId, + outcome: "rpc_error", + errorCode: response.error.code, + }); + pending.reject(new HermesGatewayRpcError(response.error.code, pending.method, disposition)); + return; + } + + this.logger?.({ + type: "response", + method: pending.method, + requestId, + outcome: "success", + }); + pending.resolve(response.result); + } + + private enqueueEvent(frame: HermesGatewayEventFrame): void { + const transportSequence = ++this.transportSequence; + const sessionId = frame.params.session_id || undefined; + const sessionKey = sessionId ?? ""; + const sessionSequence = (this.sessionSequences.get(sessionKey) ?? 0) + 1; + this.sessionSequences.set(sessionKey, sessionSequence); + const ordered: HermesGatewayOrderedEvent = { + transportSequence, + sessionSequence, + sessionId, + eventId: frame.params.event_id, + eventSequence: frame.params.event_sequence, + emittedAt: frame.params.emitted_at, + sessionKey: frame.params.session_key, + runId: frame.params.run_id, + messageId: frame.params.message_id, + cursor: frame.params.event_sequence ?? frame.params.cursor, + mutationId: frame.params.mutation_id, + frame, + }; + this.eventDispatch = this.eventDispatch + .catch(() => undefined) + .then(async () => { + for (const listener of this.eventListeners) { + await listener(ordered); + } + }); + } + + private sendRequest( + method: string, + params: HermesGatewayUnknownRecord, + options: { + readonly operation: "read" | "mutation"; + readonly operationId: string | undefined; + readonly mutationId: string | undefined; + readonly signal: AbortSignal | undefined; + readonly retryOnReconnect: boolean; + readonly timeoutMs: number; + }, + ): Promise { + const ready = this.stateValue === "ready" && this.socket?.readyState === SOCKET_OPEN; + const mayWaitForReconnect = + options.operation === "read" && + options.retryOnReconnect && + this.stateValue === "reconnecting"; + if (!ready && !mayWaitForReconnect) { + return Promise.reject(new HermesGatewayConnectionError("Hermes gateway is not ready.")); + } + if (options.signal?.aborted) { + return Promise.reject(new HermesGatewayRequestCancelledError("Hermes request cancelled.")); + } + + const id = String(this.nextRequestId++); + return new Promise((resolve, reject) => { + let pending: PendingRequest; + const abortListener = + options.signal === undefined + ? undefined + : () => { + const markIndeterminate = pending.operation === "mutation" && pending.sent; + this.rejectPending( + pending, + markIndeterminate && pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayRequestCancelledError("Hermes request cancelled."), + markIndeterminate, + ); + }; + pending = { + id, + method, + params, + operation: options.operation, + operationId: options.operationId, + mutationId: options.mutationId, + retryOnReconnect: options.retryOnReconnect, + resolve, + reject, + signal: options.signal, + abortListener, + timeout: undefined, + sent: false, + awaitingReconnect: false, + timeoutMs: options.timeoutMs, + }; + this.pending.set(id, pending); + options.signal?.addEventListener("abort", abortListener!, { once: true }); + this.sendPending(pending); + }); + } + + private sendPending(pending: PendingRequest): void { + const socket = this.socket; + if (!socket || socket.readyState !== SOCKET_OPEN) { + if (pending.operation === "read" && pending.retryOnReconnect) { + pending.awaitingReconnect = true; + return; + } + this.rejectPending( + pending, + new HermesGatewayConnectionError("Hermes gateway disconnected before send."), + false, + ); + return; + } + const frame = { + jsonrpc: "2.0", + id: pending.id, + method: pending.method, + params: pending.params, + } as const; + try { + socket.send(JSON.stringify(frame)); + pending.sent = true; + pending.awaitingReconnect = false; + pending.timeout = setTimeout(() => { + const markIndeterminate = pending.operation === "mutation"; + this.rejectPending( + pending, + markIndeterminate && pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayConnectionError(`Hermes gateway read timed out: ${pending.method}`), + markIndeterminate, + ); + }, pending.timeoutMs); + this.logger?.({ + type: "request", + method: pending.method, + requestId: pending.id, + operation: pending.operation, + }); + } catch { + this.rejectPending( + pending, + new HermesGatewayConnectionError("Hermes gateway send failed before admission."), + false, + ); + } + } + + private rejectPending(pending: PendingRequest, error: Error, markIndeterminate: boolean): void { + if (!this.pending.delete(pending.id)) return; + this.clearPendingResources(pending); + if (markIndeterminate && pending.operationId) { + const record = this.mutations.get(pending.operationId); + if (record) { + this.setMutation({ ...record, state: "indeterminate" }); + } + } + pending.reject(error); + } + + private clearPendingResources(pending: PendingRequest): void { + if (pending.timeout) clearTimeout(pending.timeout); + pending.timeout = undefined; + if (pending.abortListener) { + pending.signal?.removeEventListener("abort", pending.abortListener); + } + } + + private handleClose(_event: HermesGatewaySocketEvent, generation: number): void { + if (generation !== this.connectionGeneration) return; + this.socket = undefined; + this.rejectReady(new HermesGatewayConnectionError("Hermes gateway disconnected.")); + if (this.manuallyClosed || this.stateValue === "closed") return; + + for (const pending of this.pending.values()) { + this.clearPendingResources(pending); + if (pending.operation === "read" && pending.retryOnReconnect) { + pending.awaitingReconnect = true; + pending.sent = false; + continue; + } + this.rejectPending( + pending, + pending.operationId + ? new HermesGatewayMutationIndeterminateError(pending.operationId, pending.method) + : new HermesGatewayConnectionError("Hermes gateway disconnected."), + pending.operation === "mutation" && pending.sent, + ); + } + + const reconnecting = this.maxReconnectAttempts > 0; + this.setState(reconnecting ? "reconnecting" : "disconnected", 0); + void this.supervisor?.onDisconnected?.({ + reconnecting, + pendingReads: [...this.pending.values()].filter( + (pending) => pending.operation === "read" && pending.awaitingReconnect, + ).length, + indeterminateMutations: this.indeterminateOperationIds(), + }); + if (reconnecting && !this.reconnectTask) { + this.reconnectTask = this.reconnectLoop().finally(() => { + this.reconnectTask = undefined; + }); + } else if (!reconnecting) { + this.rejectPendingReads(new HermesGatewayConnectionError("Hermes gateway disconnected.")); + } + } + + private async reconnectLoop(): Promise { + let lastError: Error = new HermesGatewayConnectionError("Hermes gateway disconnected."); + for (let attempt = 1; attempt <= this.maxReconnectAttempts; attempt += 1) { + await delay( + Math.min(this.reconnectBaseDelayMs * 2 ** (attempt - 1), this.reconnectMaxDelayMs), + ); + if (this.manuallyClosed) return; + try { + await this.connectAttempt(attempt, true); + return; + } catch (error) { + lastError = + error instanceof Error + ? error + : new HermesGatewayConnectionError("Hermes gateway reconnect failed."); + } + } + this.setState("disconnected", this.maxReconnectAttempts); + this.rejectPendingReads(lastError); + await this.supervisor?.onReconnectExhausted?.({ + attempts: this.maxReconnectAttempts, + }); + } + + private replayPendingReads(): void { + for (const pending of this.pending.values()) { + if (pending.operation === "read" && pending.awaitingReconnect) { + this.sendPending(pending); + } + } + } + + private rejectPendingReads(error: Error): void { + for (const pending of this.pending.values()) { + if (pending.operation === "read") { + this.rejectPending(pending, error, false); + } + } + } + + private requireCapability(capability: string | undefined): void { + if (capability && !this.capabilities.has(capability)) { + throw new HermesGatewayCapabilityError(capability); + } + } + + private confirmMutation(operationId: string): void { + const record = this.mutations.get(operationId); + if (record) this.setMutation({ ...record, state: "confirmed" }); + } + + private markMutationIndeterminate(operationId: string): void { + const record = this.mutations.get(operationId); + if (record) this.setMutation({ ...record, state: "indeterminate" }); + } + + private setMutation(record: HermesGatewayMutationRecord): void { + this.mutations.set(record.operationId, record); + this.logger?.({ + type: "mutation", + operationId: "", + method: record.method, + state: record.state, + }); + this.emitHealth(); + } + + private indeterminateOperationIds(): ReadonlyArray { + return [...this.mutations.values()] + .filter((record) => record.state === "indeterminate") + .map((record) => record.operationId) + .toSorted(); + } + + private setState(state: HermesGatewayConnectionState, attempt: number): void { + this.stateValue = state; + this.reconnectAttempt = attempt; + this.logger?.({ + type: "connection", + state, + endpoint: this.endpointLabel, + attempt, + }); + this.emitHealth(); + } + + private emitHealth(): void { + const health = this.health; + for (const listener of this.healthListeners) listener(health); + } +} + +function authenticatedEndpoint(options: HermesGatewayClientOptions): string { + const assessment = assessHermesConnectionSecurity({ + endpoint: options.endpoint, + gatewayToken: options.authToken, + remoteGloballyEnabled: false, + remoteInstanceEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + }); + if (assessment.status !== "ready") { + throw new HermesGatewayConfigurationError(assessment.message); + } + const endpoint = new URL(assessment.endpoint); + endpoint.searchParams.set("token", assessment.authToken); + return endpoint.toString(); +} + +function defaultSocketFactory(endpoint: string): HermesGatewaySocket { + return new WebSocket(endpoint) as unknown as HermesGatewaySocket; +} + +function decodeResult( + schema: S, + value: unknown, + method: string, +): Schema.Schema.Type { + try { + return Schema.decodeUnknownSync(schema as never)(value) as Schema.Schema.Type; + } catch { + throw new HermesGatewayProtocolError(`Hermes gateway returned malformed ${method} result.`); + } +} + +function decodeOptionalMutationOutcome(value: unknown): HermesGatewayMutationOutcome | undefined { + try { + return decodeMutationOutcome(value); + } catch { + return undefined; + } +} + +function capabilityEnabled(value: unknown): boolean { + if (value === null || value === undefined || value === false) return false; + if (typeof value === "string") { + return !["", "disabled", "unsupported", "unavailable", "false"].includes( + value.trim().toLowerCase(), + ); + } + if (typeof value === "object" && !Array.isArray(value)) { + const enabled = (value as Readonly>).enabled; + return enabled !== false; + } + return true; +} + +function positive(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isFinite(value) && value > 0 ? value : fallback; +} + +function nonNegative(value: number | undefined, fallback: number): number { + return value !== undefined && Number.isInteger(value) && value >= 0 ? value : fallback; +} + +function delay(milliseconds: number): Promise { + return new Promise((resolve) => setTimeout(resolve, milliseconds)); +} diff --git a/apps/server/src/hermes/HermesHistoryNormalization.test.ts b/apps/server/src/hermes/HermesHistoryNormalization.test.ts new file mode 100644 index 00000000000..44abfd7af84 --- /dev/null +++ b/apps/server/src/hermes/HermesHistoryNormalization.test.ts @@ -0,0 +1,294 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { it as effectIt } from "@effect/vitest"; +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; + +import { + hermesHistoryMediaRoots, + normalizeHermesHistoryMessage, + parseHermesHistoryText, + persistHermesHistoryMedia, +} from "./HermesHistoryNormalization.ts"; + +const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0]); +const MP4_BYTES = Uint8Array.from([0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]); + +describe("Hermes imported history normalization", () => { + it("parses the installed sender, image-path, and screenshot persistence envelope", () => { + const path = "/Users/maria/.hermes/cache/images/img_c2f562760fbb.webp"; + expect( + parseHermesHistoryText({ + role: "user", + text: `[maria] rewrite better\n\n[Image attached at: ${path}]\n[screenshot]`, + }), + ).toEqual({ + text: "rewrite better", + media: [{ kind: "image", path }], + }); + }); + + it("preserves assistant text, reply envelopes, multiline content, and ordinary bracketed prose", () => { + const reply = ['[Replying to: "earlier message"]', "keep this", "and this"].join("\n"); + expect(parseHermesHistoryText({ role: "user", text: reply })).toEqual({ + text: reply, + media: [], + }); + expect(parseHermesHistoryText({ role: "user", text: "[Important] keep this" }).text).toBe( + "[Important] keep this", + ); + expect(parseHermesHistoryText({ role: "assistant", text: "[maria] assistant text" }).text).toBe( + "[maria] assistant text", + ); + expect( + parseHermesHistoryText({ + role: "user", + text: "[maria|transport-user-id]\nfirst line\nsecond line", + }).text, + ).toBe("first line\nsecond line"); + }); + + it("extracts upstream MEDIA directives from labeled assistant output", () => { + const root = "/Users/maria/Downloads/kimi-thumbnail-renditions"; + expect( + parseHermesHistoryText({ + role: "assistant", + text: [ + "Made 3 renditions.", + "", + `1. **Wall quote** MEDIA:${root}/01-wall-quote.jpg`, + `2. **Bottom-right** MEDIA:${root}/02-bottom-right.jpg`, + `**Comparison sheet** MEDIA:${root}/comparison-sheet.jpg`, + ].join("\n"), + }), + ).toEqual({ + text: [ + "Made 3 renditions.", + "", + "1. **Wall quote**", + "2. **Bottom-right**", + "**Comparison sheet**", + ].join("\n"), + media: [ + { kind: "image", path: `${root}/01-wall-quote.jpg` }, + { kind: "image", path: `${root}/02-bottom-right.jpg` }, + { kind: "image", path: `${root}/comparison-sheet.jpg` }, + ], + }); + }); + + it("honors quoted MEDIA paths and preserves examples in protected prose", () => { + const real = "/Users/maria/Downloads/rendered output/report.pdf"; + const text = [ + `Report MEDIA:"${real}"`, + "Use `MEDIA:/tmp/example.png` to attach an image.", + "> Example: MEDIA:/tmp/quoted.png", + "```text", + "MEDIA:/tmp/fenced.png", + "```", + 'log: {"old":"MEDIA:/tmp/stale.png"}', + ].join("\n"); + expect(parseHermesHistoryText({ role: "assistant", text })).toEqual({ + text: [ + "Report", + "Use `MEDIA:/tmp/example.png` to attach an image.", + "> Example: MEDIA:/tmp/quoted.png", + "```text", + "MEDIA:/tmp/fenced.png", + "```", + 'log: {"old":"MEDIA:/tmp/stale.png"}', + ].join("\n"), + media: [{ kind: "file", path: real }], + }); + expect( + parseHermesHistoryText({ + role: "user", + text: "Please explain MEDIA:/tmp/not-a-user-attachment.png", + }), + ).toEqual({ + text: "Please explain MEDIA:/tmp/not-a-user-attachment.png", + media: [], + }); + }); + + it("uses a safe placeholder when a screenshot has no recoverable media reference", () => { + expect(parseHermesHistoryText({ role: "user", text: "[maria] \n[screenshot]" }).text).toBe( + "[Image unavailable]", + ); + }); + + it("defaults to Hermes-owned media locations, not general user or temporary directories", () => { + const hermesHome = NodePath.join(NodePath.sep, "tmp", "hermes-profile"); + const roots = hermesHistoryMediaRoots({ hermesHome, profileKey: "default" }); + + expect(roots).toContain(NodePath.join(hermesHome, "cache", "images")); + expect(roots).toContain(NodePath.join(hermesHome, "cache", "videos")); + expect(roots).toContain(NodePath.join(hermesHome, "cache", "screenshots")); + expect(roots).toContain(NodePath.join(hermesHome, "image_cache")); + expect(roots).not.toContain(NodePath.join(hermesHome, "cache")); + expect(roots).not.toContain(NodeOS.tmpdir()); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Desktop")); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Documents")); + expect(roots).not.toContain(NodePath.join(NodeOS.homedir(), "Downloads")); + }); + + effectIt.effect( + "allows Hermes cache media and denies arbitrary user files, traversal, and symlink escapes", + () => + Effect.gen(function* () { + const temp = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-hermes-history-")), + ); + return yield* Effect.gen(function* () { + const hermesHome = NodePath.join(temp, "profile"); + const cache = NodePath.join(hermesHome, "cache", "images"); + const attachmentsDir = NodePath.join(temp, "t3", "attachments"); + const outside = NodePath.join(temp, "outside.png"); + const image = NodePath.join(cache, "img_fixture.webp"); + const video = NodePath.join(hermesHome, "cache", "videos", "clip.mp4"); + const desktop = NodePath.join(temp, "Desktop", "predictable.png"); + const documents = NodePath.join(temp, "Documents", "predictable.png"); + const downloads = NodePath.join(temp, "Downloads", "predictable.png"); + const arbitraryTemp = NodePath.join(temp, "predictable-tmp.png"); + const traversal = `${cache}${NodePath.sep}..${NodePath.sep}..${NodePath.sep}..${NodePath.sep}outside.png`; + const unsupported = NodePath.join(cache, "not-media.txt"); + const oversized = NodePath.join(cache, "oversized.png"); + const symlink = NodePath.join(cache, "escaped.webp"); + NodeFS.mkdirSync(cache, { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(video), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(desktop), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(documents), { recursive: true }); + NodeFS.mkdirSync(NodePath.dirname(downloads), { recursive: true }); + NodeFS.writeFileSync(image, PNG_BYTES); + NodeFS.writeFileSync(video, MP4_BYTES); + NodeFS.writeFileSync(outside, PNG_BYTES); + NodeFS.writeFileSync(desktop, PNG_BYTES); + NodeFS.writeFileSync(documents, PNG_BYTES); + NodeFS.writeFileSync(downloads, PNG_BYTES); + NodeFS.writeFileSync(arbitraryTemp, PNG_BYTES); + NodeFS.writeFileSync(unsupported, "not media"); + NodeFS.writeFileSync(oversized, PNG_BYTES); + NodeFS.truncateSync(oversized, 20 * 1024 * 1024 + 1); + NodeFS.symlinkSync(outside, symlink); + + const persist = (sourcePath: string, expectedKind: "image" | "video" = "image") => + persistHermesHistoryMedia({ + sourcePath, + expectedKind, + approvedRoots: hermesHistoryMediaRoots({ + hermesHome, + profileKey: "default", + }), + attachmentsDir, + threadId: "thread:hermes:imported", + stableKey: "history-message-1:0", + }); + const first = yield* persist(image); + NodeFS.unlinkSync(image); + const replay = yield* persist(image); + + expect(first).toMatchObject({ + type: "image", + name: "img_fixture.webp", + mimeType: "image/png", + sizeBytes: PNG_BYTES.byteLength, + }); + expect(yield* persist(video, "video")).toMatchObject({ + type: "video", + name: "clip.mp4", + mimeType: "video/mp4", + sizeBytes: MP4_BYTES.byteLength, + }); + expect(replay).toEqual(first); + expect(NodeFS.readdirSync(attachmentsDir)).toHaveLength(2); + expect(yield* persist(outside)).toBeNull(); + expect(yield* persist(desktop)).toBeNull(); + expect(yield* persist(documents)).toBeNull(); + expect(yield* persist(downloads)).toBeNull(); + expect(yield* persist(arbitraryTemp)).toBeNull(); + expect(yield* persist(traversal)).toBeNull(); + expect(yield* persist(symlink)).toBeNull(); + expect(yield* persist(unsupported)).toBeNull(); + expect(yield* persist(oversized)).toBeNull(); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(temp, { recursive: true, force: true }))), + ); + }), + ); + + effectIt.effect("persists an approved generated PDF and generic document", () => + Effect.gen(function* () { + const temp = yield* Effect.sync(() => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-hermes-media-output-")), + ); + return yield* Effect.gen(function* () { + const output = NodePath.join(temp, "output"); + const attachmentsDir = NodePath.join(temp, "attachments"); + const pdf = NodePath.join(output, "report.pdf"); + const markdown = NodePath.join(output, "notes.md"); + NodeFS.mkdirSync(output, { recursive: true }); + NodeFS.writeFileSync(pdf, "%PDF-1.7\nfixture"); + NodeFS.writeFileSync(markdown, "# Fixture\n"); + const approvedRoots = hermesHistoryMediaRoots({ + hermesHome: NodePath.join(temp, "hermes"), + profileKey: "default", + extraRoots: [output], + }); + const persist = (sourcePath: string, stableKey: string) => + persistHermesHistoryMedia({ + sourcePath, + expectedKind: "file", + approvedRoots, + attachmentsDir, + threadId: "thread:hermes:media-output", + stableKey, + }); + + expect(yield* persist(pdf, "pdf")).toMatchObject({ + type: "pdf", + mimeType: "application/pdf", + name: "report.pdf", + }); + expect(yield* persist(markdown, "markdown")).toMatchObject({ + type: "file", + mimeType: "text/markdown", + name: "notes.md", + }); + }).pipe( + Effect.ensuring(Effect.sync(() => NodeFS.rmSync(temp, { recursive: true, force: true }))), + ); + }), + ); + + effectIt.effect("degrades missing media without leaking its path", () => + Effect.gen(function* () { + const rawPath = "/Users/maria/.hermes/cache/images/missing-secret.webp"; + const result = yield* normalizeHermesHistoryMessage({ + role: "user", + text: `[maria] see this\n[Image attached at: ${rawPath}]\n[screenshot]`, + resolveMedia: () => Effect.succeed(null), + }); + expect(result).toEqual({ text: "see this\n\n[Image unavailable]", attachments: [] }); + expect(result.text).not.toContain(rawPath); + }), + ); + + effectIt.effect("hides an unsupported assistant MEDIA path and degrades safely", () => + Effect.gen(function* () { + const rawPath = "/Users/maria/Downloads/generated/Caddyfile"; + const result = yield* normalizeHermesHistoryMessage({ + role: "assistant", + text: `Generated the configuration.\nMEDIA:${rawPath}`, + resolveMedia: () => Effect.succeed(null), + }); + expect(result).toEqual({ + text: "Generated the configuration.\n\n[File unavailable]", + attachments: [], + }); + expect(result.text).not.toContain(rawPath); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesHistoryNormalization.ts b/apps/server/src/hermes/HermesHistoryNormalization.ts new file mode 100644 index 00000000000..8c407a58773 --- /dev/null +++ b/apps/server/src/hermes/HermesHistoryNormalization.ts @@ -0,0 +1,601 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + ChatAttachmentId, + type ChatAttachment, + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { + attachmentRelativePath, + createDeterministicAttachmentId, + resolveAttachmentPathById, +} from "../attachmentStore.ts"; + +export type HermesHistoryMediaKind = "image" | "audio" | "video" | "file"; + +export interface HermesHistoryMediaReference { + readonly kind: HermesHistoryMediaKind; + readonly path: string; +} + +export interface ParsedHermesHistoryText { + readonly text: string; + readonly media: ReadonlyArray; +} + +const MEDIA_REFERENCE_LINE = + /^\[(Image attached at|User sent an image|User sent audio|User sent a video|User sent a file):\s*(.+)\]$/i; +const HERMES_MEDIA_DELIVERY_EXTENSIONS = [ + "jpeg", + "docx", + "webp", + "tiff", + "flac", + "pptx", + "xlsx", + "html", + "yaml", + "epub", + "opus", + "json", + "webm", + "m4a", + "odt", + "rtf", + "txt", + "csv", + "xml", + "yml", + "ppt", + "odp", + "key", + "tar", + "tgz", + "bz2", + "apk", + "ipa", + "png", + "jpg", + "gif", + "bmp", + "svg", + "mp4", + "mov", + "avi", + "mkv", + "mp3", + "wav", + "ogg", + "pdf", + "doc", + "md", + "xls", + "ods", + "tsv", + "zip", + "htm", + "gz", + "xz", + "7z", + "rar", +] as const; +const HERMES_MEDIA_DELIVERY_EXTENSION_PATTERN = HERMES_MEDIA_DELIVERY_EXTENSIONS.join("|"); +const HERMES_MEDIA_TAG = new RegExp( + [ + String.raw`["']?MEDIA:\s*(?`, + String.raw`\x60[^\x60\n]+\x60`, + String.raw`|"[^"\n]+"|'[^'\n]+'|(?:~\/|\/|[A-Za-z]:[/\\])\S+(?:[^\S\n]+\S+)*?\.(?:${HERMES_MEDIA_DELIVERY_EXTENSION_PATTERN}))(?=[\s\x60"',;:)\]}]|$)["']?`, + ].join(""), + "giu", +); +const HERMES_MEDIA_FALLBACK_TAG = + /["']?MEDIA:\s*(?(?:~\/|\/|[A-Za-z]:[/\\])[^\s\n`"']+)["']?/giu; +const REDUNDANT_IMAGE_PLACEHOLDER_LINE = /^\[(?:image|screenshot)\]$/i; +const TRANSPORT_SENDER_PREFIX = /^\[([^\]\r\n]{1,80})\](?:[ \t]+|\r?\n)/; +const RESERVED_TRANSPORT_LABELS = new Set([ + "attachment", + "image", + "important", + "info", + "note", + "reply", + "replying to", + "screenshot", + "system", + "todo", + "warning", +]); + +function mediaKind(label: string): HermesHistoryMediaKind { + const normalized = label.toLowerCase(); + if (normalized.includes("image")) return "image"; + if (normalized.includes("audio")) return "audio"; + if (normalized.includes("video")) return "video"; + return "file"; +} + +function mediaKindForPath(path: string): HermesHistoryMediaKind { + const extension = NodePath.extname(path).toLowerCase(); + if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff"].includes(extension)) { + return "image"; + } + if ([".mp4", ".mov", ".avi", ".mkv", ".webm"].includes(extension)) return "video"; + if ([".mp3", ".wav", ".ogg", ".opus", ".m4a", ".flac"].includes(extension)) return "audio"; + return "file"; +} + +function normalizedMediaTagPath(raw: string): string { + let path = raw.trim(); + if ( + path.length >= 2 && + path[0] === path[path.length - 1] && + (path[0] === "`" || path[0] === '"' || path[0] === "'") + ) { + path = path.slice(1, -1).trim(); + } + return path.replace(/^[`"']+/u, "").replace(/[`"',.;:)}\]]+$/u, ""); +} + +function protectedMediaSpans(text: string): ReadonlyArray { + const spans: Array = []; + for (const match of text.matchAll(/```[^\n]*\n[\s\S]*?```/gu)) { + spans.push([match.index, match.index + match[0].length]); + } + for (const match of text.matchAll(/`[^`\n]+`/gu)) { + const prefix = text.slice(Math.max(0, match.index - 20), match.index); + if (!/MEDIA:\s*$/iu.test(prefix)) { + spans.push([match.index, match.index + match[0].length]); + } + } + for (const match of text.matchAll(/^>.*$/gmu)) { + spans.push([match.index, match.index + match[0].length]); + } + // Hermes deliberately ignores MEDIA references embedded in serialized tool + // result values so replaying stored JSON cannot re-deliver an old local file. + for (const match of text.matchAll(/(?<=:|,|\{|\[)\s*"((?:[^"\\\n]|\\.)*)"/gu)) { + const body = match[1]; + if (body?.match(/MEDIA:\s*(?:~\/|\/|[A-Za-z]:[/\\])/u)) { + spans.push([match.index, match.index + match[0].length]); + } + } + return spans; +} + +function isProtectedSpan( + start: number, + end: number, + spans: ReadonlyArray, +): boolean { + return spans.some( + ([protectedStart, protectedEnd]) => start < protectedEnd && end > protectedStart, + ); +} + +function extractAssistantMedia(text: string): ParsedHermesHistoryText { + if (!text.includes("MEDIA:")) { + return { + text: text.replaceAll("[[audio_as_voice]]", "").replaceAll("[[as_document]]", "").trim(), + media: [], + }; + } + const media: HermesHistoryMediaReference[] = []; + const removalSpans: Array = []; + const protectedSpans = protectedMediaSpans(text); + for (const match of text.matchAll(HERMES_MEDIA_TAG)) { + const path = normalizedMediaTagPath(match.groups?.path ?? ""); + const start = match.index; + const end = start + match[0].length; + if (!path || isProtectedSpan(start, end, protectedSpans)) continue; + media.push({ kind: mediaKindForPath(path), path }); + removalSpans.push([start, end]); + } + // Upstream also accepts extension-less and unknown-extension files after + // filesystem validation. Capturing the conservative, no-whitespace form + // here prevents a partially streamed local path from flashing in the UI; + // persistence remains the authority on whether it is safe and supported. + for (const match of text.matchAll(HERMES_MEDIA_FALLBACK_TAG)) { + const path = normalizedMediaTagPath(match.groups?.path ?? ""); + const start = match.index; + const end = start + match[0].length; + if ( + !path || + isProtectedSpan(start, end, protectedSpans) || + isProtectedSpan(start, end, removalSpans) + ) { + continue; + } + media.push({ kind: mediaKindForPath(path), path }); + removalSpans.push([start, end]); + } + + let visible = text; + for (const [start, end] of removalSpans.toSorted((left, right) => right[0] - left[0])) { + visible = `${visible.slice(0, start)}${visible.slice(end)}`; + } + visible = visible + .replaceAll("[[audio_as_voice]]", "") + .replaceAll("[[as_document]]", "") + .replace(/[ \t]+\n/gu, "\n") + .replace(/\n{3,}/gu, "\n\n") + .trim(); + return { text: visible, media }; +} + +function stripTransportSenderPrefix(text: string): string { + const match = TRANSPORT_SENDER_PREFIX.exec(text); + const label = match?.[1]?.trim(); + if ( + match === null || + label === undefined || + (!/\p{L}/u.test(label) && !label.includes("|")) || + /[:'"{}\r\n]/u.test(label) || + RESERVED_TRANSPORT_LABELS.has(label.toLowerCase()) + ) { + return text; + } + return text.slice(match[0].length); +} + +/** + * Parses display artifacts emitted by Hermes transports plus its outbound + * MEDIA protocol. Protected examples remain prose, while real assistant/tool + * directives and native-image persistence lines become attachment references. + */ +export function parseHermesHistoryText(input: { + readonly role: "user" | "assistant" | "tool" | "system"; + readonly text: string; +}): ParsedHermesHistoryText { + if (input.role === "assistant" || input.role === "tool") { + return extractAssistantMedia(input.text); + } + if (input.role !== "user") return { text: input.text, media: [] }; + + const media: HermesHistoryMediaReference[] = []; + let sawScreenshotPlaceholder = false; + const visibleLines: string[] = []; + for (const line of input.text.split(/\r?\n/u)) { + const match = MEDIA_REFERENCE_LINE.exec(line.trim()); + if (match?.[1] !== undefined && match[2] !== undefined) { + media.push({ kind: mediaKind(match[1]), path: match[2].trim() }); + continue; + } + if (REDUNDANT_IMAGE_PLACEHOLDER_LINE.test(line.trim())) { + sawScreenshotPlaceholder = true; + continue; + } + visibleLines.push(line); + } + + let text = stripTransportSenderPrefix(visibleLines.join("\n")).trim(); + if (sawScreenshotPlaceholder && media.length === 0) { + text = [text, "[Image unavailable]"].filter(Boolean).join("\n\n"); + } + return { text, media }; +} + +function isPathInsideRoot(root: string, candidate: string): boolean { + const relative = NodePath.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${NodePath.sep}`) && relative !== ".."); +} + +function detectSupportedMedia(bytes: Uint8Array): { + readonly type: ChatAttachment["type"]; + readonly mimeType: string; +} | null { + const ascii = (start: number, end: number) => + Buffer.from(bytes.subarray(start, end)).toString("ascii"); + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + ascii(1, 4) === "PNG" && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return { type: "image", mimeType: "image/png" }; + } + if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return { type: "image", mimeType: "image/jpeg" }; + } + if (ascii(0, 6) === "GIF87a" || ascii(0, 6) === "GIF89a") { + return { type: "image", mimeType: "image/gif" }; + } + if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") { + return { type: "image", mimeType: "image/webp" }; + } + if (ascii(0, 2) === "BM") return { type: "image", mimeType: "image/bmp" }; + if (ascii(0, 4) === "II*\0" || ascii(0, 4) === "MM\0*") { + return { type: "image", mimeType: "image/tiff" }; + } + if (ascii(0, 5) === "%PDF-") return { type: "pdf", mimeType: "application/pdf" }; + if (ascii(4, 8) === "ftyp") { + const brand = ascii(8, 12); + if (/^(?:M4A |M4B |M4P )$/u.test(brand)) { + return { type: "file", mimeType: "audio/mp4" }; + } + return { + type: "video", + mimeType: brand === "qt " ? "video/quicktime" : "video/mp4", + }; + } + if (bytes.length >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf) { + return { type: "video", mimeType: "video/webm" }; + } + if (ascii(0, 4) === "OggS") return { type: "file", mimeType: "audio/ogg" }; + if (ascii(0, 4) === "fLaC") return { type: "file", mimeType: "audio/flac" }; + if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WAVE") { + return { type: "file", mimeType: "audio/wav" }; + } + if ( + ascii(0, 3) === "ID3" || + (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1]! & 0xe0) === 0xe0) + ) { + return { type: "file", mimeType: "audio/mpeg" }; + } + return null; +} + +const GENERIC_FILE_MIME_BY_EXTENSION: Readonly> = { + ".7z": "application/x-7z-compressed", + ".apk": "application/vnd.android.package-archive", + ".bz2": "application/x-bzip2", + ".csv": "text/csv", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".epub": "application/epub+zip", + ".flac": "audio/flac", + ".gz": "application/gzip", + ".html": "text/html", + ".htm": "text/html", + ".ipa": "application/octet-stream", + ".json": "application/json", + ".key": "application/octet-stream", + ".m4a": "audio/mp4", + ".md": "text/markdown", + ".odp": "application/vnd.oasis.opendocument.presentation", + ".ods": "application/vnd.oasis.opendocument.spreadsheet", + ".odt": "application/vnd.oasis.opendocument.text", + ".ogg": "audio/ogg", + ".opus": "audio/ogg", + ".ppt": "application/vnd.ms-powerpoint", + ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ".rar": "application/vnd.rar", + ".rtf": "application/rtf", + ".svg": "image/svg+xml", + ".tar": "application/x-tar", + ".tgz": "application/gzip", + ".tsv": "text/tab-separated-values", + ".txt": "text/plain", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xml": "application/xml", + ".xz": "application/x-xz", + ".yaml": "application/yaml", + ".yml": "application/yaml", + ".zip": "application/zip", +}; + +function supportedGenericFile(path: string): { + readonly type: ChatAttachment["type"]; + readonly mimeType: string; +} | null { + const mimeType = GENERIC_FILE_MIME_BY_EXTENSION[NodePath.extname(path).toLowerCase()]; + return mimeType === undefined ? null : { type: "file", mimeType }; +} + +function safeAttachmentName(path: string, mimeType: string): string { + const fallback = + mimeType === "image/png" + ? "image.png" + : mimeType === "image/jpeg" + ? "image.jpg" + : mimeType === "image/webp" + ? "image.webp" + : mimeType === "application/pdf" + ? "document.pdf" + : "attachment"; + const name = Array.from(NodePath.basename(path), (character) => { + const code = character.charCodeAt(0); + return code <= 0x1f || code === 0x7f ? "_" : character; + }) + .join("") + .trim(); + return (name || fallback).slice(0, 255); +} + +export function hermesHistoryMediaRoots(input: { + readonly hermesHome?: string | undefined; + readonly profileKey: string; + readonly extraRoots?: ReadonlyArray | undefined; +}): ReadonlyArray { + const defaultHome = NodePath.join(NodeOS.homedir(), ".hermes"); + const configuredHome = NodePath.resolve(input.hermesHome?.trim() || defaultHome); + const homes = new Set([configuredHome]); + if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(input.profileKey) && input.profileKey !== "default") { + homes.add(NodePath.join(configuredHome, "profiles", input.profileKey)); + } + const roots = [...homes].flatMap((home) => [ + NodePath.join(home, "cache", "images"), + NodePath.join(home, "cache", "audio"), + NodePath.join(home, "cache", "videos"), + NodePath.join(home, "cache", "video"), + NodePath.join(home, "cache", "documents"), + NodePath.join(home, "cache", "screenshots"), + NodePath.join(home, "cache", "vision"), + NodePath.join(home, "image_cache"), + NodePath.join(home, "audio_cache"), + NodePath.join(home, "video_cache"), + NodePath.join(home, "document_cache"), + NodePath.join(home, "browser_screenshots"), + NodePath.join(home, "temp_video_files"), + NodePath.join(home, "temp_vision_images"), + ]); + // HERMES_MEDIA_ALLOW_DIRS is an explicit, provider-instance-scoped operator + // extension point. Deliberately do not trust general user output locations + // or the entire Hermes home/cache tree. + for (const root of input.extraRoots ?? []) { + const expanded = + root === "~" + ? NodeOS.homedir() + : root.startsWith(`~${NodePath.sep}`) + ? NodePath.join(NodeOS.homedir(), root.slice(2)) + : root; + if (NodePath.isAbsolute(expanded)) roots.push(NodePath.resolve(expanded)); + } + return [...new Set(roots)]; +} + +export const persistHermesHistoryMedia = Effect.fn("persistHermesHistoryMedia")(function* (input: { + readonly sourcePath: string; + readonly expectedKind: HermesHistoryMediaKind; + readonly approvedRoots: ReadonlyArray; + readonly attachmentsDir: string; + readonly threadId: string; + readonly stableKey: string; +}): Effect.fn.Return { + if (input.sourcePath.includes("\0")) return null; + + return yield* Effect.tryPromise({ + try: async () => { + const expandedSourcePath = + input.sourcePath === "~" + ? NodeOS.homedir() + : input.sourcePath.startsWith(`~${NodePath.sep}`) + ? NodePath.join(NodeOS.homedir(), input.sourcePath.slice(2)) + : input.sourcePath; + if (!NodePath.isAbsolute(expandedSourcePath)) return null; + const resolvedSourcePath = NodePath.resolve(expandedSourcePath); + const configuredRoots = input.approvedRoots.map((root) => NodePath.resolve(root)); + if (!configuredRoots.some((root) => isPathInsideRoot(root, resolvedSourcePath))) return null; + const rawId = createDeterministicAttachmentId( + input.threadId, + `${input.stableKey}:${resolvedSourcePath}`, + ); + if (rawId === null) return null; + + const attachmentFromBytes = ( + bytes: Uint8Array, + sourceName: string, + ): ChatAttachment | null => { + const detected = detectSupportedMedia(bytes) ?? supportedGenericFile(sourceName); + if ( + detected === null || + (input.expectedKind === "image" && detected.type !== "image") || + (input.expectedKind === "video" && detected.type !== "video") || + (input.expectedKind === "audio" && !detected.mimeType.startsWith("audio/")) + ) { + return null; + } + const maxBytes = + detected.type === "image" + ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES + : PROVIDER_SEND_TURN_MAX_FILE_BYTES; + if (bytes.byteLength > maxBytes) return null; + return { + type: detected.type, + id: ChatAttachmentId.make(rawId), + name: safeAttachmentName(sourceName, detected.mimeType), + mimeType: detected.mimeType, + sizeBytes: bytes.byteLength, + }; + }; + + // Hermes prunes transport caches. Once T3 has safely imported a file, + // keep using that durable copy even if the original cache entry is gone. + const existingPath = resolveAttachmentPathById({ + attachmentsDir: input.attachmentsDir, + attachmentId: rawId, + }); + if (existingPath !== null) { + const existingStat = await NodeFS.promises.stat(existingPath); + if (existingStat.isFile() && existingStat.size <= PROVIDER_SEND_TURN_MAX_FILE_BYTES) { + const existingAttachment = attachmentFromBytes( + await NodeFS.promises.readFile(existingPath), + input.sourcePath, + ); + if (existingAttachment !== null) return existingAttachment; + } + } + + const canonicalRoots = ( + await Promise.all( + configuredRoots.map((root) => NodeFS.promises.realpath(root).catch(() => null)), + ) + ).filter((root): root is string => root !== null); + const canonicalSource = await NodeFS.promises.realpath(resolvedSourcePath); + if (!canonicalRoots.some((root) => isPathInsideRoot(root, canonicalSource))) return null; + + const before = await NodeFS.promises.stat(canonicalSource); + if (!before.isFile() || before.size > PROVIDER_SEND_TURN_MAX_FILE_BYTES) return null; + const handle = await NodeFS.promises.open( + canonicalSource, + NodeFS.constants.O_RDONLY | (NodeFS.constants.O_NOFOLLOW ?? 0), + ); + let bytes: Uint8Array; + try { + const opened = await handle.stat(); + if ( + !opened.isFile() || + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.size !== before.size + ) { + return null; + } + bytes = await handle.readFile(); + } finally { + await handle.close(); + } + + const attachment = attachmentFromBytes(bytes, canonicalSource); + if (attachment === null) return null; + await NodeFS.promises.mkdir(input.attachmentsDir, { recursive: true }); + await NodeFS.promises.writeFile( + NodePath.join(input.attachmentsDir, attachmentRelativePath(attachment)), + bytes, + ); + return attachment; + }, + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); +}); + +export const normalizeHermesHistoryMessage = Effect.fn("normalizeHermesHistoryMessage")( + function* (input: { + readonly role: "user" | "assistant" | "tool" | "system"; + readonly text: string; + readonly resolveMedia: ( + media: HermesHistoryMediaReference, + index: number, + ) => Effect.Effect; + }) { + const parsed = parseHermesHistoryText({ role: input.role, text: input.text }); + const resolved = yield* Effect.forEach( + parsed.media.map((media, index) => ({ media, index })), + ({ media, index }) => input.resolveMedia(media, index), + { concurrency: 2 }, + ); + const attachments = resolved.filter( + (attachment): attachment is ChatAttachment => attachment !== null, + ); + const missingKinds = parsed.media + .filter((_, index) => resolved[index] === null) + .map((media) => media.kind); + const placeholders = [...new Set(missingKinds)].map((kind) => + kind === "image" + ? "[Image unavailable]" + : `[${kind[0]!.toUpperCase()}${kind.slice(1)} unavailable]`, + ); + return { + text: [parsed.text, ...placeholders].filter(Boolean).join("\n\n"), + attachments, + }; + }, +); diff --git a/apps/server/src/hermes/HermesOperational.test.ts b/apps/server/src/hermes/HermesOperational.test.ts new file mode 100644 index 00000000000..81812d3f054 --- /dev/null +++ b/apps/server/src/hermes/HermesOperational.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vite-plus/test"; +import { HermesSettings, type HermesGatewayCompatibility } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { + assessHermesOnboarding, + buildHermesOperationalDiagnostics, + deriveHermesUpgradeGate, + projectHermesFeatureDiagnostics, + projectHermesRecoveryControls, + sanitizeHermesImportProgress, +} from "./HermesOperational.ts"; + +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const settings = decodeHermesSettings({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "work", +}); + +const supported: HermesGatewayCompatibility = { + status: "supported", + protocol: { major: 1, minor: 3 }, + capabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "attachments.image", + ], + inventory: null, + reason: "supported", + serverVersion: "1.3.0", +}; + +describe("Hermes operational onboarding and gates", () => { + it("validates local onboarding without exposing the gateway token", () => { + const result = assessHermesOnboarding(settings, { + gatewayToken: "private-gateway-token", + remoteGloballyEnabled: false, + remotePairingToken: undefined, + remoteTlsCertificateSha256: undefined, + }); + + expect(result.status).toBe("ready"); + expect(result.diagnosticEndpoint).not.toContain("private-gateway-token"); + }); + + it("requires an upgrade for unsupported protocols or missing recovery capabilities", () => { + expect( + deriveHermesUpgradeGate({ + ...supported, + protocol: { major: 2, minor: 0 }, + status: "unsupported", + }).status, + ).toBe("upgrade_required"); + expect( + deriveHermesUpgradeGate({ + ...supported, + capabilities: ["session.lifecycle"], + }), + ).toMatchObject({ + status: "upgrade_required", + missingCapabilities: ["session.history", "turn.prompt", "turn.interrupt"], + }); + }); + + it("keeps optional features off unless both instance switch and capability agree", () => { + const enabled = decodeHermesSettings({ + ...settings, + attachmentsEnabled: true, + importEnabled: true, + proactiveEnabled: true, + }); + const diagnostics = projectHermesFeatureDiagnostics(enabled, supported); + + expect(diagnostics.find((entry) => entry.feature === "attachments")).toMatchObject({ + requested: true, + available: true, + }); + expect(diagnostics.find((entry) => entry.feature === "import")).toMatchObject({ + requested: true, + available: false, + missingCapabilities: ["profile.import"], + }); + expect(diagnostics.find((entry) => entry.feature === "proactive")).toMatchObject({ + requested: true, + available: false, + }); + }); + + it("recognizes the negotiated ephemeral session MCP lease capability", () => { + const enabled = decodeHermesSettings({ + ...settings, + mcpEnabled: true, + }); + const diagnostics = projectHermesFeatureDiagnostics(enabled, { + ...supported, + capabilities: [...supported.capabilities, "session_mcp"], + }); + + expect(diagnostics.find((entry) => entry.feature === "mcp")).toMatchObject({ + requested: true, + available: true, + missingCapabilities: [], + }); + }); +}); + +describe("Hermes recovery and sanitized diagnostics", () => { + it("never offers process stop for an externally started gateway", () => { + const controls = projectHermesRecoveryControls({ + connectionState: "ready", + compatibility: supported, + processOwnership: "external", + ownedProcessStopAvailable: true, + }); + + expect(controls.find((control) => control.control === "reconnect")?.supported).toBe(true); + expect(controls.find((control) => control.control === "stop_owned_process")).toMatchObject({ + supported: false, + reason: "Hermes was started externally; T3 does not own or stop this process.", + }); + expect(controls.find((control) => control.control === "revoke_all")?.supported).toBe(false); + }); + + it("redacts every endpoint query value and emits counts instead of import details", () => { + const diagnostics = buildHermesOperationalDiagnostics({ + endpoint: "ws://127.0.0.1:9119/api/ws?token=secret&label=private", + profileKey: "work", + settings, + compatibility: supported, + connection: { + state: "ready", + reconnectAttempt: 0, + protocolStatus: "supported", + protocolMajor: 1, + protocolMinor: 3, + serverVersion: "1.3.0", + capabilities: supported.capabilities, + writesBlocked: false, + indeterminateMutationCount: 0, + }, + }); + const progress = sanitizeHermesImportProgress({ + status: "running", + completed: 3, + total: 10, + attempt: 1, + path: "/private/profile", + error: "secret", + }); + + expect(diagnostics.endpoint).not.toContain("secret"); + expect(diagnostics.endpoint).not.toContain("private"); + expect(diagnostics.processOwnership).toBe("external"); + expect(progress).toEqual({ + status: "running", + completed: 3, + total: 10, + attempt: 1, + canRetry: false, + canCancel: true, + }); + }); +}); diff --git a/apps/server/src/hermes/HermesOperational.ts b/apps/server/src/hermes/HermesOperational.ts new file mode 100644 index 00000000000..49477e875c2 --- /dev/null +++ b/apps/server/src/hermes/HermesOperational.ts @@ -0,0 +1,279 @@ +import type { HermesGatewayCompatibility, HermesSettings } from "@t3tools/contracts"; + +import type { HermesGatewayConnectionState, HermesGatewayHealth } from "./HermesGatewayClient.ts"; +import { + assessHermesConnectionSecurity, + sanitizeHermesEndpoint, + type HermesConnectionSecurityInput, +} from "./HermesConnectionSecurity.ts"; + +export const HERMES_CORE_CAPABILITIES = [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", +] as const; + +export type HermesFeatureName = "remote" | "import" | "mcp" | "attachments" | "proactive" | "voice"; + +export interface HermesFeatureDiagnostic { + readonly feature: HermesFeatureName; + readonly requested: boolean; + readonly available: boolean; + readonly missingCapabilities: ReadonlyArray; + readonly reason: string; +} + +export type HermesUpgradeGate = + | { readonly status: "ready"; readonly reason: string } + | { readonly status: "degraded"; readonly reason: string } + | { + readonly status: "upgrade_required"; + readonly reason: string; + readonly missingCapabilities: ReadonlyArray; + }; + +export type HermesRecoveryControlName = + | "reconnect" + | "revoke_all" + | "quarantine" + | "pause_ingestion" + | "stop_owned_process"; + +export interface HermesRecoveryControl { + readonly control: HermesRecoveryControlName; + readonly supported: boolean; + readonly reason: string; +} + +export interface HermesOperationalDiagnostics { + readonly connection: HermesGatewayHealth; + readonly endpoint: string; + readonly profileConfigured: boolean; + readonly upgradeGate: HermesUpgradeGate; + readonly features: ReadonlyArray; + readonly recoveryControls: ReadonlyArray; + readonly processOwnership: "external" | "t3_owned"; +} + +export interface HermesImportProgress { + readonly status: "pending" | "running" | "completed" | "failed" | "cancelled" | "unknown"; + readonly completed: number | null; + readonly total: number | null; + readonly attempt: number | null; + readonly canRetry: boolean; + readonly canCancel: boolean; +} + +const FEATURE_CAPABILITIES: Readonly< + Record, ReadonlyArray> +> = { + import: ["profile.import"], + mcp: ["session_mcp"], + proactive: ["cron.events.global_cursor", "events.stable_ids"], + voice: ["voice"], +}; + +const RECOVERY_CAPABILITIES: Readonly< + Record, string> +> = { + revoke_all: "auth.revoke_all", + quarantine: "gateway.quarantine", + pause_ingestion: "ingestion.pause", +}; + +export function assessHermesOnboarding( + settings: HermesSettings, + security: Omit, +) { + return assessHermesConnectionSecurity({ + ...security, + endpoint: settings.endpoint, + remoteInstanceEnabled: settings.remoteAccessEnabled, + }); +} + +export function deriveHermesUpgradeGate( + compatibility: HermesGatewayCompatibility | undefined, +): HermesUpgradeGate { + if (compatibility === undefined) { + return { + status: "degraded", + reason: "Gateway version and capabilities have not been negotiated.", + }; + } + if (compatibility.status === "unsupported") { + return { + status: "upgrade_required", + reason: compatibility.reason, + missingCapabilities: [], + }; + } + if (compatibility.status === "legacy") { + return { + status: "degraded", + reason: + "Gateway does not advertise a protocol version; optional and destructive operations remain unavailable.", + }; + } + const available = new Set(compatibility.capabilities); + const missingCapabilities = HERMES_CORE_CAPABILITIES.filter( + (capability) => !available.has(capability), + ); + return missingCapabilities.length === 0 + ? { status: "ready", reason: compatibility.reason } + : { + status: "upgrade_required", + reason: "Gateway is missing capabilities required for safe session recovery.", + missingCapabilities, + }; +} + +export function projectHermesFeatureDiagnostics( + settings: HermesSettings, + compatibility: HermesGatewayCompatibility | undefined, +): ReadonlyArray { + const available = new Set(compatibility?.capabilities ?? []); + const requested: Readonly> = { + remote: settings.remoteAccessEnabled, + import: settings.importEnabled, + mcp: settings.mcpEnabled, + attachments: settings.attachmentsEnabled, + proactive: settings.proactiveEnabled, + voice: settings.voiceEnabled, + }; + const required = (feature: HermesFeatureName): ReadonlyArray => { + if (feature === "remote") return []; + if (feature === "attachments") { + return ["attachments.image|attachments.file|attachments.pdf"]; + } + return FEATURE_CAPABILITIES[feature]; + }; + return (Object.keys(requested) as HermesFeatureName[]).map((feature) => { + const missingCapabilities = + feature === "attachments" + ? [...available].some((capability) => capability.startsWith("attachments.")) + ? [] + : required(feature) + : required(feature).filter((capability) => !available.has(capability)); + const isAvailable = + feature === "remote" + ? false + : compatibility?.status === "supported" && missingCapabilities.length === 0; + const reason = !requested[feature] + ? "Disabled for this instance." + : feature === "remote" + ? "Remote transport remains blocked until scoped pairing and TLS pin verification are implemented." + : isAvailable + ? "Enabled and advertised by the gateway." + : compatibility?.status === "legacy" + ? "Unavailable without a negotiated capability inventory." + : "Requested but not advertised by the gateway."; + return { + feature, + requested: requested[feature], + available: requested[feature] && isAvailable, + missingCapabilities, + reason, + }; + }); +} + +export function projectHermesRecoveryControls(input: { + readonly connectionState: HermesGatewayConnectionState; + readonly compatibility: HermesGatewayCompatibility | undefined; + readonly processOwnership: "external" | "t3_owned"; + readonly ownedProcessStopAvailable: boolean; +}): ReadonlyArray { + const capabilities = new Set(input.compatibility?.capabilities ?? []); + const controls: HermesRecoveryControl[] = [ + { + control: "reconnect", + supported: input.connectionState !== "closed", + reason: + input.connectionState === "closed" + ? "The client has been permanently closed." + : "Reconnects only the T3-owned WebSocket client; it does not restart Hermes.", + }, + ]; + for (const control of ["revoke_all", "quarantine", "pause_ingestion"] as const) { + const capability = RECOVERY_CAPABILITIES[control]; + const supported = input.compatibility?.status === "supported" && capabilities.has(capability); + controls.push({ + control, + supported, + reason: supported + ? `Gateway advertises ${capability}.` + : `Gateway does not advertise ${capability}; no operation is exposed.`, + }); + } + const canStopOwnedProcess = + input.processOwnership === "t3_owned" && input.ownedProcessStopAvailable; + controls.push({ + control: "stop_owned_process", + supported: canStopOwnedProcess, + reason: canStopOwnedProcess + ? "Available for a process launched and tracked by this T3 runtime." + : input.processOwnership === "external" + ? "Hermes was started externally; T3 does not own or stop this process." + : "No verified owned-process stop handle is available.", + }); + return controls; +} + +export function buildHermesOperationalDiagnostics(input: { + readonly endpoint: string; + readonly profileKey: string; + readonly settings: HermesSettings; + readonly connection: HermesGatewayHealth; + readonly compatibility: HermesGatewayCompatibility | undefined; + readonly processOwnership?: "external" | "t3_owned"; + readonly ownedProcessStopAvailable?: boolean; +}): HermesOperationalDiagnostics { + const processOwnership = input.processOwnership ?? "external"; + return { + connection: input.connection, + endpoint: sanitizeHermesEndpoint(input.endpoint), + profileConfigured: input.profileKey.trim().length > 0, + upgradeGate: deriveHermesUpgradeGate(input.compatibility), + features: projectHermesFeatureDiagnostics(input.settings, input.compatibility), + recoveryControls: projectHermesRecoveryControls({ + connectionState: input.connection.state, + compatibility: input.compatibility, + processOwnership, + ownedProcessStopAvailable: input.ownedProcessStopAvailable === true, + }), + processOwnership, + }; +} + +export function sanitizeHermesImportProgress(value: unknown): HermesImportProgress { + const record = isRecord(value) ? value : {}; + const status = readImportStatus(record.status); + return { + status, + completed: readNonNegativeNumber(record.completed), + total: readNonNegativeNumber(record.total), + attempt: readNonNegativeNumber(record.attempt), + canRetry: status === "failed", + canCancel: status === "pending" || status === "running", + }; +} + +function readImportStatus(value: unknown): HermesImportProgress["status"] { + return value === "pending" || + value === "running" || + value === "completed" || + value === "failed" || + value === "cancelled" + ? value + : "unknown"; +} + +function readNonNegativeNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/server/src/hermes/HermesProactiveEventRepository.test.ts b/apps/server/src/hermes/HermesProactiveEventRepository.test.ts new file mode 100644 index 00000000000..57918bfd034 --- /dev/null +++ b/apps/server/src/hermes/HermesProactiveEventRepository.test.ts @@ -0,0 +1,330 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import { + HermesProactiveEventRepository, + classifyHermesProactiveCapability, + layer as repositoryLayer, +} from "./HermesProactiveEventRepository.ts"; + +const T0 = "2026-07-25T12:00:00.000Z"; +const T1 = "2026-07-25T12:01:00.000Z"; +const T2 = "2026-07-25T12:02:00.000Z"; +const T3 = "2026-07-25T12:03:00.000Z"; +const decodeUnknownJsonString = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString); + +function testLayer(databaseLayer: Layer.Layer) { + return repositoryLayer.pipe(Layer.provideMerge(databaseLayer)); +} + +const memory = it.layer(testLayer(NodeSqliteClient.layerMemory())); + +const legacyCompatibility = { + status: "legacy" as const, + protocol: null, + capabilities: ["cron.read", "cron.manage"], + inventory: null, + reason: "Pinned gateway does not advertise protocol capabilities.", +}; + +const readyCompatibility = { + status: "supported" as const, + protocol: { + major: 1, + minor: 1, + capabilities: ["cron.events.global_cursor", "events.stable_ids"], + }, + capabilities: ["cron.events.global_cursor", "events.stable_ids"], + inventory: ["cron.events.global_cursor", "events.stable_ids"], + reason: "Future gateway advertises the required durable feed.", +}; + +function registerReady( + repository: HermesProactiveEventRepository["Service"], + profileKey = "profile:default", +) { + return repository.registerSource({ + providerInstanceId: "hermes-local", + profileKey, + compatibility: readyCompatibility, + now: T0, + }); +} + +function event(externalEventId = "event:1") { + return { + externalEventId, + externalCursor: "cursor:1", + eventKind: "cron.completed", + title: "Background task completed", + body: "A Hermes background task produced a result.", + projectId: "project:1", + threadId: null, + occurredAt: T0, + }; +} + +memory("HermesProactiveEventRepository", (it) => { + it.effect("fails closed with explicit diagnostics for the pinned legacy gateway", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* repository.registerSource({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + compatibility: legacyCompatibility, + now: T0, + }); + + assert.strictEqual(source.state, "degraded"); + assert.strictEqual(source.diagnosticCode, "missing_capability_inventory"); + assert.deepStrictEqual(source.missingCapabilities, [ + "cron.events.global_cursor", + "events.stable_ids", + ]); + + const result = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: null, + protocolMajor: null, + protocolMinor: null, + receivedAt: T1, + events: [event()], + }); + assert.deepStrictEqual(result, { + status: "degraded", + diagnosticCode: "missing_capability_inventory", + }); + + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM hermes_proactive_events + `; + assert.strictEqual(rows[0]?.count, 0); + }), + ); + + it.effect("classifies each missing upstream guarantee without optimistic fallback", () => + Effect.sync(() => { + assert.deepStrictEqual( + classifyHermesProactiveCapability({ + ...readyCompatibility, + capabilities: ["events.stable_ids"], + inventory: ["events.stable_ids"], + }), + { + state: "degraded", + diagnosticCode: "missing_durable_global_cursor", + missingCapabilities: ["cron.events.global_cursor"], + }, + ); + assert.deepStrictEqual( + classifyHermesProactiveCapability({ + ...readyCompatibility, + capabilities: ["cron.events.global_cursor"], + inventory: ["cron.events.global_cursor"], + }), + { + state: "degraded", + diagnosticCode: "missing_stable_event_ids", + missingCapabilities: ["events.stable_ids"], + }, + ); + }), + ); + + it.effect( + "atomically advances a durable cursor and deduplicates stable upstream identities", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* registerReady(repository, "profile:delivery"); + + const applied = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T1, + events: [event(), event()], + }); + assert.deepStrictEqual(applied, { + status: "applied", + inserted: 1, + duplicates: 1, + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const replay = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T2, + events: [event()], + }); + assert.deepStrictEqual(replay, { + status: "already_applied", + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const stale = yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:2", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T2, + events: [], + }); + assert.deepStrictEqual(stale, { + status: "stale_checkpoint", + checkpointCursor: "cursor:1", + checkpointSequence: 1, + }); + + const sql = yield* SqlClient.SqlClient; + const rows = yield* sql<{ + event_id: string; + provenance_json: string; + outbox_count: number; + }>` + SELECT + event.event_id, + event.provenance_json, + COUNT(outbox.outbox_id) AS outbox_count + FROM hermes_proactive_events AS event + JOIN hermes_notification_outbox AS outbox ON outbox.event_id = event.event_id + GROUP BY event.event_id, event.provenance_json + `; + assert.lengthOf(rows, 1); + assert.match(rows[0]!.event_id, /^hermes-event:[0-9a-f]{64}$/); + assert.strictEqual(rows[0]!.outbox_count, 1); + const provenance = yield* decodeUnknownJsonString(rows[0]!.provenance_json); + assert.deepInclude(provenance, { + provider: "hermes", + externalEventId: "event:1", + externalCursor: "cursor:1", + gatewayRevision: "future-revision", + }); + + const cleanupClaim = yield* repository.claimNotification({ + workerId: "worker:test-cleanup", + now: T3, + leaseExpiresAt: "2026-07-25T12:04:00.000Z", + }); + assert.isTrue(Option.isSome(cleanupClaim)); + if (Option.isSome(cleanupClaim)) { + assert.isTrue( + yield* repository.deadLetterNotification({ + outboxId: cleanupClaim.value.outboxId, + workerId: "worker:test-cleanup", + now: T3, + errorCode: "test_cleanup", + }), + ); + } + }), + ); + + it.effect( + "leases, retries, fences, and projects outbox entries into Work and in-app records", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 45 }); + const repository = yield* HermesProactiveEventRepository; + const source = yield* registerReady(repository); + yield* repository.ingestPage({ + sourceId: source.sourceId, + expectedCursor: null, + nextCursor: "cursor:1", + gatewayRevision: "future-revision", + protocolMajor: 1, + protocolMinor: 1, + receivedAt: T0, + events: [event()], + }); + + const first = yield* repository.claimNotification({ + workerId: "worker:a", + now: T0, + leaseExpiresAt: T1, + }); + assert.isTrue(Option.isSome(first)); + if (Option.isNone(first)) return; + assert.strictEqual(first.value.attemptCount, 1); + + assert.isFalse( + yield* repository.deliverInApp({ + outboxId: first.value.outboxId, + workerId: "worker:b", + now: T0, + }), + ); + assert.isTrue( + yield* repository.retryNotification({ + outboxId: first.value.outboxId, + workerId: "worker:a", + now: T0, + availableAt: T2, + errorCode: "projection_busy", + }), + ); + + const tooEarly = yield* repository.claimNotification({ + workerId: "worker:b", + now: T1, + leaseExpiresAt: T2, + }); + assert.isTrue(Option.isNone(tooEarly)); + + const retried = yield* repository.claimNotification({ + workerId: "worker:b", + now: T2, + leaseExpiresAt: T3, + }); + assert.isTrue(Option.isSome(retried)); + if (Option.isNone(retried)) return; + assert.strictEqual(retried.value.attemptCount, 2); + assert.isTrue( + yield* repository.deliverInApp({ + outboxId: retried.value.outboxId, + workerId: "worker:b", + now: T2, + }), + ); + + const workItems = yield* repository.listWorkItems(); + const notifications = yield* repository.listInAppNotifications(); + assert.lengthOf(workItems, 1); + assert.lengthOf(notifications, 1); + assert.strictEqual(workItems[0]!.eventId, retried.value.eventId); + assert.strictEqual(notifications[0]!.workItemId, workItems[0]!.workItemId); + assert.strictEqual(notifications[0]!.status, "unread"); + + const exhausted = yield* repository.claimNotification({ + workerId: "worker:c", + now: T3, + leaseExpiresAt: "2026-07-25T12:04:00.000Z", + }); + assert.isTrue(Option.isNone(exhausted)); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesProactiveEventRepository.ts b/apps/server/src/hermes/HermesProactiveEventRepository.ts new file mode 100644 index 00000000000..d7f83872830 --- /dev/null +++ b/apps/server/src/hermes/HermesProactiveEventRepository.ts @@ -0,0 +1,769 @@ +import { + HermesInAppNotification, + HermesNotificationOutboxEntry, + HermesProactiveEventProvenance, + HermesProactiveRequiredCapabilities, + HermesProactiveSourceStatus, + HermesProactiveWorkItem, + type HermesGatewayCompatibility, + type HermesNotificationOutboxEntry as HermesNotificationOutboxEntryType, + type HermesProactiveDiagnosticCode, + type HermesProactiveSourceStatus as HermesProactiveSourceStatusType, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +const decodeSource = Schema.decodeUnknownEffect(HermesProactiveSourceStatus); +const decodeOutbox = Schema.decodeUnknownEffect(HermesNotificationOutboxEntry); +const decodeWorkItem = Schema.decodeUnknownEffect(HermesProactiveWorkItem); +const decodeNotification = Schema.decodeUnknownEffect(HermesInAppNotification); +const encodeProvenance = Schema.encodeEffect(HermesProactiveEventProvenance); +const MissingCapabilitiesJson = Schema.fromJsonString(Schema.Array(Schema.String)); +const ProvenanceJson = Schema.fromJsonString(HermesProactiveEventProvenance); +const decodeMissingCapabilitiesJson = Schema.decodeUnknownSync(MissingCapabilitiesJson); +const encodeMissingCapabilitiesJson = Schema.encodeSync(MissingCapabilitiesJson); +const encodeProvenanceJson = Schema.encodeSync(ProvenanceJson); + +export interface RegisterHermesProactiveSourceInput { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly compatibility: HermesGatewayCompatibility; + readonly now: string; +} + +export interface HermesProactiveIncomingEvent { + readonly externalEventId: string; + readonly externalCursor: string; + readonly eventKind: string; + readonly title: string; + readonly body: string; + readonly projectId: string | null; + readonly threadId: string | null; + readonly occurredAt: string; +} + +export interface IngestHermesProactivePageInput { + readonly sourceId: string; + readonly expectedCursor: string | null; + readonly nextCursor: string; + readonly gatewayRevision: string | null; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly receivedAt: string; + readonly events: ReadonlyArray; +} + +export type IngestHermesProactivePageResult = + | { + readonly status: "applied"; + readonly inserted: number; + readonly duplicates: number; + readonly checkpointCursor: string; + readonly checkpointSequence: number; + } + | { + readonly status: "already_applied"; + readonly checkpointCursor: string; + readonly checkpointSequence: number; + } + | { + readonly status: "stale_checkpoint"; + readonly checkpointCursor: string | null; + readonly checkpointSequence: number; + } + | { + readonly status: "degraded"; + readonly diagnosticCode: HermesProactiveDiagnosticCode; + }; + +export interface ClaimHermesNotificationInput { + readonly workerId: string; + readonly now: string; + readonly leaseExpiresAt: string; +} + +export class HermesProactiveEventRepositoryError extends Schema.TaggedErrorClass()( + "HermesProactiveEventRepositoryError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Hermes proactive event repository failed in ${this.operation}: ${this.detail}`; + } +} + +export interface HermesProactiveEventRepositoryShape { + readonly registerSource: ( + input: RegisterHermesProactiveSourceInput, + ) => Effect.Effect; + readonly getSource: ( + sourceId: string, + ) => Effect.Effect< + Option.Option, + HermesProactiveEventRepositoryError + >; + readonly ingestPage: ( + input: IngestHermesProactivePageInput, + ) => Effect.Effect; + readonly claimNotification: ( + input: ClaimHermesNotificationInput, + ) => Effect.Effect< + Option.Option, + HermesProactiveEventRepositoryError + >; + readonly deliverInApp: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + }) => Effect.Effect; + readonly retryNotification: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + readonly availableAt: string; + readonly errorCode: string; + }) => Effect.Effect; + readonly deadLetterNotification: (input: { + readonly outboxId: string; + readonly workerId: string; + readonly now: string; + readonly errorCode: string; + }) => Effect.Effect; + readonly listWorkItems: () => Effect.Effect< + ReadonlyArray, + HermesProactiveEventRepositoryError + >; + readonly listInAppNotifications: () => Effect.Effect< + ReadonlyArray, + HermesProactiveEventRepositoryError + >; +} + +export class HermesProactiveEventRepository extends Context.Service< + HermesProactiveEventRepository, + HermesProactiveEventRepositoryShape +>()("t3/hermes/HermesProactiveEventRepository") {} + +interface SourceRow { + readonly source_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly capability_state: string; + readonly diagnostic_code: string; + readonly missing_capabilities_json: string; + readonly checkpoint_cursor: string | null; + readonly checkpoint_sequence: number; + readonly last_checked_at: string; + readonly updated_at: string; +} + +interface OutboxRow { + readonly outbox_id: string; + readonly event_id: string; + readonly state: string; + readonly attempt_count: number; + readonly available_at: string; + readonly lease_owner: string | null; + readonly lease_expires_at: string | null; + readonly last_error_code: string | null; + readonly created_at: string; + readonly updated_at: string; + readonly delivered_at: string | null; +} + +interface EventRow { + readonly event_id: string; + readonly title: string; + readonly body: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly occurred_at: string; +} + +interface WorkItemRow { + readonly work_item_id: string; + readonly event_id: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly title: string; + readonly summary: string; + readonly status: string; + readonly occurred_at: string; + readonly created_at: string; + readonly updated_at: string; +} + +interface NotificationRow { + readonly notification_id: string; + readonly event_id: string; + readonly work_item_id: string; + readonly project_id: string | null; + readonly thread_id: string | null; + readonly title: string; + readonly body: string; + readonly status: string; + readonly created_at: string; + readonly updated_at: string; +} + +function stableId(namespace: string, ...parts: ReadonlyArray): string { + const digest = NodeCrypto.createHash("sha256") + .update([namespace, ...parts].map((part) => `${part.length}:${part}`).join("|")) + .digest("hex"); + return `${namespace}:${digest}`; +} + +export function classifyHermesProactiveCapability(compatibility: HermesGatewayCompatibility): { + readonly state: "ready" | "degraded"; + readonly diagnosticCode: HermesProactiveDiagnosticCode; + readonly missingCapabilities: ReadonlyArray; +} { + if (compatibility.inventory === null) { + return { + state: "degraded", + diagnosticCode: "missing_capability_inventory", + missingCapabilities: [...HermesProactiveRequiredCapabilities], + }; + } + const available = new Set(compatibility.capabilities); + const missingCapabilities = HermesProactiveRequiredCapabilities.filter( + (capability) => !available.has(capability), + ); + if (missingCapabilities.includes("cron.events.global_cursor")) { + return { + state: "degraded", + diagnosticCode: "missing_durable_global_cursor", + missingCapabilities, + }; + } + if (missingCapabilities.includes("events.stable_ids")) { + return { + state: "degraded", + diagnosticCode: "missing_stable_event_ids", + missingCapabilities, + }; + } + return { state: "ready", diagnosticCode: "ready", missingCapabilities: [] }; +} + +function sourceFromRow(row: SourceRow) { + return decodeSource({ + sourceId: row.source_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + state: row.capability_state, + diagnosticCode: row.diagnostic_code, + missingCapabilities: decodeMissingCapabilitiesJson(row.missing_capabilities_json), + checkpointCursor: row.checkpoint_cursor, + checkpointSequence: row.checkpoint_sequence, + lastCheckedAt: row.last_checked_at, + updatedAt: row.updated_at, + }); +} + +function outboxFromRow(row: OutboxRow) { + return decodeOutbox({ + outboxId: row.outbox_id, + eventId: row.event_id, + state: row.state, + attemptCount: row.attempt_count, + availableAt: row.available_at, + leaseOwner: row.lease_owner, + leaseExpiresAt: row.lease_expires_at, + lastErrorCode: row.last_error_code, + createdAt: row.created_at, + updatedAt: row.updated_at, + deliveredAt: row.delivered_at, + }); +} + +function repositoryError(operation: string, cause: unknown) { + return new HermesProactiveEventRepositoryError({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + cause, + }); +} + +export const layer: Layer.Layer = + Layer.effect( + HermesProactiveEventRepository, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const loadSource = Effect.fn("HermesProactiveEventRepository.loadSource")(function* ( + sourceId: string, + ) { + const rows = yield* sql` + SELECT + source_id, + provider_instance_id, + profile_key, + capability_state, + diagnostic_code, + missing_capabilities_json, + checkpoint_cursor, + checkpoint_sequence, + last_checked_at, + updated_at + FROM hermes_proactive_sources + WHERE source_id = ${sourceId} + LIMIT 1 + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* sourceFromRow(row)); + }); + + const registerSource: HermesProactiveEventRepositoryShape["registerSource"] = (input) => + Effect.gen(function* () { + const sourceId = stableId( + "hermes-source", + input.providerInstanceId, + input.profileKey, + "global-cron-events", + ); + const classification = classifyHermesProactiveCapability(input.compatibility); + yield* sql` + INSERT INTO hermes_proactive_sources ( + source_id, + provider_instance_id, + profile_key, + capability_state, + diagnostic_code, + missing_capabilities_json, + checkpoint_cursor, + checkpoint_sequence, + last_checked_at, + created_at, + updated_at + ) + VALUES ( + ${sourceId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${classification.state}, + ${classification.diagnosticCode}, + ${encodeMissingCapabilitiesJson(classification.missingCapabilities)}, + NULL, + 0, + ${input.now}, + ${input.now}, + ${input.now} + ) + ON CONFLICT(provider_instance_id, profile_key) + DO UPDATE SET + capability_state = excluded.capability_state, + diagnostic_code = excluded.diagnostic_code, + missing_capabilities_json = excluded.missing_capabilities_json, + last_checked_at = excluded.last_checked_at, + updated_at = excluded.updated_at + `; + const source = yield* loadSource(sourceId); + if (Option.isNone(source)) { + return yield* repositoryError("registerSource", "Source disappeared after upsert."); + } + return source.value; + }).pipe(Effect.mapError((cause) => repositoryError("registerSource", cause))); + + const getSource: HermesProactiveEventRepositoryShape["getSource"] = (sourceId) => + loadSource(sourceId).pipe(Effect.mapError((cause) => repositoryError("getSource", cause))); + + const ingestPage: HermesProactiveEventRepositoryShape["ingestPage"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const sourceOption = yield* loadSource(input.sourceId); + if (Option.isNone(sourceOption)) { + return yield* repositoryError("ingestPage", "Source is not registered."); + } + const source = sourceOption.value; + if (source.state === "degraded") { + return { + status: "degraded", + diagnosticCode: source.diagnosticCode, + } as const; + } + if (input.nextCursor.length === 0) { + return yield* repositoryError("ingestPage", "A durable next cursor is required."); + } + if (source.checkpointCursor === input.nextCursor) { + return { + status: "already_applied", + checkpointCursor: input.nextCursor, + checkpointSequence: source.checkpointSequence, + } as const; + } + if (source.checkpointCursor !== input.expectedCursor) { + return { + status: "stale_checkpoint", + checkpointCursor: source.checkpointCursor, + checkpointSequence: source.checkpointSequence, + } as const; + } + + let inserted = 0; + for (const event of input.events) { + if (event.externalEventId.length === 0 || event.externalCursor.length === 0) { + return yield* repositoryError( + "ingestPage", + "Every proactive event requires a stable id and durable cursor.", + ); + } + const eventId = stableId("hermes-event", input.sourceId, event.externalEventId); + const provenance = yield* encodeProvenance({ + provider: "hermes", + providerInstanceId: source.providerInstanceId, + profileKey: source.profileKey, + sourceId: source.sourceId, + externalEventId: event.externalEventId, + externalCursor: event.externalCursor, + gatewayRevision: input.gatewayRevision, + protocolMajor: input.protocolMajor, + protocolMinor: input.protocolMinor, + ingestedAt: input.receivedAt, + }); + const rows = yield* sql<{ event_id: string }>` + INSERT INTO hermes_proactive_events ( + event_id, + source_id, + external_event_id, + external_cursor, + event_kind, + title, + body, + project_id, + thread_id, + occurred_at, + received_at, + provenance_json + ) + VALUES ( + ${eventId}, + ${input.sourceId}, + ${event.externalEventId}, + ${event.externalCursor}, + ${event.eventKind}, + ${event.title}, + ${event.body}, + ${event.projectId}, + ${event.threadId}, + ${event.occurredAt}, + ${input.receivedAt}, + ${encodeProvenanceJson(provenance)} + ) + ON CONFLICT(source_id, external_event_id) DO NOTHING + RETURNING event_id + `; + if (rows.length === 0) continue; + inserted += 1; + yield* sql` + INSERT INTO hermes_notification_outbox ( + outbox_id, + event_id, + state, + attempt_count, + available_at, + lease_owner, + lease_expires_at, + last_error_code, + created_at, + updated_at, + delivered_at + ) + VALUES ( + ${stableId("hermes-outbox", eventId)}, + ${eventId}, + 'pending', + 0, + ${input.receivedAt}, + NULL, + NULL, + NULL, + ${input.receivedAt}, + ${input.receivedAt}, + NULL + ) + `; + } + + const advanced = yield* sql<{ checkpoint_sequence: number }>` + UPDATE hermes_proactive_sources + SET checkpoint_cursor = ${input.nextCursor}, + checkpoint_sequence = checkpoint_sequence + 1, + updated_at = ${input.receivedAt} + WHERE source_id = ${input.sourceId} + AND ( + checkpoint_cursor = ${input.expectedCursor} + OR (checkpoint_cursor IS NULL AND ${input.expectedCursor} IS NULL) + ) + RETURNING checkpoint_sequence + `; + const checkpoint = advanced[0]; + if (checkpoint === undefined) { + return yield* repositoryError("ingestPage", "Checkpoint compare-and-set failed."); + } + return { + status: "applied", + inserted, + duplicates: input.events.length - inserted, + checkpointCursor: input.nextCursor, + checkpointSequence: checkpoint.checkpoint_sequence, + } as const; + }), + ) + .pipe(Effect.mapError((cause) => repositoryError("ingestPage", cause))); + + const claimNotification: HermesProactiveEventRepositoryShape["claimNotification"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const candidates = yield* sql<{ outbox_id: string }>` + SELECT outbox_id + FROM hermes_notification_outbox + WHERE + ( + state IN ('pending', 'retry') + AND available_at <= ${input.now} + ) + OR ( + state = 'processing' + AND lease_expires_at <= ${input.now} + ) + ORDER BY available_at ASC, created_at ASC, outbox_id ASC + LIMIT 1 + `; + const candidate = candidates[0]; + if (candidate === undefined) return Option.none(); + const claimed = yield* sql` + UPDATE hermes_notification_outbox + SET state = 'processing', + attempt_count = attempt_count + 1, + lease_owner = ${input.workerId}, + lease_expires_at = ${input.leaseExpiresAt}, + updated_at = ${input.now} + WHERE outbox_id = ${candidate.outbox_id} + AND ( + ( + state IN ('pending', 'retry') + AND available_at <= ${input.now} + ) + OR ( + state = 'processing' + AND lease_expires_at <= ${input.now} + ) + ) + RETURNING * + `; + const row = claimed[0]; + return row === undefined ? Option.none() : Option.some(yield* outboxFromRow(row)); + }), + ) + .pipe(Effect.mapError((cause) => repositoryError("claimNotification", cause))); + + const deliverInApp: HermesProactiveEventRepositoryShape["deliverInApp"] = (input) => + sql + .withTransaction( + Effect.gen(function* () { + const events = yield* sql` + SELECT + event.event_id, + event.title, + event.body, + event.project_id, + event.thread_id, + event.occurred_at + FROM hermes_notification_outbox AS outbox + JOIN hermes_proactive_events AS event ON event.event_id = outbox.event_id + WHERE outbox.outbox_id = ${input.outboxId} + AND outbox.state = 'processing' + AND outbox.lease_owner = ${input.workerId} + LIMIT 1 + `; + const event = events[0]; + if (event === undefined) return false; + const workItemId = stableId("hermes-work", event.event_id); + yield* sql` + INSERT INTO hermes_proactive_work_items ( + work_item_id, + event_id, + project_id, + thread_id, + title, + summary, + status, + occurred_at, + created_at, + updated_at + ) + VALUES ( + ${workItemId}, + ${event.event_id}, + ${event.project_id}, + ${event.thread_id}, + ${event.title}, + ${event.body}, + 'unread', + ${event.occurred_at}, + ${input.now}, + ${input.now} + ) + ON CONFLICT(event_id) DO NOTHING + `; + yield* sql` + INSERT INTO hermes_in_app_notifications ( + notification_id, + event_id, + work_item_id, + project_id, + thread_id, + title, + body, + status, + created_at, + updated_at + ) + VALUES ( + ${stableId("hermes-notification", event.event_id)}, + ${event.event_id}, + ${workItemId}, + ${event.project_id}, + ${event.thread_id}, + ${event.title}, + ${event.body}, + 'unread', + ${input.now}, + ${input.now} + ) + ON CONFLICT(event_id) DO NOTHING + `; + const delivered = yield* sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'delivered', + lease_owner = NULL, + lease_expires_at = NULL, + updated_at = ${input.now}, + delivered_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + RETURNING outbox_id + `; + return delivered.length === 1; + }), + ) + .pipe(Effect.mapError((cause) => repositoryError("deliverInApp", cause))); + + const retryNotification: HermesProactiveEventRepositoryShape["retryNotification"] = (input) => + sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'retry', + available_at = ${input.availableAt}, + lease_owner = NULL, + lease_expires_at = NULL, + last_error_code = ${input.errorCode}, + updated_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + RETURNING outbox_id + `.pipe( + Effect.map((rows) => rows.length === 1), + Effect.mapError((cause) => repositoryError("retryNotification", cause)), + ); + + const deadLetterNotification: HermesProactiveEventRepositoryShape["deadLetterNotification"] = + (input) => + sql<{ outbox_id: string }>` + UPDATE hermes_notification_outbox + SET state = 'dead_letter', + lease_owner = NULL, + lease_expires_at = NULL, + last_error_code = ${input.errorCode}, + updated_at = ${input.now} + WHERE outbox_id = ${input.outboxId} + AND state = 'processing' + AND lease_owner = ${input.workerId} + RETURNING outbox_id + `.pipe( + Effect.map((rows) => rows.length === 1), + Effect.mapError((cause) => repositoryError("deadLetterNotification", cause)), + ); + + const listWorkItems: HermesProactiveEventRepositoryShape["listWorkItems"] = () => + sql` + SELECT * + FROM hermes_proactive_work_items + ORDER BY occurred_at DESC, work_item_id ASC + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeWorkItem({ + workItemId: row.work_item_id, + eventId: row.event_id, + projectId: row.project_id, + threadId: row.thread_id, + title: row.title, + summary: row.summary, + status: row.status, + occurredAt: row.occurred_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + { concurrency: 1 }, + ), + ), + Effect.mapError((cause) => repositoryError("listWorkItems", cause)), + ); + + const listInAppNotifications: HermesProactiveEventRepositoryShape["listInAppNotifications"] = + () => + sql` + SELECT * + FROM hermes_in_app_notifications + ORDER BY created_at DESC, notification_id ASC + `.pipe( + Effect.flatMap((rows) => + Effect.forEach( + rows, + (row) => + decodeNotification({ + notificationId: row.notification_id, + eventId: row.event_id, + workItemId: row.work_item_id, + projectId: row.project_id, + threadId: row.thread_id, + title: row.title, + body: row.body, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }), + { concurrency: 1 }, + ), + ), + Effect.mapError((cause) => repositoryError("listInAppNotifications", cause)), + ); + + return HermesProactiveEventRepository.of({ + registerSource, + getSource, + ingestPage, + claimNotification, + deliverInApp, + retryNotification, + deadLetterNotification, + listWorkItems, + listInAppNotifications, + }); + }), + ); diff --git a/apps/server/src/hermes/HermesServeRuntime.test.ts b/apps/server/src/hermes/HermesServeRuntime.test.ts new file mode 100644 index 00000000000..0d0f36f2492 --- /dev/null +++ b/apps/server/src/hermes/HermesServeRuntime.test.ts @@ -0,0 +1,119 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + DEFAULT_HERMES_SERVE_ENDPOINT, + makeHermesServeRuntime, + resolveHermesServeEndpoint, +} from "./HermesServeRuntime.ts"; + +describe("HermesServeRuntime", () => { + it("uses the standard loopback gateway when no endpoint is configured", () => { + assert.equal(resolveHermesServeEndpoint(""), DEFAULT_HERMES_SERVE_ENDPOINT); + assert.equal( + resolveHermesServeEndpoint(" ws://127.0.0.1:19119/api/ws "), + "ws://127.0.0.1:19119/api/ws", + ); + }); + + it.effect("attaches to an already-running compatible Hermes gateway", () => + Effect.scoped( + Effect.gen(function* () { + let starts = 0; + const runtime = yield* makeHermesServeRuntime({ + endpoint: "", + authToken: "shared-token", + managedServerEnabled: true, + processEnvironment: {}, + probe: async () => undefined, + endpointReachable: async () => true, + start: () => { + starts += 1; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => Effect.void, + }); + }, + }); + + const connection = yield* runtime.ensureReady; + assert.equal(connection.endpoint, DEFAULT_HERMES_SERVE_ENDPOINT); + assert.equal(connection.ownership, "external"); + assert.equal(starts, 0); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("starts and owns Hermes Serve only when no endpoint is listening", () => + Effect.gen(function* () { + let ready = false; + let starts = 0; + let kills = 0; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeHermesServeRuntime({ + endpoint: "ws://127.0.0.1:19119/api/ws", + authToken: "managed-token", + managedServerEnabled: true, + processEnvironment: {}, + startupPollInterval: "1 millis", + probe: async () => { + if (!ready) throw new Error("not ready"); + }, + endpointReachable: async () => false, + start: () => { + starts += 1; + ready = true; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => + Effect.sync(() => { + kills += 1; + }), + }); + }, + }); + + const connection = yield* runtime.ensureReady; + assert.equal(connection.ownership, "t3_owned"); + assert.equal(starts, 1); + assert.equal(runtime.currentOwnership(), "t3_owned"); + }), + ); + assert.equal(kills, 1); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not replace a reachable gateway that rejects the configured token", () => + Effect.scoped( + Effect.gen(function* () { + let starts = 0; + const runtime = yield* makeHermesServeRuntime({ + endpoint: "", + authToken: "wrong-token", + managedServerEnabled: true, + processEnvironment: {}, + probe: async () => { + throw new Error("unauthorized"); + }, + endpointReachable: async () => true, + start: () => { + starts += 1; + return Effect.succeed({ + isRunning: Effect.succeed(true), + kill: () => Effect.void, + }); + }, + }); + + const result = yield* Effect.result(runtime.ensureReady); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.include(result.failure.message, "already running"); + } + assert.equal(starts, 0); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/hermes/HermesServeRuntime.ts b/apps/server/src/hermes/HermesServeRuntime.ts new file mode 100644 index 00000000000..cd7e5b348ce --- /dev/null +++ b/apps/server/src/hermes/HermesServeRuntime.ts @@ -0,0 +1,311 @@ +import * as NodeNet from "node:net"; +import type * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { HermesGatewayClient } from "./HermesGatewayClient.ts"; + +export const DEFAULT_HERMES_SERVE_ENDPOINT = "ws://127.0.0.1:9119/api/ws"; + +export type HermesServeOwnership = "external" | "t3_owned"; + +export interface HermesServeConnection { + readonly endpoint: string; + readonly authToken: string; + readonly ownership: HermesServeOwnership; +} + +export interface HermesServeRuntimeShape { + readonly effectiveEndpoint: string; + readonly ensureReady: Effect.Effect; + readonly currentOwnership: () => HermesServeOwnership | null; +} + +interface HermesOwnedProcessHandle { + readonly isRunning: Effect.Effect; + readonly kill: () => Effect.Effect; +} + +class HermesOwnedProcessError extends Schema.TaggedErrorClass()( + "HermesOwnedProcessError", + { + cause: Schema.Defect(), + }, +) {} + +export class HermesServeRuntimeError extends Schema.TaggedErrorClass()( + "HermesServeRuntimeError", + { + code: Schema.Literals([ + "authentication_required", + "endpoint_in_use", + "managed_start_disabled", + "managed_start_failed", + "remote_unreachable", + "unreachable", + ]), + message: Schema.String, + }, +) {} + +class HermesServeProbeError extends Schema.TaggedErrorClass()( + "HermesServeProbeError", + { + cause: Schema.Defect(), + }, +) {} + +export interface MakeHermesServeRuntimeOptions { + readonly endpoint: string; + readonly authToken: string | undefined; + readonly managedServerEnabled: boolean; + readonly processEnvironment: NodeJS.ProcessEnv; + readonly probe?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => Promise; + readonly endpointReachable?: (endpoint: string) => Promise; + readonly start?: (input: { + readonly host: string; + readonly port: number; + readonly authToken: string; + }) => Effect.Effect; + readonly startupAttempts?: number; + readonly startupPollInterval?: Duration.Input; +} + +export function resolveHermesServeEndpoint(endpoint: string): string { + return endpoint.trim() || DEFAULT_HERMES_SERVE_ENDPOINT; +} + +function localServeTarget( + endpoint: string, +): { readonly host: string; readonly port: number } | null { + try { + const parsed = new URL(endpoint); + if (parsed.protocol !== "ws:") return null; + if (!["127.0.0.1", "localhost", "::1", "[::1]"].includes(parsed.hostname)) return null; + const port = parsed.port ? Number(parsed.port) : 80; + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) return null; + return { host: parsed.hostname === "[::1]" ? "::1" : parsed.hostname, port }; + } catch { + return null; + } +} + +async function defaultEndpointReachable(endpoint: string): Promise { + const target = localServeTarget(endpoint); + if (target === null) return false; + return new Promise((resolve) => { + const socket = NodeNet.createConnection(target); + const finish = (reachable: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(reachable); + }; + socket.setTimeout(1_500); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); +} + +async function defaultProbe(input: { + readonly endpoint: string; + readonly authToken: string; +}): Promise { + const client = new HermesGatewayClient({ + endpoint: input.endpoint, + authToken: input.authToken, + reconnect: { maxAttempts: 0 }, + }); + try { + await client.connect(); + } finally { + client.close(); + } +} + +export const makeHermesServeRuntime = Effect.fn("makeHermesServeRuntime")(function* ( + options: MakeHermesServeRuntimeOptions, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const ownerScope = yield* Scope.Scope; + const mutex = yield* Semaphore.make(1); + const effectiveEndpoint = resolveHermesServeEndpoint(options.endpoint); + const probe = options.probe ?? defaultProbe; + const endpointReachable = options.endpointReachable ?? defaultEndpointReachable; + const startupAttempts = options.startupAttempts ?? 80; + const startupPollInterval = options.startupPollInterval ?? "150 millis"; + let connection: HermesServeConnection | null = null; + let ownedProcess: HermesOwnedProcessHandle | null = null; + + const probeEffect = (authToken: string) => + Effect.tryPromise({ + try: () => probe({ endpoint: effectiveEndpoint, authToken }), + catch: (cause) => new HermesServeProbeError({ cause }), + }); + + const start = + options.start ?? + ((input: { readonly host: string; readonly port: number; readonly authToken: string }) => + spawner + .spawn( + ChildProcess.make( + "hermes", + ["serve", "--host", input.host, "--port", String(input.port)], + { + env: { + ...options.processEnvironment, + HERMES_DASHBOARD_SESSION_TOKEN: input.authToken, + }, + extendEnv: false, + detached: false, + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + forceKillAfter: "5 seconds", + }, + ), + ) + .pipe( + Effect.provideService(Scope.Scope, ownerScope), + Effect.mapError((cause) => new HermesOwnedProcessError({ cause })), + Effect.map((handle) => ({ + isRunning: handle.isRunning.pipe( + Effect.mapError((cause) => new HermesOwnedProcessError({ cause })), + ), + kill: () => + handle + .kill({ forceKillAfter: "5 seconds" }) + .pipe(Effect.mapError((cause) => new HermesOwnedProcessError({ cause }))), + })), + )); + + const stopOwnedProcess = Effect.fn("HermesServeRuntime.stopOwnedProcess")(function* () { + const handle = ownedProcess; + ownedProcess = null; + if (handle !== null) { + yield* handle.kill().pipe(Effect.orElseSucceed(() => undefined)); + } + }); + + yield* Effect.addFinalizer(() => + mutex.withPermit( + Effect.gen(function* () { + connection = null; + yield* stopOwnedProcess(); + }), + ), + ); + + const ensureReady = mutex.withPermit( + Effect.gen(function* () { + const authToken = options.authToken?.trim(); + if (!authToken) { + return yield* new HermesServeRuntimeError({ + code: "authentication_required", + message: + "Hermes requires a gateway token. Add HERMES_GATEWAY_TOKEN in T3 Work so T3 can attach to or launch Hermes Serve securely.", + }); + } + + if (connection !== null) { + const cachedProbe = yield* Effect.result(probeEffect(authToken)); + if (cachedProbe._tag === "Success") return connection; + connection = null; + if (ownedProcess !== null) { + const stillRunning = yield* ownedProcess.isRunning.pipe( + Effect.orElseSucceed(() => false), + ); + if (!stillRunning) ownedProcess = null; + } + } + + const attachProbe = yield* Effect.result(probeEffect(authToken)); + if (attachProbe._tag === "Success") { + connection = { + endpoint: effectiveEndpoint, + authToken, + ownership: ownedProcess === null ? "external" : "t3_owned", + }; + return connection; + } + + const reachable = yield* Effect.promise(() => + endpointReachable(effectiveEndpoint).catch(() => false), + ); + if (reachable) { + return yield* new HermesServeRuntimeError({ + code: "endpoint_in_use", + message: + "A Hermes Serve instance is already running at this endpoint, but it rejected the configured gateway token or protocol handshake. Use the token from that Hermes instance, then refresh.", + }); + } + + const target = localServeTarget(effectiveEndpoint); + if (target === null) { + return yield* new HermesServeRuntimeError({ + code: "remote_unreachable", + message: + "The configured Hermes gateway is unreachable. T3 only auto-starts credentialed loopback Hermes Serve instances.", + }); + } + if (!options.managedServerEnabled) { + return yield* new HermesServeRuntimeError({ + code: "managed_start_disabled", + message: + "Hermes Serve is not reachable and automatic startup is disabled for this provider.", + }); + } + + const started = yield* Effect.result( + start({ ...target, authToken }).pipe( + Effect.mapError( + () => + new HermesServeRuntimeError({ + code: "managed_start_failed", + message: + "T3 could not launch `hermes serve`. Make sure the Hermes CLI is installed and available on PATH.", + }), + ), + ), + ); + if (started._tag === "Failure") { + return yield* started.failure; + } + ownedProcess = started.success; + + for (let attempt = 0; attempt < startupAttempts; attempt += 1) { + const ready = yield* Effect.result(probeEffect(authToken)); + if (ready._tag === "Success") { + connection = { + endpoint: effectiveEndpoint, + authToken, + ownership: "t3_owned", + }; + return connection; + } + const stillRunning = yield* ownedProcess.isRunning.pipe(Effect.orElseSucceed(() => false)); + if (!stillRunning) break; + yield* Effect.sleep(startupPollInterval); + } + + yield* stopOwnedProcess(); + return yield* new HermesServeRuntimeError({ + code: "managed_start_failed", + message: + "T3 launched `hermes serve`, but the gateway did not become ready before the startup timeout.", + }); + }), + ); + + return { + effectiveEndpoint, + ensureReady, + currentOwnership: () => connection?.ownership ?? null, + } satisfies HermesServeRuntimeShape; +}); diff --git a/apps/server/src/hermes/HermesSessionBindingRepository.test.ts b/apps/server/src/hermes/HermesSessionBindingRepository.test.ts new file mode 100644 index 00000000000..c011598bed5 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionBindingRepository.test.ts @@ -0,0 +1,722 @@ +// @effect-diagnostics nodeBuiltinImport:off - Restart coverage needs a real file-backed SQLite database. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import type { SqlError } from "effect/unstable/sql/SqlError"; + +import { runMigrations } from "../persistence/Migrations.ts"; +import * as NodeSqliteClient from "../persistence/NodeSqliteClient.ts"; +import { + HermesSessionBindingRepository, + layer as HermesSessionBindingRepositoryLayer, +} from "./HermesSessionBindingRepository.ts"; + +const T0 = "2026-07-24T12:00:00.000Z"; +const T1 = "2026-07-24T12:00:10.000Z"; +const T2 = "2026-07-24T12:00:20.000Z"; +const T3 = "2026-07-24T12:00:30.000Z"; +const T4 = "2026-07-24T12:00:40.000Z"; +const DIGEST_A = "a".repeat(64); +const DIGEST_B = "b".repeat(64); + +function testLayer(databaseLayer: Layer.Layer) { + return HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(databaseLayer)); +} + +const memory = it.layer(testLayer(NodeSqliteClient.layerMemory())); + +function createBinding( + repository: HermesSessionBindingRepository["Service"], + overrides: Partial[0]> = {}, +) { + return repository.createBinding({ + bindingId: "hermes-binding:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:conversation-1", + threadId: "thread:1", + protocolClassification: "supported", + protocolMajor: 1, + protocolMinor: 4, + capabilities: ["turn.prompt", "session.lifecycle", "turn.prompt"], + reconciliationCursor: "cursor:7", + reconciliationFingerprint: "fingerprint:7", + now: T0, + ...overrides, + }); +} + +memory("HermesSessionBindingRepository", (it) => { + it.effect("keeps session imports idempotent and enforces one Main per profile", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + const first = yield* repository.prepareSessionImport({ + importId: "import:session:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:1", + threadId: "thread:import:1", + now: T0, + }); + const replay = yield* repository.prepareSessionImport({ + importId: "import:session:replay", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:1", + threadId: "thread:import:other", + now: T1, + }); + assert.strictEqual(replay.importId, first.importId); + assert.strictEqual(replay.threadId, first.threadId); + + assert.isTrue( + yield* repository.transitionSessionImport({ + importId: first.importId, + from: "prepared", + to: "thread_created", + now: T1, + }), + ); + assert.isTrue( + yield* repository.transitionSessionImport({ + importId: first.importId, + from: "thread_created", + to: "completed", + now: T2, + }), + ); + const imported = yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:1", + }); + assert.isTrue(Option.isSome(imported)); + if (Option.isSome(imported)) assert.strictEqual(imported.value.state, "completed"); + + const main = yield* repository.prepareSessionImport({ + importId: "import:main:1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "main", + storedSessionKey: null, + threadId: "thread:main:1", + now: T0, + }); + const competingMain = yield* repository.prepareSessionImport({ + importId: "import:main:2", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + importKind: "main", + storedSessionKey: null, + threadId: "thread:main:2", + now: T1, + }); + assert.strictEqual(competingMain.importId, main.importId); + assert.strictEqual(competingMain.threadId, "thread:main:1"); + }), + ); + + it.effect("clears every local Hermes binding and import so sessions can be imported again", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + yield* repository.prepareSessionImport({ + importId: "import:session:reset", + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:reset", + threadId: "thread:reset", + now: T0, + }); + yield* repository.prepareSessionImport({ + importId: "import:other-profile", + providerInstanceId: "hermes-local", + profileKey: "profile:other", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:other-profile", + threadId: "thread:other-profile", + now: T0, + }); + yield* repository.prepareSessionImport({ + importId: "import:other-provider", + providerInstanceId: "hermes-other", + profileKey: "profile:default", + projectId: "project:1", + importKind: "session", + storedSessionKey: "stored:other-provider", + threadId: "thread:other-provider", + now: T0, + }); + yield* createBinding(repository, { + bindingId: "binding:reset", + profileKey: "profile:reset", + storedSessionKey: "stored:reset", + threadId: "thread:reset", + }); + yield* createBinding(repository, { + bindingId: "binding:other-project", + profileKey: "profile:reset", + projectId: "project:other", + storedSessionKey: "stored:other", + threadId: "thread:other", + }); + + const scope = { + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + }; + assert.deepEqual(yield* repository.listHistoryThreadIds(scope), ["thread:reset"]); + assert.strictEqual(yield* repository.clearHistoryRecords(scope), 1); + assert.isTrue( + Option.isNone( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:reset", + projectId: "project:1", + storedSessionKey: "stored:reset", + }), + ), + ); + assert.isTrue(Option.isNone(yield* repository.getByThreadId("thread:reset"))); + assert.isTrue(Option.isSome(yield* repository.getByThreadId("thread:other"))); + assert.isTrue( + Option.isSome( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:other", + projectId: "project:1", + storedSessionKey: "stored:other-profile", + }), + ), + ); + assert.isTrue( + Option.isSome( + yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: "hermes-other", + profileKey: "profile:default", + projectId: "project:1", + storedSessionKey: "stored:other-provider", + }), + ), + ); + assert.deepEqual(yield* repository.listHistoryThreadIds(scope), []); + }), + ); + + it.effect("rejects a scoped reset while a mutation is unsettled and preserves its records", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + const lease = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + expectedGeneration: 0, + now: T0, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(lease)); + const prepared = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + generation: 1, + now: T1, + operationId: "operation:unsettled-reset", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_A, + }); + assert.strictEqual(prepared.status, "prepared"); + + const error = yield* Effect.flip( + repository.clearHistoryRecords({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + }), + ); + assert.include(error.detail, "unsettled Hermes mutation"); + assert.isTrue(Option.isSome(yield* repository.getByThreadId("thread:1"))); + assert.isTrue( + Option.isSome(yield* repository.getMutationIntent("operation:unsettled-reset")), + ); + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "reset-test-owner", + generation: 1, + now: T2, + operationId: "operation:unsettled-reset", + from: "prepared", + to: "rejected", + }), + ); + yield* repository.clearHistoryRecords({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + }); + }), + ); + + it.effect( + "enforces both durable identity uniqueness domains and stores negotiation metadata", + () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + + assert.isTrue(yield* createBinding(repository)); + assert.isFalse( + yield* createBinding(repository, { + bindingId: "hermes-binding:duplicate-identity", + threadId: "thread:2", + }), + ); + assert.isFalse( + yield* createBinding(repository, { + bindingId: "hermes-binding:duplicate-thread", + storedSessionKey: "stored:conversation-2", + }), + ); + + const byThread = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isSome(byThread)); + if (Option.isNone(byThread)) return; + assert.deepStrictEqual(byThread.value.capabilities, ["session.lifecycle", "turn.prompt"]); + assert.strictEqual(byThread.value.protocolClassification, "supported"); + assert.strictEqual(byThread.value.projectId, "project:1"); + assert.strictEqual(byThread.value.protocolMajor, 1); + assert.strictEqual(byThread.value.protocolMinor, 4); + assert.strictEqual(byThread.value.reconciliationCursor, "cursor:7"); + assert.strictEqual(byThread.value.reconciliationFingerprint, "fingerprint:7"); + assert.strictEqual(byThread.value.leaseGeneration, 0); + + const byIdentity = yield* repository.getByStoredIdentity({ + providerInstanceId: "hermes-local", + profileKey: "profile:default", + storedSessionKey: "stored:conversation-1", + }); + assert.isTrue(Option.isSome(byIdentity)); + if (Option.isSome(byIdentity)) { + assert.strictEqual(byIdentity.value.bindingId, "hermes-binding:1"); + } + }), + ); + + it.effect("uses generation and expiry CAS to fence lease-owned metadata writes", () => + Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + + const first = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + expectedGeneration: 0, + now: T0, + expiresAt: T2, + }); + assert.isTrue(Option.isSome(first)); + if (Option.isNone(first)) return; + assert.strictEqual(first.value.generation, 1); + + assert.isTrue( + yield* repository.updateNegotiation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T1, + protocolClassification: "legacy", + protocolMajor: null, + protocolMinor: null, + capabilities: ["session.lifecycle"], + }), + ); + assert.isTrue( + yield* repository.updateReconciliation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T1, + cursor: "cursor:8", + fingerprint: "fingerprint:8", + }), + ); + + const busy = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:b", + expectedGeneration: 1, + now: T1, + expiresAt: T3, + }); + assert.isTrue(Option.isNone(busy)); + + const takeover = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:b", + expectedGeneration: 1, + now: T2, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(takeover)); + if (Option.isNone(takeover)) return; + assert.strictEqual(takeover.value.generation, 2); + + assert.isFalse( + yield* repository.renewOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T2, + expiresAt: T4, + }), + ); + assert.isFalse( + yield* repository.updateReconciliation({ + bindingId: "hermes-binding:1", + ownerKey: "owner:a", + generation: 1, + now: T2, + cursor: "stale-cursor", + fingerprint: "stale-fingerprint", + }), + ); + }), + ); +}); + +it.effect("persists ambiguous mutation recovery across a file-backed SQLite restart", () => { + const directory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-hermes-session-binding-"), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + const privatePrompt = "PRIVATE PROMPT THAT MUST NEVER REACH SQLITE"; + + const firstRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + yield* createBinding(repository); + const lease = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + expectedGeneration: 0, + now: T0, + expiresAt: T2, + }); + assert.isTrue(Option.isSome(lease)); + + const prepared = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T0, + operationId: "operation:prompt-1", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_A, + }); + assert.strictEqual(prepared.status, "prepared"); + + const concurrent = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.deepStrictEqual(concurrent, { + status: "unsettled_prompt", + operationId: "operation:prompt-1", + }); + + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-1", + from: "prepared", + to: "admitted", + }), + ); + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:first-runtime", + generation: 1, + now: T1, + operationId: "operation:prompt-1", + from: "admitted", + to: "indeterminate", + }), + ); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + const secondRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const unsettled = yield* repository.listUnsettledMutationIntents("hermes-binding:1"); + assert.deepStrictEqual( + unsettled.map(({ operationId, state, payloadDigest }) => ({ + operationId, + state, + payloadDigest, + })), + [ + { + operationId: "operation:prompt-1", + state: "indeterminate", + payloadDigest: DIGEST_A, + }, + ], + ); + + const takeover = yield* repository.acquireOwnerLease({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + expectedGeneration: 1, + now: T2, + expiresAt: T4, + }); + assert.isTrue(Option.isSome(takeover)); + if (Option.isNone(takeover)) return; + assert.strictEqual(takeover.value.generation, 2); + + const stillBlocked = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T2, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.strictEqual(stillBlocked.status, "unsettled_prompt"); + + assert.isTrue( + yield* repository.transitionMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T2, + operationId: "operation:prompt-1", + from: "indeterminate", + to: "reconciled", + }), + ); + const afterReconciliation = yield* repository.prepareMutationIntent({ + bindingId: "hermes-binding:1", + ownerKey: "owner:second-runtime", + generation: 2, + now: T3, + operationId: "operation:prompt-2", + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: DIGEST_B, + }); + assert.strictEqual(afterReconciliation.status, "prepared"); + + const sql = yield* SqlClient.SqlClient; + const stored = yield* sql<{ + readonly payload_digest: string; + readonly state: string; + }>` + SELECT payload_digest, state + FROM hermes_mutation_intents + WHERE operation_id = 'operation:prompt-2' + `; + assert.deepStrictEqual(stored, [{ payload_digest: DIGEST_B, state: "prepared" }]); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + return Effect.gen(function* () { + yield* firstRuntime; + yield* secondRuntime; + const databaseBytes = NodeFS.readFileSync(databasePath); + assert.notInclude(databaseBytes.toString("utf8"), privatePrompt); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + NodeFS.rmSync(directory, { recursive: true, force: true }); + }), + ), + ); +}); + +it.effect( + "recovers a prepared pre-binding create and atomically attaches it to the binding", + () => { + const directory = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3-hermes-prebinding-create-"), + ); + const databasePath = NodePath.join(directory, "state.sqlite"); + + const firstRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const prepared = yield* repository.prepareSessionCreateIntent({ + operationId: "operation:create-1", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + runId: "run:1", + attemptId: "attempt:1", + messageId: "message:1", + method: "session.create", + payloadDigest: DIGEST_A, + now: T0, + }); + assert.strictEqual(prepared.status, "prepared"); + + const bindings = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isNone(bindings)); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + const secondRuntime = Effect.gen(function* () { + yield* runMigrations({ toMigrationInclusive: 46 }); + const repository = yield* HermesSessionBindingRepository; + const recovered = yield* repository.getMutationIntent("operation:create-1"); + assert.isTrue(Option.isSome(recovered)); + if (Option.isNone(recovered)) return; + assert.deepStrictEqual( + { + bindingId: recovered.value.bindingId, + providerInstanceId: recovered.value.providerInstanceId, + profileKey: recovered.value.profileKey, + projectId: recovered.value.projectId, + threadId: recovered.value.threadId, + runId: recovered.value.runId, + attemptId: recovered.value.attemptId, + messageId: recovered.value.messageId, + payloadDigest: recovered.value.payloadDigest, + state: recovered.value.state, + }, + { + bindingId: null, + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + runId: "run:1", + attemptId: "attempt:1", + messageId: "message:1", + payloadDigest: DIGEST_A, + state: "prepared", + }, + ); + + const concurrent = yield* repository.prepareSessionCreateIntent({ + operationId: "operation:create-2", + providerInstanceId: "hermes-local", + profileKey: "profile:default", + projectId: "project:1", + threadId: "thread:1", + method: "session.create", + payloadDigest: DIGEST_B, + now: T1, + }); + assert.deepStrictEqual(concurrent, { + status: "unsettled_create", + operationId: "operation:create-1", + }); + + assert.isTrue( + yield* repository.transitionSessionCreateIntent({ + operationId: "operation:create-1", + from: "prepared", + to: "admitted", + now: T1, + }), + ); + + const mismatchedAttach = yield* Effect.result( + createBinding(repository, { + projectId: "project:mismatch", + createOperationId: "operation:create-1", + now: T2, + }), + ); + assert.strictEqual(mismatchedAttach._tag, "Failure"); + assert.isTrue(Option.isNone(yield* repository.getByThreadId("thread:1"))); + + assert.isTrue( + yield* createBinding(repository, { + createOperationId: "operation:create-1", + now: T2, + }), + ); + const binding = yield* repository.getByThreadId("thread:1"); + assert.isTrue(Option.isSome(binding)); + if (Option.isSome(binding)) { + assert.strictEqual(binding.value.projectId, "project:1"); + assert.strictEqual(binding.value.storedSessionKey, "stored:conversation-1"); + } + + const confirmed = yield* repository.getMutationIntent("operation:create-1"); + assert.isTrue(Option.isSome(confirmed)); + if (Option.isSome(confirmed)) { + assert.strictEqual(confirmed.value.bindingId, "hermes-binding:1"); + assert.strictEqual(confirmed.value.state, "confirmed"); + assert.strictEqual(confirmed.value.settledAt, T2); + } + + assert.isTrue( + yield* createBinding(repository, { + createOperationId: "operation:create-1", + now: T3, + }), + ); + }).pipe( + Effect.provide(testLayer(NodeSqliteClient.layer({ filename: databasePath }))), + Effect.scoped, + ); + + return Effect.gen(function* () { + yield* firstRuntime; + yield* secondRuntime; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + NodeFS.rmSync(directory, { recursive: true, force: true }); + }), + ), + ); + }, +); diff --git a/apps/server/src/hermes/HermesSessionBindingRepository.ts b/apps/server/src/hermes/HermesSessionBindingRepository.ts new file mode 100644 index 00000000000..03ac3b262ce --- /dev/null +++ b/apps/server/src/hermes/HermesSessionBindingRepository.ts @@ -0,0 +1,1363 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export const HermesProtocolClassification = Schema.Literals(["legacy", "supported", "unsupported"]); +export type HermesProtocolClassification = typeof HermesProtocolClassification.Type; + +export const HermesMutationIntentState = Schema.Literals([ + "prepared", + "admitted", + "confirmed", + "indeterminate", + "reconciled", + "rejected", +]); +export type HermesMutationIntentState = typeof HermesMutationIntentState.Type; + +export const HermesSessionBinding = Schema.Struct({ + bindingId: Schema.String, + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + storedSessionKey: Schema.String, + threadId: Schema.String, + protocolClassification: HermesProtocolClassification, + protocolMajor: Schema.NullOr(Schema.Number), + protocolMinor: Schema.NullOr(Schema.Number), + capabilities: Schema.Array(Schema.String), + reconciliationCursor: Schema.NullOr(Schema.String), + reconciliationFingerprint: Schema.NullOr(Schema.String), + titleRevision: Schema.Number, + titleOrigin: Schema.NullOr(Schema.String), + parentBindingId: Schema.NullOr(Schema.String), + branchBoundaryMode: Schema.NullOr(Schema.Literal("latest_only")), + branchBoundaryMessageId: Schema.NullOr(Schema.String), + branchBoundaryMessageCount: Schema.NullOr(Schema.Number), + leaseOwnerKey: Schema.NullOr(Schema.String), + leaseGeneration: Schema.Number, + leaseExpiresAt: Schema.NullOr(Schema.String), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type HermesSessionBinding = typeof HermesSessionBinding.Type; + +export const HermesMutationIntent = Schema.Struct({ + operationId: Schema.String, + bindingId: Schema.NullOr(Schema.String), + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + threadId: Schema.String, + runId: Schema.NullOr(Schema.String), + attemptId: Schema.NullOr(Schema.String), + messageId: Schema.NullOr(Schema.String), + mutationKind: Schema.String, + method: Schema.String, + payloadDigest: Schema.String, + ownerGeneration: Schema.Number, + state: HermesMutationIntentState, + preparedAt: Schema.String, + admittedAt: Schema.NullOr(Schema.String), + settledAt: Schema.NullOr(Schema.String), + updatedAt: Schema.String, +}); +export type HermesMutationIntent = typeof HermesMutationIntent.Type; + +export const HermesSessionImport = Schema.Struct({ + importId: Schema.String, + providerInstanceId: Schema.String, + profileKey: Schema.String, + projectId: Schema.String, + importKind: Schema.Literals(["session", "main"]), + storedSessionKey: Schema.NullOr(Schema.String), + threadId: Schema.String, + state: Schema.Literals(["prepared", "thread_created", "completed"]), + createdAt: Schema.String, + updatedAt: Schema.String, +}); +export type HermesSessionImport = typeof HermesSessionImport.Type; + +export interface PrepareHermesSessionImportInput { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; +} + +export interface CreateHermesSessionBindingInput { + readonly bindingId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly storedSessionKey: string; + readonly threadId: string; + readonly protocolClassification: HermesProtocolClassification; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly capabilities: ReadonlyArray; + readonly reconciliationCursor: string | null; + readonly reconciliationFingerprint: string | null; + readonly titleRevision?: number; + readonly titleOrigin?: string | null; + readonly parentBindingId?: string | null; + readonly branchBoundaryMode?: "latest_only" | null; + readonly branchBoundaryMessageId?: string | null; + readonly branchBoundaryMessageCount?: number | null; + readonly now: string; + /** + * When supplied, binding creation and confirmation/attachment of this + * pre-binding `session_create` intent commit in one transaction. + */ + readonly createOperationId?: string; +} + +export interface HermesBindingStoredIdentity { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly storedSessionKey: string; +} + +export interface HermesHistoryScope { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; +} + +export interface HermesLeaseFence { + readonly bindingId: string; + readonly ownerKey: string; + readonly generation: number; + /** + * Canonical UTC ISO timestamp used both for expiry comparison and auditing. + */ + readonly now: string; +} + +export interface HermesOwnerLease { + readonly bindingId: string; + readonly ownerKey: string; + readonly generation: number; + readonly expiresAt: string; +} + +export interface AcquireHermesOwnerLeaseInput { + readonly bindingId: string; + readonly ownerKey: string; + readonly expectedGeneration: number; + readonly now: string; + readonly expiresAt: string; +} + +export interface UpdateHermesNegotiationInput extends HermesLeaseFence { + readonly protocolClassification: HermesProtocolClassification; + readonly protocolMajor: number | null; + readonly protocolMinor: number | null; + readonly capabilities: ReadonlyArray; +} + +export interface UpdateHermesReconciliationInput extends HermesLeaseFence { + readonly cursor: string | null; + readonly fingerprint: string | null; +} + +export interface UpdateHermesTitleStateInput extends HermesLeaseFence { + readonly revision: number; + readonly origin: string; +} + +export interface PrepareHermesMutationIntentInput extends HermesLeaseFence { + readonly operationId: string; + readonly mutationKind: string; + readonly method: string; + /** + * Digest of the external write payload. Raw payloads, prompts, transcripts, + * credentials, and gateway session ids must never be passed to this service. + */ + readonly payloadDigest: string; +} + +export interface PrepareHermesSessionCreateIntentInput { + readonly operationId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly threadId: string; + readonly runId?: string | null; + readonly attemptId?: string | null; + readonly messageId?: string | null; + readonly method: string; + readonly payloadDigest: string; + readonly now: string; +} + +export type PrepareHermesMutationIntentResult = + | { + readonly status: "prepared"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "lease_not_held"; + } + | { + readonly status: "operation_exists"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "unsettled_prompt"; + readonly operationId: string; + }; + +export type PrepareHermesSessionCreateIntentResult = + | { + readonly status: "prepared"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "operation_exists"; + readonly intent: HermesMutationIntent; + } + | { + readonly status: "unsettled_create"; + readonly operationId: string; + }; + +export interface TransitionHermesMutationIntentInput extends HermesLeaseFence { + readonly operationId: string; + readonly from: HermesMutationIntentState; + readonly to: HermesMutationIntentState; +} + +export interface TransitionHermesSessionCreateIntentInput { + readonly operationId: string; + readonly from: "prepared" | "admitted"; + readonly to: "admitted" | "indeterminate" | "rejected"; + readonly now: string; +} + +export class HermesSessionBindingRepositoryError extends Schema.TaggedErrorClass()( + "HermesSessionBindingRepositoryError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Hermes session binding repository failed in ${this.operation}: ${this.detail}`; + } +} + +export interface HermesSessionBindingRepositoryShape { + readonly createBinding: ( + input: CreateHermesSessionBindingInput, + ) => Effect.Effect; + readonly getByThreadId: ( + threadId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly getByStoredIdentity: ( + identity: HermesBindingStoredIdentity, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly updateNegotiation: ( + input: UpdateHermesNegotiationInput, + ) => Effect.Effect; + readonly updateReconciliation: ( + input: UpdateHermesReconciliationInput, + ) => Effect.Effect; + readonly updateTitleState: ( + input: UpdateHermesTitleStateInput, + ) => Effect.Effect; + readonly acquireOwnerLease: ( + input: AcquireHermesOwnerLeaseInput, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly renewOwnerLease: ( + input: HermesLeaseFence & { readonly expiresAt: string }, + ) => Effect.Effect; + readonly releaseOwnerLease: ( + input: Omit & { readonly now: string }, + ) => Effect.Effect; + readonly prepareMutationIntent: ( + input: PrepareHermesMutationIntentInput, + ) => Effect.Effect; + readonly prepareSessionCreateIntent: ( + input: PrepareHermesSessionCreateIntentInput, + ) => Effect.Effect; + readonly transitionSessionCreateIntent: ( + input: TransitionHermesSessionCreateIntentInput, + ) => Effect.Effect; + readonly transitionMutationIntent: ( + input: TransitionHermesMutationIntentInput, + ) => Effect.Effect; + readonly getMutationIntent: ( + operationId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly listUnsettledMutationIntents: ( + bindingId: string, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly prepareSessionImport: ( + input: PrepareHermesSessionImportInput, + ) => Effect.Effect; + readonly getSessionImportByStoredIdentity: ( + identity: HermesBindingStoredIdentity & { readonly projectId: string }, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly getMainSessionImport: (input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + }) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly transitionSessionImport: (input: { + readonly importId: string; + readonly from: "prepared" | "thread_created"; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => Effect.Effect; + readonly listHistoryThreadIds: ( + scope: HermesHistoryScope, + ) => Effect.Effect, HermesSessionBindingRepositoryError>; + readonly clearHistoryRecords: ( + scope: HermesHistoryScope, + ) => Effect.Effect; +} + +export class HermesSessionBindingRepository extends Context.Service< + HermesSessionBindingRepository, + HermesSessionBindingRepositoryShape +>()("t3/hermes/HermesSessionBindingRepository") {} + +interface BindingRow { + readonly binding_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly stored_session_key: string; + readonly thread_id: string; + readonly protocol_classification: string; + readonly protocol_major: number | null; + readonly protocol_minor: number | null; + readonly capabilities_json: string; + readonly reconciliation_cursor: string | null; + readonly reconciliation_fingerprint: string | null; + readonly title_revision: number; + readonly title_origin: string | null; + readonly parent_binding_id: string | null; + readonly branch_boundary_mode: string | null; + readonly branch_boundary_message_id: string | null; + readonly branch_boundary_message_count: number | null; + readonly lease_owner_key: string | null; + readonly lease_generation: number; + readonly lease_expires_at: string | null; + readonly created_at: string; + readonly updated_at: string; +} + +interface IntentRow { + readonly operation_id: string; + readonly binding_id: string | null; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly thread_id: string; + readonly run_id: string | null; + readonly attempt_id: string | null; + readonly message_id: string | null; + readonly mutation_kind: string; + readonly method: string; + readonly payload_digest: string; + readonly owner_generation: number; + readonly state: string; + readonly prepared_at: string; + readonly admitted_at: string | null; + readonly settled_at: string | null; + readonly updated_at: string; +} + +interface ImportRow { + readonly import_id: string; + readonly provider_instance_id: string; + readonly profile_key: string; + readonly project_id: string; + readonly import_kind: string; + readonly stored_session_key: string | null; + readonly thread_id: string; + readonly state: string; + readonly created_at: string; + readonly updated_at: string; +} + +const decodeCapabilities = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(Schema.String)), +); +const decodeBinding = Schema.decodeUnknownEffect(HermesSessionBinding); +const decodeIntent = Schema.decodeUnknownEffect(HermesMutationIntent); +const decodeImport = Schema.decodeUnknownEffect(HermesSessionImport); +const isRepositoryError = Schema.is(HermesSessionBindingRepositoryError); + +function repositoryError(operation: string, detail: string, cause?: unknown) { + return new HermesSessionBindingRepositoryError({ + operation, + detail, + ...(cause === undefined ? {} : { cause }), + }); +} + +function mapRepositoryError(operation: string, detail: string) { + return (cause: unknown): HermesSessionBindingRepositoryError => + isRepositoryError(cause) ? cause : repositoryError(operation, detail, cause); +} + +const bindingFromRow = Effect.fn("HermesSessionBindingRepository.bindingFromRow")(function* ( + row: BindingRow, +) { + const capabilities = yield* decodeCapabilities(row.capabilities_json); + return yield* decodeBinding({ + bindingId: row.binding_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + storedSessionKey: row.stored_session_key, + threadId: row.thread_id, + protocolClassification: row.protocol_classification, + protocolMajor: row.protocol_major, + protocolMinor: row.protocol_minor, + capabilities, + reconciliationCursor: row.reconciliation_cursor, + reconciliationFingerprint: row.reconciliation_fingerprint, + titleRevision: row.title_revision, + titleOrigin: row.title_origin, + parentBindingId: row.parent_binding_id, + branchBoundaryMode: row.branch_boundary_mode, + branchBoundaryMessageId: row.branch_boundary_message_id, + branchBoundaryMessageCount: row.branch_boundary_message_count, + leaseOwnerKey: row.lease_owner_key, + leaseGeneration: row.lease_generation, + leaseExpiresAt: row.lease_expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +}); + +const intentFromRow = Effect.fn("HermesSessionBindingRepository.intentFromRow")(function* ( + row: IntentRow, +) { + return yield* decodeIntent({ + operationId: row.operation_id, + bindingId: row.binding_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + threadId: row.thread_id, + runId: row.run_id, + attemptId: row.attempt_id, + messageId: row.message_id, + mutationKind: row.mutation_kind, + method: row.method, + payloadDigest: row.payload_digest, + ownerGeneration: row.owner_generation, + state: row.state, + preparedAt: row.prepared_at, + admittedAt: row.admitted_at, + settledAt: row.settled_at, + updatedAt: row.updated_at, + }); +}); + +const importFromRow = Effect.fn("HermesSessionBindingRepository.importFromRow")(function* ( + row: ImportRow, +) { + return yield* decodeImport({ + importId: row.import_id, + providerInstanceId: row.provider_instance_id, + profileKey: row.profile_key, + projectId: row.project_id, + importKind: row.import_kind, + storedSessionKey: row.stored_session_key, + threadId: row.thread_id, + state: row.state, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); +}); + +function optionFromRows( + rows: ReadonlyArray, + decode: (row: A) => Effect.Effect, +): Effect.Effect, E, R> { + const row = rows[0]; + return row === undefined + ? Effect.succeed(Option.none()) + : decode(row).pipe(Effect.map((value) => Option.some(value))); +} + +function normalizedCapabilities(capabilities: ReadonlyArray): string { + return JSON.stringify([...new Set(capabilities)].toSorted()); +} + +function validateProtocolVersion( + operation: string, + major: number | null, + minor: number | null, +): Effect.Effect { + if ((major === null) !== (minor === null)) { + return Effect.fail( + repositoryError( + operation, + "Protocol major and minor must either both be set or both be null.", + ), + ); + } + if ( + (major !== null && (!Number.isInteger(major) || major < 0)) || + (minor !== null && (!Number.isInteger(minor) || minor < 0)) + ) { + return Effect.fail( + repositoryError(operation, "Protocol major and minor must be non-negative integers."), + ); + } + return Effect.void; +} + +const SHA256_DIGEST = /^[a-f0-9]{64}$/; + +const allowedTransitions: Readonly>> = { + prepared: new Set(["admitted", "indeterminate", "reconciled", "rejected"]), + admitted: new Set(["confirmed", "indeterminate", "rejected"]), + confirmed: new Set(), + indeterminate: new Set(["reconciled", "rejected"]), + reconciled: new Set(), + rejected: new Set(), +}; + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const createBinding = Effect.fn("HermesSessionBindingRepository.createBinding")( + function* (input: CreateHermesSessionBindingInput) { + yield* validateProtocolVersion("createBinding", input.protocolMajor, input.protocolMinor); + return yield* sql.withTransaction( + Effect.gen(function* () { + const rows = yield* sql<{ readonly binding_id: string }>` + INSERT INTO hermes_session_bindings ( + binding_id, + provider_instance_id, + profile_key, + project_id, + stored_session_key, + thread_id, + protocol_classification, + protocol_major, + protocol_minor, + capabilities_json, + reconciliation_cursor, + reconciliation_fingerprint, + title_revision, + title_origin, + parent_binding_id, + branch_boundary_mode, + branch_boundary_message_id, + branch_boundary_message_count, + lease_owner_key, + lease_generation, + lease_expires_at, + created_at, + updated_at + ) VALUES ( + ${input.bindingId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.storedSessionKey}, + ${input.threadId}, + ${input.protocolClassification}, + ${input.protocolMajor}, + ${input.protocolMinor}, + ${normalizedCapabilities(input.capabilities)}, + ${input.reconciliationCursor}, + ${input.reconciliationFingerprint}, + ${input.titleRevision ?? 0}, + ${input.titleOrigin ?? null}, + ${input.parentBindingId ?? null}, + ${input.branchBoundaryMode ?? null}, + ${input.branchBoundaryMessageId ?? null}, + ${input.branchBoundaryMessageCount ?? null}, + NULL, + 0, + NULL, + ${input.now}, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING binding_id + `; + if (rows.length === 0) { + if (input.createOperationId === undefined) return false; + const alreadyAttached = yield* sql<{ readonly operation_id: string }>` + SELECT intent.operation_id + FROM hermes_mutation_intents AS intent + INNER JOIN hermes_session_bindings AS binding + ON binding.binding_id = intent.binding_id + WHERE intent.operation_id = ${input.createOperationId} + AND intent.state = 'confirmed' + AND intent.mutation_kind = 'session_create' + AND binding.binding_id = ${input.bindingId} + AND binding.provider_instance_id = ${input.providerInstanceId} + AND binding.profile_key = ${input.profileKey} + AND binding.project_id = ${input.projectId} + AND binding.stored_session_key = ${input.storedSessionKey} + AND binding.thread_id = ${input.threadId} + `; + return alreadyAttached.length === 1; + } + if (input.createOperationId === undefined) return true; + + const attached = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + binding_id = ${input.bindingId}, + state = 'confirmed', + settled_at = ${input.now}, + updated_at = ${input.now} + WHERE operation_id = ${input.createOperationId} + AND binding_id IS NULL + AND provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND thread_id = ${input.threadId} + AND mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + RETURNING operation_id + `; + if (attached.length !== 1) { + return yield* repositoryError( + "createBinding", + "The pre-binding session-create intent was missing or did not match the binding identity.", + ); + } + return true; + }), + ); + }, + Effect.mapError(mapRepositoryError("createBinding", "Could not create the binding.")), + ); + + const getByThreadId = Effect.fn("HermesSessionBindingRepository.getByThreadId")( + function* (threadId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_session_bindings + WHERE thread_id = ${threadId} + `; + return yield* optionFromRows(rows, bindingFromRow); + }, + Effect.mapError(mapRepositoryError("getByThreadId", "Could not load the binding.")), + ); + + const getByStoredIdentity = Effect.fn("HermesSessionBindingRepository.getByStoredIdentity")( + function* (identity: HermesBindingStoredIdentity) { + const rows = yield* sql` + SELECT * + FROM hermes_session_bindings + WHERE provider_instance_id = ${identity.providerInstanceId} + AND profile_key = ${identity.profileKey} + AND stored_session_key = ${identity.storedSessionKey} + `; + return yield* optionFromRows(rows, bindingFromRow); + }, + Effect.mapError(mapRepositoryError("getByStoredIdentity", "Could not load the binding.")), + ); + + const updateNegotiation = Effect.fn("HermesSessionBindingRepository.updateNegotiation")( + function* (input: UpdateHermesNegotiationInput) { + yield* validateProtocolVersion("updateNegotiation", input.protocolMajor, input.protocolMinor); + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + protocol_classification = ${input.protocolClassification}, + protocol_major = ${input.protocolMajor}, + protocol_minor = ${input.protocolMinor}, + capabilities_json = ${normalizedCapabilities(input.capabilities)}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateNegotiation", "Could not update negotiation.")), + ); + + const updateReconciliation = Effect.fn("HermesSessionBindingRepository.updateReconciliation")( + function* (input: UpdateHermesReconciliationInput) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + reconciliation_cursor = ${input.cursor}, + reconciliation_fingerprint = ${input.fingerprint}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateReconciliation", "Could not update reconciliation.")), + ); + + const updateTitleState = Effect.fn("HermesSessionBindingRepository.updateTitleState")( + function* (input: UpdateHermesTitleStateInput) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET + title_revision = ${input.revision}, + title_origin = ${input.origin}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + AND title_revision < ${input.revision} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("updateTitleState", "Could not update title state.")), + ); + + const acquireOwnerLease = Effect.fn("HermesSessionBindingRepository.acquireOwnerLease")( + function* (input: AcquireHermesOwnerLeaseInput) { + if (input.expiresAt <= input.now) { + return yield* repositoryError( + "acquireOwnerLease", + "Lease expiry must be later than the compare-and-swap timestamp.", + ); + } + const rows = yield* sql<{ + readonly binding_id: string; + readonly lease_owner_key: string; + readonly lease_generation: number; + readonly lease_expires_at: string; + }>` + UPDATE hermes_session_bindings + SET + lease_owner_key = ${input.ownerKey}, + lease_generation = lease_generation + 1, + lease_expires_at = ${input.expiresAt}, + updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_generation = ${input.expectedGeneration} + AND ( + lease_owner_key IS NULL + OR lease_expires_at <= ${input.now} + OR lease_owner_key = ${input.ownerKey} + ) + RETURNING binding_id, lease_owner_key, lease_generation, lease_expires_at + `; + const row = rows[0]; + return row === undefined + ? Option.none() + : Option.some({ + bindingId: row.binding_id, + ownerKey: row.lease_owner_key, + generation: row.lease_generation, + expiresAt: row.lease_expires_at, + }); + }, + Effect.mapError(mapRepositoryError("acquireOwnerLease", "Could not acquire the owner lease.")), + ); + + const renewOwnerLease = Effect.fn("HermesSessionBindingRepository.renewOwnerLease")( + function* (input: HermesLeaseFence & { readonly expiresAt: string }) { + if (input.expiresAt <= input.now) { + return yield* repositoryError( + "renewOwnerLease", + "Lease expiry must be later than the compare-and-swap timestamp.", + ); + } + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET lease_expires_at = ${input.expiresAt}, updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("renewOwnerLease", "Could not renew the owner lease.")), + ); + + const releaseOwnerLease = Effect.fn("HermesSessionBindingRepository.releaseOwnerLease")( + function* (input: Omit & { readonly now: string }) { + const rows = yield* sql<{ readonly binding_id: string }>` + UPDATE hermes_session_bindings + SET lease_owner_key = NULL, lease_expires_at = NULL, updated_at = ${input.now} + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + RETURNING binding_id + `; + return rows.length === 1; + }, + Effect.mapError(mapRepositoryError("releaseOwnerLease", "Could not release the owner lease.")), + ); + + const getMutationIntent = Effect.fn("HermesSessionBindingRepository.getMutationIntent")( + function* (operationId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_mutation_intents + WHERE operation_id = ${operationId} + `; + return yield* optionFromRows(rows, intentFromRow); + }, + Effect.mapError(mapRepositoryError("getMutationIntent", "Could not load the mutation intent.")), + ); + + const prepareMutationIntent = Effect.fn("HermesSessionBindingRepository.prepareMutationIntent")( + function* (input: PrepareHermesMutationIntentInput) { + if (!SHA256_DIGEST.test(input.payloadDigest)) { + return yield* repositoryError( + "prepareMutationIntent", + "payloadDigest must be a lowercase SHA-256 hex digest.", + ); + } + const rows = yield* sql` + INSERT INTO hermes_mutation_intents ( + operation_id, + binding_id, + provider_instance_id, + profile_key, + project_id, + thread_id, + run_id, + attempt_id, + message_id, + mutation_kind, + method, + payload_digest, + owner_generation, + state, + prepared_at, + admitted_at, + settled_at, + updated_at + ) + SELECT + ${input.operationId}, + binding_id, + provider_instance_id, + profile_key, + project_id, + thread_id, + NULL, + NULL, + NULL, + ${input.mutationKind}, + ${input.method}, + ${input.payloadDigest}, + ${input.generation}, + 'prepared', + ${input.now}, + NULL, + NULL, + ${input.now} + FROM hermes_session_bindings + WHERE binding_id = ${input.bindingId} + AND lease_owner_key = ${input.ownerKey} + AND lease_generation = ${input.generation} + AND lease_expires_at > ${input.now} + ON CONFLICT DO NOTHING + RETURNING * + `; + const prepared = rows[0]; + if (prepared !== undefined) { + return { + status: "prepared", + intent: yield* intentFromRow(prepared), + } satisfies PrepareHermesMutationIntentResult; + } + + const existing = yield* getMutationIntent(input.operationId); + if (Option.isSome(existing)) { + return { + status: "operation_exists", + intent: existing.value, + } satisfies PrepareHermesMutationIntentResult; + } + + if (input.mutationKind === "prompt") { + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE binding_id = ${input.bindingId} + AND mutation_kind = 'prompt' + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + LIMIT 1 + `; + if (unsettled[0] !== undefined) { + return { + status: "unsettled_prompt", + operationId: unsettled[0].operation_id, + } satisfies PrepareHermesMutationIntentResult; + } + } + return { status: "lease_not_held" } satisfies PrepareHermesMutationIntentResult; + }, + Effect.mapError( + mapRepositoryError("prepareMutationIntent", "Could not prepare the mutation intent."), + ), + ); + + const prepareSessionCreateIntent = Effect.fn( + "HermesSessionBindingRepository.prepareSessionCreateIntent", + )( + function* (input: PrepareHermesSessionCreateIntentInput) { + if (!SHA256_DIGEST.test(input.payloadDigest)) { + return yield* repositoryError( + "prepareSessionCreateIntent", + "payloadDigest must be a lowercase SHA-256 hex digest.", + ); + } + const rows = yield* sql` + INSERT INTO hermes_mutation_intents ( + operation_id, + binding_id, + provider_instance_id, + profile_key, + project_id, + project_id, + thread_id, + run_id, + attempt_id, + message_id, + mutation_kind, + method, + payload_digest, + owner_generation, + state, + prepared_at, + admitted_at, + settled_at, + updated_at + ) VALUES ( + ${input.operationId}, + NULL, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.projectId}, + ${input.threadId}, + ${input.runId ?? null}, + ${input.attemptId ?? null}, + ${input.messageId ?? null}, + 'session_create', + ${input.method}, + ${input.payloadDigest}, + 0, + 'prepared', + ${input.now}, + NULL, + NULL, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING * + `; + const prepared = rows[0]; + if (prepared !== undefined) { + return { + status: "prepared", + intent: yield* intentFromRow(prepared), + } satisfies PrepareHermesSessionCreateIntentResult; + } + + const existing = yield* getMutationIntent(input.operationId); + if (Option.isSome(existing)) { + return { + status: "operation_exists", + intent: existing.value, + } satisfies PrepareHermesSessionCreateIntentResult; + } + + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND thread_id = ${input.threadId} + AND mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + LIMIT 1 + `; + return { + status: "unsettled_create", + operationId: unsettled[0]!.operation_id, + } satisfies PrepareHermesSessionCreateIntentResult; + }, + Effect.mapError( + mapRepositoryError( + "prepareSessionCreateIntent", + "Could not prepare the pre-binding session-create intent.", + ), + ), + ); + + const transitionSessionCreateIntent = Effect.fn( + "HermesSessionBindingRepository.transitionSessionCreateIntent", + )( + function* (input: TransitionHermesSessionCreateIntentInput) { + const valid = + (input.from === "prepared" && + (input.to === "admitted" || input.to === "indeterminate" || input.to === "rejected")) || + (input.from === "admitted" && (input.to === "indeterminate" || input.to === "rejected")); + if (!valid) { + return yield* repositoryError( + "transitionSessionCreateIntent", + `Invalid pre-binding session-create transition ${input.from} -> ${input.to}.`, + ); + } + const terminal = input.to === "rejected"; + const rows = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + state = ${input.to}, + admitted_at = CASE + WHEN ${input.to} = 'admitted' THEN COALESCE(admitted_at, ${input.now}) + ELSE admitted_at + END, + settled_at = CASE WHEN ${terminal ? 1 : 0} = 1 THEN ${input.now} ELSE NULL END, + updated_at = ${input.now} + WHERE operation_id = ${input.operationId} + AND binding_id IS NULL + AND mutation_kind = 'session_create' + AND state = ${input.from} + RETURNING operation_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError( + "transitionSessionCreateIntent", + "Could not transition the pre-binding session-create intent.", + ), + ), + ); + + const transitionMutationIntent = Effect.fn( + "HermesSessionBindingRepository.transitionMutationIntent", + )( + function* (input: TransitionHermesMutationIntentInput) { + if (!allowedTransitions[input.from].has(input.to)) { + return yield* repositoryError( + "transitionMutationIntent", + `Invalid mutation intent transition ${input.from} -> ${input.to}.`, + ); + } + const terminal = + input.to === "confirmed" || input.to === "reconciled" || input.to === "rejected"; + const rows = yield* sql<{ readonly operation_id: string }>` + UPDATE hermes_mutation_intents + SET + state = ${input.to}, + admitted_at = CASE + WHEN ${input.to} = 'admitted' THEN COALESCE(admitted_at, ${input.now}) + ELSE admitted_at + END, + settled_at = CASE WHEN ${terminal ? 1 : 0} = 1 THEN ${input.now} ELSE NULL END, + updated_at = ${input.now} + WHERE operation_id = ${input.operationId} + AND binding_id = ${input.bindingId} + AND state = ${input.from} + AND EXISTS ( + SELECT 1 + FROM hermes_session_bindings AS binding + WHERE binding.binding_id = hermes_mutation_intents.binding_id + AND binding.lease_owner_key = ${input.ownerKey} + AND binding.lease_generation = ${input.generation} + AND binding.lease_expires_at > ${input.now} + ) + RETURNING operation_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError("transitionMutationIntent", "Could not transition the mutation intent."), + ), + ); + + const listUnsettledMutationIntents = Effect.fn( + "HermesSessionBindingRepository.listUnsettledMutationIntents", + )( + function* (bindingId: string) { + const rows = yield* sql` + SELECT * + FROM hermes_mutation_intents + WHERE binding_id = ${bindingId} + AND state IN ('prepared', 'admitted', 'indeterminate') + ORDER BY prepared_at ASC, operation_id ASC + `; + return yield* Effect.forEach(rows, intentFromRow, { concurrency: 1 }); + }, + Effect.mapError( + mapRepositoryError( + "listUnsettledMutationIntents", + "Could not list unsettled mutation intents.", + ), + ), + ); + + const prepareSessionImport = Effect.fn("HermesSessionBindingRepository.prepareSessionImport")( + function* (input: PrepareHermesSessionImportInput) { + const inserted = yield* sql` + INSERT INTO hermes_session_imports ( + import_id, + provider_instance_id, + profile_key, + project_id, + import_kind, + stored_session_key, + thread_id, + state, + created_at, + updated_at + ) VALUES ( + ${input.importId}, + ${input.providerInstanceId}, + ${input.profileKey}, + ${input.projectId}, + ${input.importKind}, + ${input.storedSessionKey}, + ${input.threadId}, + 'prepared', + ${input.now}, + ${input.now} + ) + ON CONFLICT DO NOTHING + RETURNING * + `; + if (inserted[0] !== undefined) return yield* importFromRow(inserted[0]); + + const existing = + input.importKind === "main" + ? yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND import_kind = 'main' + LIMIT 1 + ` + : yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND stored_session_key = ${input.storedSessionKey} + AND import_kind = 'session' + LIMIT 1 + `; + if (existing[0] === undefined) { + return yield* repositoryError( + "prepareSessionImport", + "Import identity conflicted with a different durable row.", + ); + } + return yield* importFromRow(existing[0]); + }, + Effect.mapError( + mapRepositoryError("prepareSessionImport", "Could not prepare the session import."), + ), + ); + + const getSessionImportByStoredIdentity = Effect.fn( + "HermesSessionBindingRepository.getSessionImportByStoredIdentity", + )( + function* (identity: HermesBindingStoredIdentity & { readonly projectId: string }) { + const rows = yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${identity.providerInstanceId} + AND profile_key = ${identity.profileKey} + AND project_id = ${identity.projectId} + AND stored_session_key = ${identity.storedSessionKey} + AND import_kind = 'session' + LIMIT 1 + `; + return rows[0] === undefined ? Option.none() : Option.some(yield* importFromRow(rows[0])); + }, + Effect.mapError( + mapRepositoryError( + "getSessionImportByStoredIdentity", + "Could not read the stored-session import.", + ), + ), + ); + + const getMainSessionImport = Effect.fn("HermesSessionBindingRepository.getMainSessionImport")( + function* (input: { + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + }) { + const rows = yield* sql` + SELECT * FROM hermes_session_imports + WHERE provider_instance_id = ${input.providerInstanceId} + AND profile_key = ${input.profileKey} + AND project_id = ${input.projectId} + AND import_kind = 'main' + LIMIT 1 + `; + return rows[0] === undefined ? Option.none() : Option.some(yield* importFromRow(rows[0])); + }, + Effect.mapError( + mapRepositoryError("getMainSessionImport", "Could not read the Hermes Main import."), + ), + ); + + const transitionSessionImport = Effect.fn( + "HermesSessionBindingRepository.transitionSessionImport", + )( + function* (input: { + readonly importId: string; + readonly from: "prepared" | "thread_created"; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) { + const valid = + (input.from === "prepared" && input.to === "thread_created") || + (input.from === "thread_created" && input.to === "completed"); + if (!valid) { + return yield* repositoryError( + "transitionSessionImport", + `Invalid session import transition ${input.from} -> ${input.to}.`, + ); + } + const rows = yield* sql<{ readonly import_id: string }>` + UPDATE hermes_session_imports + SET state = ${input.to}, updated_at = ${input.now} + WHERE import_id = ${input.importId} + AND state = ${input.from} + RETURNING import_id + `; + return rows.length === 1; + }, + Effect.mapError( + mapRepositoryError("transitionSessionImport", "Could not transition the session import."), + ), + ); + + const listHistoryThreadIds = Effect.fn("HermesSessionBindingRepository.listHistoryThreadIds")( + function* (scope: HermesHistoryScope) { + const rows = yield* sql<{ readonly thread_id: string }>` + SELECT thread_id + FROM hermes_session_bindings + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + UNION + SELECT thread_id + FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + return rows.map((row) => row.thread_id); + }, + Effect.mapError( + mapRepositoryError("listHistoryThreadIds", "Could not list locally owned Hermes threads."), + ), + ); + + const clearHistoryRecords = Effect.fn("HermesSessionBindingRepository.clearHistoryRecords")( + function* (scope: HermesHistoryScope) { + return yield* sql.withTransaction( + Effect.gen(function* () { + const unsettled = yield* sql<{ readonly operation_id: string }>` + SELECT operation_id + FROM hermes_mutation_intents + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + AND state IN ('prepared', 'admitted', 'indeterminate') + LIMIT 1 + `; + if (unsettled[0] !== undefined) { + return yield* repositoryError( + "clearHistoryRecords", + `History reset is blocked by unsettled Hermes mutation ${unsettled[0].operation_id}.`, + ); + } + const rows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS count + FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_mutation_intents + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_session_bindings + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + yield* sql` + DELETE FROM hermes_session_imports + WHERE provider_instance_id = ${scope.providerInstanceId} + AND profile_key = ${scope.profileKey} + AND project_id = ${scope.projectId} + `; + return rows[0]?.count ?? 0; + }), + ); + }, + Effect.mapError( + mapRepositoryError("clearHistoryRecords", "Could not clear T3 Work history records."), + ), + ); + + return HermesSessionBindingRepository.of({ + createBinding, + getByThreadId, + getByStoredIdentity, + updateNegotiation, + updateReconciliation, + updateTitleState, + acquireOwnerLease, + renewOwnerLease, + releaseOwnerLease, + prepareMutationIntent, + prepareSessionCreateIntent, + transitionSessionCreateIntent, + transitionMutationIntent, + getMutationIntent, + listUnsettledMutationIntents, + prepareSessionImport, + getSessionImportByStoredIdentity, + getMainSessionImport, + transitionSessionImport, + listHistoryThreadIds, + clearHistoryRecords, + }); +}); + +export const layer: Layer.Layer = + Layer.effect(HermesSessionBindingRepository, make); diff --git a/apps/server/src/hermes/HermesSessionCatalog.ts b/apps/server/src/hermes/HermesSessionCatalog.ts new file mode 100644 index 00000000000..3f9dbf90119 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionCatalog.ts @@ -0,0 +1,79 @@ +import { + type HermesGatewayCompatibility, + type HermesGatewayStoredSessionSummary, + HermesSessionsError, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import { HermesGatewayClient } from "./HermesGatewayClient.ts"; + +export interface HermesSessionCatalogSnapshot { + readonly providerInstanceId: ProviderInstanceId; + readonly profileKey: string; + readonly compatibility: HermesGatewayCompatibility; + readonly sessions: ReadonlyArray; +} + +export interface HermesSessionCatalogShape { + readonly profileKey: string; + readonly importEnabled: boolean; + readonly list: ( + limit: number, + ) => Effect.Effect; +} + +export function makeHermesSessionCatalog(input: { + readonly providerInstanceId: ProviderInstanceId; + readonly endpoint: string; + readonly authToken: string | undefined; + readonly profileKey: string; + readonly importEnabled: boolean; + readonly clientFactory?: (options: { + readonly endpoint: string; + readonly authToken: string; + }) => Pick; +}): HermesSessionCatalogShape { + return { + profileKey: input.profileKey, + importEnabled: input.importEnabled, + list: Effect.fn("HermesSessionCatalog.list")(function* (limit) { + if (input.authToken === undefined || input.endpoint.trim().length === 0) { + return yield* new HermesSessionsError({ + code: "provider_not_configured", + message: "Hermes session discovery requires a configured endpoint and gateway token.", + }); + } + const client = + input.clientFactory?.({ endpoint: input.endpoint, authToken: input.authToken }) ?? + new HermesGatewayClient({ + endpoint: input.endpoint, + authToken: input.authToken, + }); + return yield* Effect.tryPromise({ + try: async () => { + try { + const compatibility = await client.connect(); + const result = await client.listSessions({ + profile: input.profileKey, + limit, + }); + return { + providerInstanceId: input.providerInstanceId, + profileKey: input.profileKey, + compatibility, + sessions: result.sessions, + }; + } finally { + client.close(); + } + }, + catch: (cause) => + new HermesSessionsError({ + code: "gateway_error", + message: cause instanceof Error ? cause.message : "Hermes session discovery failed.", + }), + }); + }), + }; +} diff --git a/apps/server/src/hermes/HermesSessionImportService.test.ts b/apps/server/src/hermes/HermesSessionImportService.test.ts new file mode 100644 index 00000000000..dbd00c50f59 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionImportService.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationV2Command, +} from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; + +import { + HERMES_IMPORT_TRANSPORT_SOURCES, + classifyHermesImportedSession, + hermesImportCapabilityError, + isHermesImportTransportSource, + isHermesSessionWithinImportAge, + make, +} from "./HermesSessionImportService.ts"; +import { + HermesSessionBindingRepository, + type HermesSessionImport, +} from "./HermesSessionBindingRepository.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import type { ProviderInstance } from "../provider/ProviderDriver.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ServerConfig } from "../config.ts"; + +const TEST_T3_WORK_DIRECTORY = "/test/t3-work"; + +const testProjectService = (projectId: ProjectId) => + ProjectService.of({ + getByWorkspaceRoot: () => + Effect.succeed( + Option.some({ + id: projectId, + workspaceRoot: TEST_T3_WORK_DIRECTORY, + } as never), + ), + } as never); + +const testServerConfig = ServerConfig.of({ + t3WorkDir: TEST_T3_WORK_DIRECTORY, +} as never); + +describe("Hermes transport session import policy", () => { + it("recognizes every pinned built-in messaging transport without importing local surfaces", () => { + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("discord"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("telegram"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("slack"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("whatsapp"); + expect(HERMES_IMPORT_TRANSPORT_SOURCES).toContain("matrix"); + expect(isHermesImportTransportSource(" Discord ")).toBe(true); + expect(isHermesImportTransportSource("tui")).toBe(false); + expect(isHermesImportTransportSource("cli")).toBe(false); + expect(isHermesImportTransportSource("local")).toBe(false); + expect(isHermesImportTransportSource("custom-unverified-source")).toBe(false); + }); + + it("keeps the inclusive 72-hour boundary unsettled and settles only older sessions", () => { + const now = Date.UTC(2026, 6, 26, 12); + const cutoffSeconds = (now - 72 * 60 * 60 * 1_000) / 1_000; + + expect(classifyHermesImportedSession(cutoffSeconds, now)).toBe("unsettled"); + expect(classifyHermesImportedSession(cutoffSeconds + 1, now)).toBe("unsettled"); + expect(classifyHermesImportedSession(cutoffSeconds - 1, now)).toBe("settled"); + }); + + it("keeps the inclusive selected-age boundary and rejects older timestamps", () => { + const now = Date.UTC(2026, 6, 26, 12); + const cutoffSeconds = (now - 24 * 60 * 60 * 1_000) / 1_000; + + expect(isHermesSessionWithinImportAge(cutoffSeconds, 1, now)).toBe(true); + expect(isHermesSessionWithinImportAge(cutoffSeconds + 1, 1, now)).toBe(true); + expect(isHermesSessionWithinImportAge(cutoffSeconds - 1, 1, now)).toBe(false); + }); + + it("requires explicit negotiated import and session-list evidence", () => { + expect( + hermesImportCapabilityError({ + status: "legacy", + protocol: null, + capabilities: [], + inventory: null, + reason: "no negotiation", + }), + ).toContain("evidence-backed"); + expect( + hermesImportCapabilityError({ + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["session.lifecycle"], + inventory: ["session.lifecycle"], + reason: "partial negotiation", + }), + ).toContain("profile.import"); + expect( + hermesImportCapabilityError({ + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "complete negotiation", + }), + ).toBeNull(); + }); + + effectIt.effect("rejects discovery when profile import is disabled at the server boundary", () => + Effect.gen(function* () { + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "disabled-profile", + importEnabled: false, + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, {} as never), + Effect.provideService(ThreadManagementService, {} as never), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const error = yield* Effect.flip( + service.discover({ providerInstanceId: ProviderInstanceId.make("hermes-disabled") }), + ); + expect(error.message).toContain("disabled"); + }), + ); + + effectIt.effect( + "imports only transport sessions, settles old shells, and replays as already imported", + () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("hermes-work"); + const profileKey = "private-work"; + const recentSeconds = 0; + const imports = new Map(); + const bindings = new Set(); + const commands: OrchestrationV2Command[] = []; + + const repository = { + getSessionImportByStoredIdentity: (identity: { readonly storedSessionKey: string }) => + Effect.succeed( + Option.fromUndefinedOr( + [...imports.values()].find( + (row) => row.storedSessionKey === identity.storedSessionKey, + ), + ), + ), + getMainSessionImport: () => + Effect.succeed( + Option.fromUndefinedOr( + [...imports.values()].find((row) => row.importKind === "main"), + ), + ), + prepareSessionImport: (input: { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; + }) => + Effect.sync(() => { + const existing = [...imports.values()].find( + (row) => + row.providerInstanceId === input.providerInstanceId && + row.profileKey === input.profileKey && + row.importKind === input.importKind && + row.storedSessionKey === input.storedSessionKey, + ); + if (existing) return existing; + const row: HermesSessionImport = { + ...input, + state: "prepared", + createdAt: input.now, + updatedAt: input.now, + }; + imports.set(row.importId, row); + return row; + }), + transitionSessionImport: (input: { + readonly importId: string; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => + Effect.sync(() => { + const row = imports.get(input.importId); + if (!row) return false; + imports.set(input.importId, { ...row, state: input.to, updatedAt: input.now }); + return true; + }), + getByStoredIdentity: (identity: { readonly storedSessionKey: string }) => + Effect.succeed( + bindings.has(identity.storedSessionKey) + ? Option.some({ storedSessionKey: identity.storedSessionKey }) + : Option.none(), + ), + createBinding: (input: { readonly storedSessionKey: string }) => + Effect.sync(() => { + if (bindings.has(input.storedSessionKey)) return false; + bindings.add(input.storedSessionKey); + return true; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey, + importEnabled: true, + list: () => + Effect.succeed({ + providerInstanceId, + profileKey, + compatibility: { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "test negotiation", + }, + sessions: [ + { + id: "discord-recent", + title: "Recent Discord", + preview: "", + started_at: recentSeconds, + message_count: 2, + source: "discord", + }, + { + id: "telegram-old", + title: "Old Telegram", + preview: "", + started_at: -73 * 60 * 60, + message_count: 4, + source: "telegram", + }, + { + id: "local-code", + title: "Local code", + preview: "", + started_at: recentSeconds, + message_count: 1, + source: "tui", + }, + ], + }), + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + commands.push(command); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const input = { + providerInstanceId, + backingProjectId: ProjectId.make("internal-work-backing"), + selection: { type: "all" as const }, + activeWithinDays: 7, + }; + + const first = yield* service.importSessions(input); + const replay = yield* service.importSessions(input); + const forgedProjectError = yield* Effect.flip( + service.importSessions({ + ...input, + backingProjectId: ProjectId.make("project:other-environment"), + }), + ); + + expect(first.imported.map((item) => [item.storedSessionId, item.settlement])).toEqual([ + ["discord-recent", "unsettled"], + ["telegram-old", "settled"], + ]); + expect(replay.imported.map((item) => item.status)).toEqual([ + "already_imported", + "already_imported", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toHaveLength(3); + expect(commands.filter((command) => command.type === "thread.settle")).toHaveLength(1); + expect(bindings).toEqual(new Set(["discord-recent", "telegram-old"])); + expect(forgedProjectError.message).toContain("this environment's T3 Work project"); + + const firstThreadIds = first.imported.map((item) => item.threadId); + imports.clear(); + bindings.clear(); + const reimportedAfterReset = yield* service.importSessions(input); + expect(reimportedAfterReset.imported.map((item) => item.threadId)).not.toEqual( + firstThreadIds, + ); + }), + ); + + effectIt.effect("resets only the canonical project, provider, and profile owned shells", () => + Effect.gen(function* () { + const deleted: string[] = []; + let cleared = false; + const repository = { + listHistoryThreadIds: () => + Effect.succeed([ + "thread:owned", + "thread:other-project", + "thread:other-provider", + "thread:non-hermes", + ]), + clearHistoryRecords: (scope: unknown) => + Effect.sync(() => { + expect(scope).toEqual({ + providerInstanceId: "hermes-custom", + profileKey: "profile-a", + projectId: "project:work", + }); + cleared = true; + return 3; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "profile-a", + importEnabled: false, + }, + } as ProviderInstance), + listInstances: Effect.succeed([ + { + instanceId: ProviderInstanceId.make("hermes-custom"), + driverKind: ProviderDriverKind.make("hermes"), + } as never, + ]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + getShellSnapshot: () => + Effect.succeed({ + schemaVersion: 1, + snapshotSequence: 1, + threads: [ + { + id: ThreadId.make("thread:work:1"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + { + id: ThreadId.make("thread:owned"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + }, + { + id: ThreadId.make("thread:other-project"), + projectId: ProjectId.make("project:other"), + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + }, + { + id: ThreadId.make("thread:other-provider"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("hermes-other"), + }, + { + id: ThreadId.make("thread:non-hermes"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + archivedThreads: [ + { + id: ThreadId.make("thread:work:2"), + projectId: ProjectId.make("project:work"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], + } as never), + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + if (command.type === "thread.delete") deleted.push(command.threadId); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService(ProjectService, testProjectService(ProjectId.make("project:work"))), + Effect.provideService(ServerConfig, testServerConfig), + ); + + const result = yield* service.resetHistory({ + providerInstanceId: ProviderInstanceId.make("hermes-custom"), + backingProjectId: ProjectId.make("project:work"), + operationId: "reset:test", + }); + + expect(deleted).toEqual(["thread:owned"]); + expect(cleared).toBe(true); + expect(result).toEqual({ deletedThreadCount: 1, clearedImportCount: 3 }); + }), + ); + + effectIt.effect("creates the main T3 Work thread when no sessions match the age cutoff", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("hermes-work-empty"); + const imports = new Map(); + const commands: OrchestrationV2Command[] = []; + const repository = { + getMainSessionImport: () => + Effect.succeed( + Option.fromUndefinedOr([...imports.values()].find((row) => row.importKind === "main")), + ), + prepareSessionImport: (input: { + readonly importId: string; + readonly providerInstanceId: string; + readonly profileKey: string; + readonly projectId: string; + readonly importKind: "session" | "main"; + readonly storedSessionKey: string | null; + readonly threadId: string; + readonly now: string; + }) => + Effect.sync(() => { + const row: HermesSessionImport = { + ...input, + state: "prepared", + createdAt: input.now, + updatedAt: input.now, + }; + imports.set(row.importId, row); + return row; + }), + transitionSessionImport: (input: { + readonly importId: string; + readonly to: "thread_created" | "completed"; + readonly now: string; + }) => + Effect.sync(() => { + const row = imports.get(input.importId); + if (!row) return false; + imports.set(input.importId, { ...row, state: input.to, updatedAt: input.now }); + return true; + }), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => + Effect.succeed({ + driverKind: ProviderDriverKind.make("hermes"), + hermesSessionCatalog: { + profileKey: "empty-profile", + importEnabled: true, + list: () => + Effect.succeed({ + providerInstanceId, + profileKey: "empty-profile", + compatibility: { + status: "supported", + protocol: { major: 1, minor: 0 }, + capabilities: ["profile.import", "session.lifecycle"], + inventory: ["profile.import", "session.lifecycle"], + reason: "test negotiation", + }, + sessions: [ + { + id: "old-discord", + title: "Too old", + preview: "", + started_at: -2 * 24 * 60 * 60, + message_count: 1, + source: "discord", + }, + ], + }), + }, + } as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: (command: OrchestrationV2Command) => + Effect.sync(() => { + commands.push(command); + return {} as never; + }), + } as unknown as ThreadManagementService["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + const result = yield* service.importSessions({ + providerInstanceId, + backingProjectId: ProjectId.make("internal-work-backing"), + selection: { type: "all" }, + activeWithinDays: 1, + }); + + expect(result.imported).toEqual([]); + expect(result.mainThreadId).not.toBeNull(); + expect(commands.filter((command) => command.type === "thread.create")).toHaveLength(1); + }), + ); + + effectIt.effect( + "hydrates an already-completed imported thread whenever its durable binding is opened", + () => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:hermes:already-imported"); + const hydrated: ThreadId[] = []; + const repository = { + getByThreadId: () => + Effect.succeed( + Option.some({ + bindingId: "existing-binding", + providerInstanceId: "hermes-work", + }), + ), + } as unknown as HermesSessionBindingRepository["Service"]; + const registry = ProviderInstanceRegistry.of({ + getInstance: () => Effect.sync(() => undefined as never), + listInstances: Effect.succeed([]), + listUnavailable: Effect.succeed([]), + streamChanges: Stream.empty, + subscribeChanges: Effect.never, + }); + const threadManagement = { + dispatch: () => Effect.die("unused dispatch"), + } as unknown as ThreadManagementService["Service"]; + const orchestrator = { + hydrateProviderThreadSnapshot: (input: { readonly threadId: ThreadId }) => + Effect.sync(() => { + hydrated.push(input.threadId); + }), + } as unknown as OrchestratorV2["Service"]; + + const service = yield* make.pipe( + Effect.provideService(ProviderInstanceRegistry, registry), + Effect.provideService(HermesSessionBindingRepository, repository), + Effect.provideService(ThreadManagementService, threadManagement), + Effect.provideService(OrchestratorV2, orchestrator), + Effect.provideService( + ProjectService, + testProjectService(ProjectId.make("internal-work-backing")), + ), + Effect.provideService(ServerConfig, testServerConfig), + ); + yield* service.hydrateThread(threadId); + yield* service.hydrateThread(threadId); + + expect(hydrated).toEqual([threadId, threadId]); + }), + ); +}); diff --git a/apps/server/src/hermes/HermesSessionImportService.ts b/apps/server/src/hermes/HermesSessionImportService.ts new file mode 100644 index 00000000000..c0eade24e64 --- /dev/null +++ b/apps/server/src/hermes/HermesSessionImportService.ts @@ -0,0 +1,587 @@ +import { + CommandId, + type HermesGatewayCompatibility, + type HermesDiscoveredSession, + type HermesHistoryResetInput, + type HermesHistoryResetResult, + type HermesSessionDiscoveryInput, + type HermesSessionDiscoveryResult, + type HermesSessionImportCapabilities, + type HermesSessionImportInput, + type HermesSessionImportResult, + HermesSessionsError, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { ServerConfig } from "../config.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import { + HermesSessionBindingRepository, + type HermesSessionImport, +} from "./HermesSessionBindingRepository.ts"; +import type { HermesSessionCatalogSnapshot } from "./HermesSessionCatalog.ts"; + +const HERMES = ProviderDriverKind.make("hermes"); +const HERMES_IMPORT_REQUIRED_CAPABILITIES = ["profile.import", "session.lifecycle"] as const; +const MAX_DISCOVERY_LIMIT = 10_000; +const DAY_MS = 24 * 60 * 60 * 1_000; +export const HERMES_IMPORT_UNSETTLED_WINDOW_MS = 72 * 60 * 60 * 1_000; + +/** + * Built-in messaging transports declared by the pinned Hermes Platform enum. + * `local` is deliberately absent: T3 Work onboarding imports transport + * conversations, not CLI/TUI/ACP/code sessions. Unknown/custom source labels + * remain visible to Hermes itself but are not guessed to be transports here. + */ +export const HERMES_IMPORT_TRANSPORT_SOURCES = [ + "telegram", + "discord", + "whatsapp", + "whatsapp_cloud", + "slack", + "signal", + "mattermost", + "matrix", + "homeassistant", + "email", + "sms", + "dingtalk", + "api_server", + "webhook", + "msgraph_webhook", + "feishu", + "wecom", + "wecom_callback", + "weixin", + "bluebubbles", + "qqbot", + "yuanbao", + "relay", +] as const; + +const HERMES_IMPORT_TRANSPORT_SOURCE_SET = new Set(HERMES_IMPORT_TRANSPORT_SOURCES); + +export function isHermesImportTransportSource(source: string): boolean { + return HERMES_IMPORT_TRANSPORT_SOURCE_SET.has(source.trim().toLowerCase()); +} + +export function classifyHermesImportedSession( + startedAtSeconds: number, + nowMillis: number, +): "unsettled" | "settled" { + return startedAtSeconds * 1_000 >= nowMillis - HERMES_IMPORT_UNSETTLED_WINDOW_MS + ? "unsettled" + : "settled"; +} + +export function isHermesSessionWithinImportAge( + startedAtSeconds: number, + activeWithinDays: number, + nowMillis: number, +): boolean { + return startedAtSeconds * 1_000 >= nowMillis - activeWithinDays * DAY_MS; +} + +export function hermesImportCapabilityError( + compatibility: HermesGatewayCompatibility, +): string | null { + const available = new Set(compatibility.capabilities); + const missing = HERMES_IMPORT_REQUIRED_CAPABILITIES.filter( + (capability) => !available.has(capability), + ); + if ( + compatibility.status === "supported" && + compatibility.inventory !== null && + missing.length === 0 + ) { + return null; + } + return compatibility.status === "legacy" || compatibility.inventory === null + ? "Hermes import requires an evidence-backed negotiated capability inventory." + : `Hermes import is unavailable because the gateway did not advertise: ${missing.join(", ")}.`; +} + +export const HERMES_SESSION_IMPORT_CAPABILITIES: HermesSessionImportCapabilities = { + discovery: true, + lazyHistory: true, + transportSources: [...HERMES_IMPORT_TRANSPORT_SOURCES], + activityTimestamp: { + field: "started_at", + limitation: + "The pinned session.list response omits last_active; classification uses its only timestamp, started_at.", + }, + childSessionLineage: { + available: false, + reason: + "The pinned session.list response does not expose parent_session_id, so imported child lineage cannot be reconstructed safely.", + }, + copyChildSession: { + available: false, + reason: + "The pinned session.branch method copies only the latest live head and has no stable source message/run boundary.", + }, +}; + +const digest = (...parts: ReadonlyArray): string => + NodeCrypto.createHash("sha256").update(JSON.stringify(parts)).digest("hex"); + +const freshImportIdentity = (kind: "main" | "session") => { + const id = NodeCrypto.randomUUID(); + return { + importId: `hermes-import:${kind}:${id}`, + threadId: ThreadId.make(`thread:hermes:${kind}:${id}`), + } as const; +}; + +const isHermesSessionsError = Schema.is(HermesSessionsError); + +function asImportError(cause: unknown): HermesSessionsError { + return isHermesSessionsError(cause) + ? cause + : new HermesSessionsError({ + code: "import_failed", + message: cause instanceof Error ? cause.message : "Hermes session import failed.", + }); +} + +function asHistoryResetError(cause: unknown): HermesSessionsError { + return isHermesSessionsError(cause) + ? cause + : new HermesSessionsError({ + code: "history_reset_failed", + message: cause instanceof Error ? cause.message : "T3 Work history reset failed.", + }); +} + +export interface HermesSessionImportServiceShape { + readonly hydrateThread: (threadId: ThreadId) => Effect.Effect; + readonly discover: ( + input: HermesSessionDiscoveryInput, + ) => Effect.Effect; + readonly importSessions: ( + input: HermesSessionImportInput, + ) => Effect.Effect; + readonly resetHistory: ( + input: HermesHistoryResetInput, + ) => Effect.Effect; +} + +export const make = Effect.gen(function* () { + const instances = yield* ProviderInstanceRegistry; + const repository = yield* HermesSessionBindingRepository; + const threads = yield* ThreadManagementService; + const projects = yield* ProjectService; + const config = yield* ServerConfig; + const orchestrator = yield* Effect.serviceOption(OrchestratorV2); + + const hydrateThread = Effect.fn("HermesSessionImportService.hydrateThread")( + function* (threadId: ThreadId) { + const binding = yield* repository.getByThreadId(String(threadId)); + if (Option.isNone(binding) || Option.isNone(orchestrator)) return; + yield* orchestrator.value.hydrateProviderThreadSnapshot({ + threadId, + providerInstanceId: ProviderInstanceId.make(binding.value.providerInstanceId), + }); + }, + Effect.catchCause((cause) => + Effect.logWarning("Unable to hydrate imported Hermes thread history", { + cause, + }), + ), + ); + + const resolveCatalog = Effect.fn("HermesSessionImportService.resolveCatalog")(function* ( + providerInstanceId: HermesSessionDiscoveryInput["providerInstanceId"], + ) { + const instance = yield* instances.getInstance(providerInstanceId); + if (instance === undefined) { + return yield* new HermesSessionsError({ + code: "provider_not_found", + message: `Provider instance ${providerInstanceId} was not found.`, + }); + } + if (instance.driverKind !== HERMES || instance.hermesSessionCatalog === undefined) { + return yield* new HermesSessionsError({ + code: "provider_not_hermes", + message: `Provider instance ${providerInstanceId} is not a Hermes provider.`, + }); + } + return instance.hermesSessionCatalog; + }); + + const resolveImportCatalog = Effect.fn("HermesSessionImportService.resolveImportCatalog")( + function* (providerInstanceId: HermesSessionDiscoveryInput["providerInstanceId"]) { + const catalog = yield* resolveCatalog(providerInstanceId); + if (catalog.importEnabled) return catalog; + return yield* new HermesSessionsError({ + code: "import_failed", + message: `Hermes profile import is disabled for provider instance ${providerInstanceId}.`, + }); + }, + ); + + const resolveCanonicalProject = Effect.fn("HermesSessionImportService.resolveCanonicalProject")( + function* () { + const workspaceRoot = config.t3WorkDir; + if (workspaceRoot === undefined) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "This environment does not expose a canonical T3 Work directory.", + }); + } + const project = yield* projects.getByWorkspaceRoot(workspaceRoot); + if (Option.isNone(project)) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "The canonical T3 Work backing project is not available in this environment.", + }); + } + return project.value; + }, + ); + + const resolveBackingProject = Effect.fn("HermesSessionImportService.resolveBackingProject")( + function* (callerProjectId: ProjectId) { + const project = yield* resolveCanonicalProject(); + if (project.id !== callerProjectId) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: "The requested backing project is not this environment's T3 Work project.", + }); + } + return project; + }, + ); + + const assertImportCompatibility = Effect.fn( + "HermesSessionImportService.assertImportCompatibility", + )(function* (snapshot: HermesSessionCatalogSnapshot) { + const error = hermesImportCapabilityError(snapshot.compatibility); + if (error !== null) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: error, + }); + } + }); + + const discover = Effect.fn("HermesSessionImportService.discover")(function* ( + input: HermesSessionDiscoveryInput, + ) { + const catalog = yield* resolveImportCatalog(input.providerInstanceId); + const backingProject = yield* resolveCanonicalProject(); + const snapshot = yield* catalog.list( + Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.trunc(input.limit ?? 200))), + ); + yield* assertImportCompatibility(snapshot); + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); + const sessions = yield* Effect.forEach( + snapshot.sessions.filter((session) => isHermesImportTransportSource(session.source)), + Effect.fnUntraced(function* (session) { + const imported = yield* repository.getSessionImportByStoredIdentity({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + storedSessionKey: session.id, + }); + return { + storedSessionId: session.id, + title: session.title, + preview: session.preview, + startedAt: session.started_at, + settlement: classifyHermesImportedSession(session.started_at, nowMillis), + messageCount: session.message_count, + source: session.source, + importedThreadId: Option.isSome(imported) ? ThreadId.make(imported.value.threadId) : null, + } satisfies HermesDiscoveredSession; + }), + { concurrency: 16 }, + ); + const main = yield* repository.getMainSessionImport({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + }); + return { + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + sessions, + capabilities: HERMES_SESSION_IMPORT_CAPABILITIES, + mainThreadId: Option.isSome(main) ? ThreadId.make(main.value.threadId) : null, + } satisfies HermesSessionDiscoveryResult; + }, Effect.mapError(asImportError)); + + const createThreadForImport = Effect.fn("HermesSessionImportService.createThreadForImport")( + function* (input: { + readonly row: HermesSessionImport; + readonly projectId: ProjectId; + readonly title: string; + readonly isMain: boolean; + }) { + if (input.row.state !== "prepared") return; + yield* threads.dispatch({ + type: "thread.create", + createdBy: "system", + creationSource: "provider", + commandId: CommandId.make(`command:${input.row.importId}:create-thread`), + threadId: ThreadId.make(input.row.threadId), + projectId: input.projectId, + title: input.title.trim() || "Untitled Hermes session", + modelSelection: { + instanceId: ProviderInstanceId.make(input.row.providerInstanceId), + model: "default", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + if (input.isMain) { + yield* threads.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`command:${input.row.importId}:mark-main`), + threadId: ThreadId.make(input.row.threadId), + pinned: true, + workInboxRole: "main", + }); + } + const now = DateTime.formatIso(yield* DateTime.now); + yield* repository.transitionSessionImport({ + importId: input.row.importId, + from: "prepared", + to: "thread_created", + now, + }); + }, + ); + + const ensureMain = Effect.fn("HermesSessionImportService.ensureMain")(function* (input: { + readonly providerInstanceId: HermesSessionImportInput["providerInstanceId"]; + readonly profileKey: string; + readonly projectId: ProjectId; + }) { + const identity = freshImportIdentity("main"); + const now = DateTime.formatIso(yield* DateTime.now); + let row = yield* repository.prepareSessionImport({ + importId: identity.importId, + providerInstanceId: String(input.providerInstanceId), + profileKey: input.profileKey, + projectId: String(input.projectId), + importKind: "main", + storedSessionKey: null, + threadId: String(identity.threadId), + now, + }); + yield* createThreadForImport({ + row, + projectId: input.projectId, + title: "Main", + isMain: true, + }); + if (row.state === "prepared") { + row = { + ...row, + state: "thread_created", + updatedAt: DateTime.formatIso(DateTime.nowUnsafe()), + }; + } + if (row.state === "thread_created") { + yield* repository.transitionSessionImport({ + importId: row.importId, + from: "thread_created", + to: "completed", + now: DateTime.formatIso(yield* DateTime.now), + }); + } + return ThreadId.make(row.threadId); + }); + + const importSessions = Effect.fn("HermesSessionImportService.importSessions")(function* ( + input: HermesSessionImportInput, + ) { + const catalog = yield* resolveImportCatalog(input.providerInstanceId); + const backingProject = yield* resolveBackingProject(input.backingProjectId); + const requestedLimit = + input.selection.type === "recent" + ? Math.max(1, Math.min(MAX_DISCOVERY_LIMIT, Math.trunc(input.selection.limit ?? 20))) + : MAX_DISCOVERY_LIMIT; + const snapshot = yield* catalog.list(requestedLimit); + yield* assertImportCompatibility(snapshot); + const nowMillis = DateTime.toEpochMillis(yield* DateTime.now); + const transportSessions = snapshot.sessions.filter( + (session) => + isHermesImportTransportSource(session.source) && + isHermesSessionWithinImportAge(session.started_at, input.activeWithinDays, nowMillis), + ); + const selectedSessionIds = + input.selection.type === "selected" ? input.selection.sessionIds : null; + const selected = + selectedSessionIds !== null + ? transportSessions.filter((session) => selectedSessionIds.includes(session.id)) + : transportSessions; + if (selectedSessionIds !== null && selected.length !== new Set(selectedSessionIds).size) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: + "One or more selected Hermes sessions were not returned by the configured profile.", + }); + } + + const imported = yield* Effect.forEach( + selected, + Effect.fnUntraced(function* (session) { + const settlement = classifyHermesImportedSession(session.started_at, nowMillis); + const identity = freshImportIdentity("session"); + const now = DateTime.formatIso(yield* DateTime.now); + let row = yield* repository.prepareSessionImport({ + importId: identity.importId, + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + importKind: "session", + storedSessionKey: session.id, + threadId: String(identity.threadId), + now, + }); + const threadId = ThreadId.make(row.threadId); + const wasCompleted = row.state === "completed"; + yield* createThreadForImport({ + row, + projectId: backingProject.id, + title: session.title || session.preview || "Hermes session", + isMain: false, + }); + if (row.state === "prepared") { + row = { ...row, state: "thread_created", updatedAt: now }; + } + if (row.state === "thread_created") { + if (settlement === "settled") { + yield* threads.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`command:${row.importId}:classify-settled`), + threadId, + }); + } + const binding = yield* repository.getByStoredIdentity({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + storedSessionKey: session.id, + }); + if (Option.isNone(binding)) { + const created = yield* repository.createBinding({ + bindingId: `hermes-binding:${digest(row.threadId)}`, + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + storedSessionKey: session.id, + threadId: String(threadId), + protocolClassification: snapshot.compatibility.status, + protocolMajor: snapshot.compatibility.protocol?.major ?? null, + protocolMinor: snapshot.compatibility.protocol?.minor ?? null, + capabilities: snapshot.compatibility.capabilities, + reconciliationCursor: null, + reconciliationFingerprint: null, + now: DateTime.formatIso(yield* DateTime.now), + }); + if (!created) { + return yield* new HermesSessionsError({ + code: "import_failed", + message: `Hermes session ${session.id} conflicted with another imported thread.`, + }); + } + } + yield* repository.transitionSessionImport({ + importId: row.importId, + from: "thread_created", + to: "completed", + now: DateTime.formatIso(yield* DateTime.now), + }); + } + return { + storedSessionId: session.id, + threadId, + settlement, + status: wasCompleted ? ("already_imported" as const) : ("imported" as const), + }; + }), + { concurrency: 1 }, + ); + let ensuredMainThreadId: ThreadId | null = null; + if (input.ensureMain === false) { + const existingMain = yield* repository.getMainSessionImport({ + providerInstanceId: String(input.providerInstanceId), + profileKey: snapshot.profileKey, + projectId: String(backingProject.id), + }); + ensuredMainThreadId = Option.isSome(existingMain) + ? ThreadId.make(existingMain.value.threadId) + : null; + } else { + ensuredMainThreadId = yield* ensureMain({ + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + projectId: backingProject.id, + }); + } + return { + providerInstanceId: input.providerInstanceId, + profileKey: snapshot.profileKey, + imported, + mainThreadId: ensuredMainThreadId, + capabilities: HERMES_SESSION_IMPORT_CAPABILITIES, + } satisfies HermesSessionImportResult; + }, Effect.mapError(asImportError)); + + const resetHistory = Effect.fn("HermesSessionImportService.resetHistory")(function* ( + input: HermesHistoryResetInput, + ) { + const catalog = yield* resolveCatalog(input.providerInstanceId); + const backingProject = yield* resolveBackingProject(input.backingProjectId); + const scope = { + providerInstanceId: String(input.providerInstanceId), + profileKey: catalog.profileKey, + projectId: String(backingProject.id), + }; + const snapshot = yield* threads.getShellSnapshot(); + const historyThreadIds = new Set(yield* repository.listHistoryThreadIds(scope)); + const targets = [...snapshot.threads, ...snapshot.archivedThreads].filter( + (thread) => + historyThreadIds.has(String(thread.id)) && + String(thread.projectId) === scope.projectId && + String(thread.providerInstanceId) === scope.providerInstanceId, + ); + const clearedImportCount = yield* repository.clearHistoryRecords(scope); + yield* Effect.forEach( + targets, + (thread) => + threads.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${input.operationId}:delete:${thread.id}`), + threadId: thread.id, + }), + { concurrency: 1, discard: true }, + ); + return { + deletedThreadCount: targets.length, + clearedImportCount, + } satisfies HermesHistoryResetResult; + }, Effect.mapError(asHistoryResetError)); + + return { + hydrateThread, + discover, + importSessions, + resetHistory, + } satisfies HermesSessionImportServiceShape; +}); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index e1cdf992f08..e08bf22a32e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,13 +1,28 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { + EnvironmentId, + PreviewTabId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -16,12 +31,15 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); const tabId = PreviewTabId.make("tab-mcp-test"); const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); +const testWorkspaceRoot = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcp-snapshot-test-")); const invocation = { + credentialId: "credential-mcp-test", environmentId, threadId, providerSessionId: "provider-session-mcp-test", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(["preview"] as const), + audience: "urn:t3-code:mcp:environment-mcp-test", issuedAt: 1, }; const client = McpSchema.McpServerClient.of({ @@ -33,9 +51,44 @@ const client = McpSchema.McpServerClient.of({ }, getClient: Effect.die("unused"), }); +const stubProjectionSnapshotQueryLayer = Layer.succeed( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getShellSnapshotWithoutEnrichment: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: (contextThreadId) => + Effect.succeed( + contextThreadId === threadId + ? Option.some({ + threadId, + projectId: ProjectId.make("project-mcp-test"), + workspaceRoot: testWorkspaceRoot, + worktreePath: null, + checkpoints: [], + }) + : Option.none(), + ), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + }, +); const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.provideMerge(stubProjectionSnapshotQueryLayer), + Layer.provideMerge(WorkspacePaths.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-test-" })), + Layer.provideMerge(NodeServices.layer), ); it("normalizes empty successful notification responses to accepted", () => { @@ -269,3 +322,98 @@ it.effect("registers annotated tools and preserves authenticated request context }), ).pipe(Effect.provide(TestLayer)), ); + +it.effect("saves snapshot screenshots without returning raw base64 metadata", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const events = yield* broker.connect({ + clientId: "mcp-save-client", + environmentId, + }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Effect.void + : broker.respond({ + clientId: "mcp-save-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result: { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png-bytes").toString("base64"), + width: 10, + height: 5, + }, + }, + }), + ).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + const callSnapshot = (arguments_: Record) => + server + .callTool({ name: "preview_snapshot", arguments: arguments_ }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const saved = yield* callSnapshot({ savePath: "evidence/login.png" }); + expect(saved.isError).toBe(false); + expect(saved.structuredContent).toMatchObject({ + savedScreenshotPath: "evidence/login.png", + screenshot: { mimeType: "image/png", width: 10, height: 5 }, + }); + expect( + (saved.structuredContent as { screenshot?: { data?: unknown } }).screenshot?.data, + ).toBeUndefined(); + expect( + NodeFS.readFileSync(NodePath.join(testWorkspaceRoot, "evidence/login.png"), "utf8"), + ).toBe("png-bytes"); + + expect((yield* callSnapshot({ savePath: "../outside.png" })).isError).toBe(true); + const outsideDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcp-outside-")); + NodeFS.symlinkSync(outsideDir, NodePath.join(testWorkspaceRoot, "escape-dir")); + expect((yield* callSnapshot({ savePath: "escape-dir/sub/leak.png" })).isError).toBe(true); + expect(NodeFS.existsSync(NodePath.join(outsideDir, "sub"))).toBe(false); + + const victimPath = NodePath.join(outsideDir, "victim.png"); + NodeFS.writeFileSync(victimPath, "original"); + NodeFS.symlinkSync(victimPath, NodePath.join(testWorkspaceRoot, "evidence", "escape.png")); + expect((yield* callSnapshot({ savePath: "evidence/escape.png" })).isError).toBe(true); + expect(NodeFS.readFileSync(victimPath, "utf8")).toBe("original"); + + const danglingTarget = NodePath.join(outsideDir, "not-created.png"); + NodeFS.symlinkSync( + danglingTarget, + NodePath.join(testWorkspaceRoot, "evidence", "dangling.png"), + ); + expect((yield* callSnapshot({ savePath: "evidence/dangling.png" })).isError).toBe(true); + expect(NodeFS.existsSync(danglingTarget)).toBe(false); + + expect((yield* callSnapshot({ save: true, savePath: "evidence/login.png" })).isError).toBe( + true, + ); + + const artifact = yield* callSnapshot({ save: true }); + expect(artifact.isError).toBe(false); + const artifactPath = (artifact.structuredContent as { savedScreenshotPath?: string }) + .savedScreenshotPath; + const config = yield* ServerConfig.ServerConfig; + expect(artifactPath).toBeDefined(); + expect(NodePath.dirname(artifactPath!)).toBe(config.browserArtifactsDir); + expect(NodeFS.readFileSync(artifactPath!, "utf8")).toBe("png-bytes"); + }), + ).pipe(Effect.provide(TestLayer)), +); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 3a236937232..a0d058604e1 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,8 +1,10 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; @@ -10,6 +12,9 @@ import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; @@ -79,7 +84,7 @@ const makeMcpAuthMiddleware = McpSessionRegistry.McpSessionRegistry.pipe( authorization?.startsWith("Bearer ") === true ? authorization.slice("Bearer ".length).trim() : ""; - const invocation = yield* registry.resolve(token); + const invocation = yield* registry.resolve(token, registry.audience); if (!invocation) return unauthorized; return yield* httpEffect.pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), @@ -128,6 +133,13 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const snapshotHandlerContext = yield* Effect.context< + | ServerConfig.ServerConfig + | ProjectionSnapshotQuery.ProjectionSnapshotQuery + | WorkspacePaths.WorkspacePaths + | FileSystem.FileSystem + | Path.Path + >(); const built = yield* PreviewSnapshotToolkit; const tool = PreviewSnapshotTool; yield* server.addTool({ @@ -159,6 +171,7 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideContext(snapshotHandlerContext), Effect.matchCauseEffect({ onFailure: previewSnapshotFailure, onSuccess: ({ encodedResult }) => { diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 569917325be..5e9f0b13bfb 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -11,11 +11,13 @@ import * as McpInvocationContext from "./McpInvocationContext.ts"; it.effect("reports the scoped credential context when preview capability is unavailable", () => { const invocation: McpInvocationContext.McpInvocationScope = { + credentialId: "credential-1", environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), providerSessionId: "provider-session-1", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(), + audience: "urn:t3-code:mcp:environment-1", issuedAt: 1, }; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 4f28874f199..62b4b31b2a9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -11,11 +11,14 @@ export const ALL_MCP_CAPABILITIES = ["preview", "orchestration", "worktree"] as export type McpCapability = (typeof ALL_MCP_CAPABILITIES)[number]; export interface McpInvocationScope { + /** Opaque, non-secret handle suitable for audit correlation and revocation. */ + readonly credentialId: string; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; readonly capabilities: ReadonlySet; + readonly audience: string; readonly issuedAt: number; } diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index d5dc582046c..5872f52023d 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -1,12 +1,17 @@ import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; export interface McpProviderSessionConfig { + /** Opaque audit/revocation handle. This is not the bearer credential. */ + readonly credentialId?: string; readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly providerSessionId: string; readonly providerInstanceId: ProviderInstanceId; readonly endpoint: string; readonly authorizationHeader: string; + readonly audience?: string; + readonly capabilities?: ReadonlyArray<"preview" | "orchestration" | "worktree">; + readonly issuedAt?: number; } const sessionsByThread = new Map(); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index f8e275461a5..116945c4601 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -36,17 +36,19 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t const issued = yield* registry.issue({ threadId, providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview", "orchestration"]), }); expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); expect(token.length).toBeGreaterThan(20); - const resolved = yield* registry.resolve(token); + const resolved = yield* registry.resolve(token, registry.audience); expect(resolved?.threadId).toBe(threadId); - expect(resolved?.capabilities).toEqual(new Set(["preview", "orchestration", "worktree"])); + expect(resolved?.capabilities).toEqual(new Set(["preview", "orchestration"])); + expect(resolved?.audience).toBe(registry.audience); yield* registry.revokeThread(threadId); - expect(yield* registry.resolve(token)).toBeUndefined(); + expect(yield* registry.resolve(token, registry.audience)).toBeUndefined(); timestamp += 2_000; }), @@ -66,27 +68,64 @@ it.effect("builds MCP endpoints from the bound server host", () => const issued = yield* registry.issue({ threadId: ThreadId.make(`thread-${hostname}`), providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"]), }); expect(issued.config.endpoint).toBe(expectedEndpoint); } }), ); -it.effect("keeps credentials valid until they are explicitly revoked", () => +it.effect("remains valid without wall-clock expiry and emits only audit-safe metadata", () => Effect.gen(function* () { let timestamp = 1_000; - const registry = yield* makeRegistry(() => timestamp); + const audit: Array = []; + const registry = yield* McpSessionRegistry.__testing + .make({ now: () => timestamp, audit: (event) => audit.push(event) }) + .pipe( + Effect.provideService(HttpServer.HttpServer, fakeHttpServer), + Effect.provideService(ServerEnvironment.ServerEnvironment, fakeEnvironment), + Effect.provide(NodeServices.layer), + ); const issued = yield* registry.issue({ threadId: ThreadId.make("thread-2"), providerInstanceId: ProviderInstanceId.make("claude"), + capabilities: new Set(["orchestration"]), }); const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* registry.resolve(token); + const resolved = yield* registry.resolve(token, registry.audience); expect(resolved?.providerSessionId).toBe(issued.config.providerSessionId); + expect(yield* registry.resolve(token, "urn:t3-code:mcp:other")).toBeUndefined(); timestamp += 365 * 24 * 60 * 60 * 1_000; - expect(yield* registry.resolve(token)).toEqual(resolved); + expect(yield* registry.resolve(token, registry.audience)).toEqual(resolved); + expect(audit.map((event) => event.type)).toEqual([ + "issued", + "resolved", + "audience_denied", + "resolved", + ]); + expect(audit.some((event) => Object.values(event).includes(token))).toBe(false); + }), +); + +it.effect("rotates a thread credential atomically and revokes the predecessor", () => + Effect.gen(function* () { + const registry = yield* makeRegistry(() => 1_000); + const request = { + threadId: ThreadId.make("thread-rotate"), + providerInstanceId: ProviderInstanceId.make("codex"), + capabilities: new Set(["preview"] as const), + }; + const first = yield* registry.issue(request); + const second = yield* registry.rotate({ + ...request, + capabilities: new Set(["preview", "orchestration"]), + }); + const firstToken = first.config.authorizationHeader.replace(/^Bearer\s+/, ""); + const secondToken = second.config.authorizationHeader.replace(/^Bearer\s+/, ""); - yield* registry.revokeProviderSession(issued.config.providerSessionId); - expect(yield* registry.resolve(token)).toBeUndefined(); + expect(yield* registry.resolve(firstToken, registry.audience)).toBeUndefined(); + expect((yield* registry.resolve(secondToken, registry.audience))?.capabilities).toEqual( + new Set(["preview", "orchestration"]), + ); }), ); diff --git a/apps/server/src/mcp/McpSessionRegistry.testkit.ts b/apps/server/src/mcp/McpSessionRegistry.testkit.ts index a5dc099310b..278e3ffc30c 100644 --- a/apps/server/src/mcp/McpSessionRegistry.testkit.ts +++ b/apps/server/src/mcp/McpSessionRegistry.testkit.ts @@ -7,6 +7,7 @@ import { McpSessionRegistry } from "./McpSessionRegistry.ts"; export const layer = Layer.succeed( McpSessionRegistry, McpSessionRegistry.of({ + audience: "urn:t3-code:mcp:environment:mcp-test", issue: ({ threadId, providerInstanceId }) => Effect.succeed({ config: { @@ -18,6 +19,17 @@ export const layer = Layer.succeed( authorizationHeader: `Bearer mcp-test:${threadId}`, }, }), + rotate: ({ threadId, providerInstanceId }) => + Effect.succeed({ + config: { + environmentId: EnvironmentId.make("environment:mcp-test"), + threadId, + providerSessionId: `mcp-test:${threadId}`, + providerInstanceId, + endpoint: "http://127.0.0.1/mcp", + authorizationHeader: `Bearer mcp-test:${threadId}`, + }, + }), resolve: () => Effect.succeed(undefined), revokeProviderSession: () => Effect.void, revokeThread: () => Effect.void, diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 069cf608345..09c889df013 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -14,6 +14,7 @@ import * as McpProviderSession from "./McpProviderSession.ts"; export interface McpCredentialRequest { readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly capabilities: ReadonlySet; } export interface McpIssuedCredential { @@ -21,11 +22,15 @@ export interface McpIssuedCredential { } export interface McpSessionRegistryShape { - /** Credentials have no time-based expiry and must be explicitly revoked by their owner. */ + /** Credentials remain valid until their owning thread/session lifecycle revokes them. */ readonly issue: (request: McpCredentialRequest) => Effect.Effect; + /** Atomically replaces every credential owned by the thread. */ + readonly rotate: (request: McpCredentialRequest) => Effect.Effect; readonly resolve: ( rawToken: string, + audience: string, ) => Effect.Effect; + readonly audience: string; readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect; readonly revokeThread: (threadId: ThreadId) => Effect.Effect; readonly revokeAll: Effect.Effect; @@ -44,10 +49,43 @@ interface RegistryState { readonly records: ReadonlyMap; } +type ResolveOutcome = + | { readonly type: "missing" } + | { + readonly type: "resolved" | "audience_denied"; + readonly scope: McpInvocationContext.McpInvocationScope; + }; + export interface McpSessionRegistryOptions { readonly now?: () => number; + readonly audit?: (event: McpCredentialAuditEvent) => void; } +export type McpCredentialAuditEvent = + | { + readonly type: "issued" | "rotated"; + readonly credentialId: string; + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly capabilities: ReadonlyArray; + readonly audience: string; + readonly issuedAt: number; + } + | { + readonly type: "resolved" | "audience_denied"; + readonly credentialId: string; + readonly providerSessionId: string; + readonly audience: string; + readonly occurredAt: number; + } + | { + readonly type: "revoked"; + readonly credentialIds: ReadonlyArray; + readonly reason: "provider_session" | "thread" | "rotation" | "all"; + readonly occurredAt: number; + }; + const bytesToHex = (bytes: Uint8Array): string => Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); @@ -73,6 +111,18 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( const httpServer = yield* HttpServer.HttpServer; const state = yield* SynchronizedRef.make({ records: new Map() }); const currentTimeMillis = options.now ? Effect.sync(options.now) : Clock.currentTimeMillis; + const audit = (event: McpCredentialAuditEvent) => + Effect.sync(() => { + options.audit?.(event); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("mcp.credential-audit-hook-failed", { + eventType: event.type, + cause, + }), + ), + ); + const audience = `urn:t3-code:mcp:${environmentId}`; const endpoint = httpServer.address._tag === "TcpAddress" ? `http://${getHttpMcpEndpointHost(httpServer.address.hostname)}:${httpServer.address.port}/mcp` @@ -83,64 +133,149 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( .digest("SHA-256", new TextEncoder().encode(token)) .pipe(Effect.map(bytesToHex), Effect.orDie); - const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( - function* (request) { - const issuedAt = yield* currentTimeMillis; - const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); - const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); - const tokenHash = yield* hashToken(rawToken); - const scope: McpInvocationContext.McpInvocationScope = { + const mint = Effect.fn("McpSessionRegistry.mint")(function* ( + request: McpCredentialRequest, + rotation: boolean, + ) { + const issuedAt = yield* currentTimeMillis; + const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const credentialId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); + const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); + const tokenHash = yield* hashToken(rawToken); + const scope: McpInvocationContext.McpInvocationScope = { + credentialId, + environmentId, + threadId: ThreadId.make(request.threadId), + providerSessionId, + providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), + capabilities: new Set( + McpInvocationContext.ALL_MCP_CAPABILITIES.filter((capability) => + request.capabilities.has(capability), + ), + ), + audience, + issuedAt, + }; + const revokedCredentialIds = yield* SynchronizedRef.modify(state, ({ records }) => { + const next = new Map(records); + const revoked = rotation + ? Array.from(next) + .filter(([, record]) => record.scope.threadId === request.threadId) + .map(([hash, record]) => { + next.delete(hash); + return record.scope.credentialId; + }) + : []; + next.set(tokenHash, { scope }); + return [revoked, { records: next }] as const; + }); + if (revokedCredentialIds.length > 0) { + yield* audit({ + type: "revoked", + credentialIds: revokedCredentialIds, + reason: "rotation", + occurredAt: issuedAt, + }); + } + const capabilities = [...scope.capabilities].toSorted(); + yield* audit({ + type: rotation ? "rotated" : "issued", + credentialId, + threadId: scope.threadId, + providerInstanceId: scope.providerInstanceId, + providerSessionId, + capabilities, + audience, + issuedAt, + }); + return { + config: { + credentialId, environmentId, - threadId: ThreadId.make(request.threadId), + threadId: scope.threadId, providerSessionId, - providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(McpInvocationContext.ALL_MCP_CAPABILITIES), + providerInstanceId: scope.providerInstanceId, + endpoint, + authorizationHeader: `Bearer ${rawToken}`, + audience, + capabilities, issuedAt, - }; - yield* SynchronizedRef.update(state, ({ records }) => { - const next = new Map(records); - next.set(tokenHash, { scope }); - return { records: next }; - }); - return { - config: { - environmentId, - threadId: scope.threadId, - providerSessionId, - providerInstanceId: scope.providerInstanceId, - endpoint, - authorizationHeader: `Bearer ${rawToken}`, - }, - }; - }, - ); + }, + }; + }); + + const issue: McpSessionRegistryShape["issue"] = (request) => mint(request, false); + const rotate: McpSessionRegistryShape["rotate"] = (request) => mint(request, true); const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( - function* (rawToken) { + function* (rawToken, requestedAudience) { if (rawToken.length === 0) return undefined; const tokenHash = yield* hashToken(rawToken); - const record = (yield* SynchronizedRef.get(state)).records.get(tokenHash); - return record?.scope; + const now = yield* currentTimeMillis; + const outcome = yield* SynchronizedRef.modify( + state, + ({ records }): readonly [ResolveOutcome, RegistryState] => { + const record = records.get(tokenHash); + if (record === undefined) { + return [{ type: "missing" }, { records }]; + } + if (record.scope.audience !== requestedAudience) { + return [{ type: "audience_denied", scope: record.scope }, { records }]; + } + return [{ type: "resolved", scope: record.scope }, { records }]; + }, + ); + if (outcome.type === "missing") return undefined; + yield* audit({ + type: outcome.type, + credentialId: outcome.scope.credentialId, + providerSessionId: outcome.scope.providerSessionId, + audience: requestedAudience, + occurredAt: now, + }); + return outcome.type === "resolved" ? outcome.scope : undefined; }, ); - const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => - SynchronizedRef.update(state, ({ records }) => ({ - records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), - })); + const revokeWhere = ( + predicate: (record: CredentialRecord) => boolean, + reason: Extract["reason"], + ) => + Effect.gen(function* () { + const now = yield* currentTimeMillis; + const revoked = yield* SynchronizedRef.modify(state, ({ records }) => { + const credentialIds: Array = []; + const next = new Map( + Array.from(records).filter(([, record]) => { + if (!predicate(record)) return true; + credentialIds.push(record.scope.credentialId); + return false; + }), + ); + return [credentialIds, { records: next }] as const; + }); + if (revoked.length > 0) { + yield* audit({ type: "revoked", credentialIds: revoked, reason, occurredAt: now }); + } + }); return McpSessionRegistry.of({ + audience, issue, + rotate, resolve, revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( function* (providerSessionId) { - yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); + yield* revokeWhere( + (record) => record.scope.providerSessionId === providerSessionId, + "provider_session", + ); }, ), revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { - yield* revokeWhere((record) => record.scope.threadId === threadId); + yield* revokeWhere((record) => record.scope.threadId === threadId, "thread"); }), - revokeAll: SynchronizedRef.set(state, { records: new Map() }), + revokeAll: revokeWhere(() => true, "all"), }); }); @@ -168,9 +303,7 @@ export const issueActiveMcpCredential = ( request: McpCredentialRequest, ): Effect.Effect => activeMcpSessionRegistry - ? activeMcpSessionRegistry - .revokeThread(request.threadId) - .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) + ? activeMcpSessionRegistry.rotate(request) : Effect.sync((): McpIssuedCredential | undefined => undefined); export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect => diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 78b4d86edb0..c4382d80018 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -570,11 +570,13 @@ describe("orchestrator MCP toolkit", () => { expect(parentRun?.status).toBe("running"); const invocation: McpInvocationContext.McpInvocationScope = { + credentialId: "credential-orchestrator-test", environmentId: EnvironmentId.make("environment:mcp-orchestrator"), threadId: parentThreadId, providerSessionId: "mcp-provider-session-parent", providerInstanceId: codexInstanceId, capabilities: new Set(["orchestration"]), + audience: "urn:t3-code:mcp:environment-orchestrator", issuedAt: 1, }; const invoke = (name: string, args: Record) => diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 3bc0fd71308..0bb71a0f554 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -25,11 +25,13 @@ import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); const scope = { + credentialId: "credential-preview-test", environmentId: EnvironmentId.make("environment-1"), threadId: ThreadId.make("thread-1"), providerSessionId: "provider-session-1", providerInstanceId: ProviderInstanceId.make("codex"), capabilities: new Set(["preview"] as const), + audience: "urn:t3-code:mcp:environment-1", issuedAt: 1, }; diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts index 1917d9f507a..ace42677fa4 100644 --- a/apps/server/src/mcp/WorktreeMcpService.test.ts +++ b/apps/server/src/mcp/WorktreeMcpService.test.ts @@ -45,11 +45,13 @@ const workspaceRoot = "/repo/project"; const makeScope = ( capabilities: ReadonlySet, ): McpInvocationContext.McpInvocationScope => ({ + credentialId: "credential-worktree-test", environmentId, threadId, providerSessionId: "provider-session-worktree-test", providerInstanceId: ProviderInstanceId.make("claudeAgent"), capabilities, + audience: "urn:t3-code:mcp:environment-worktree-test", issuedAt: 1, }); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 1c7ff6f9cd9..4e29e44ada1 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -1,4 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import type { PreviewAutomationOperation, PreviewAutomationOpenInput, @@ -10,7 +17,11 @@ import type { PreviewAutomationStatus, PreviewTabId, } from "@t3tools/contracts"; +import { PreviewAutomationScreenshotSaveError } from "@t3tools/contracts"; +import * as ServerConfig from "../../../config.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; import { PreviewSnapshotToolkit, PreviewStandardToolkit, PreviewToolkit } from "./tools.ts"; @@ -60,6 +71,162 @@ const invokeTargeted = ( return invoke(operation, operationInput, timeoutMs, tabId); }; +const writeScreenshotFile = (input: { + readonly absolutePath: string; + readonly screenshotBase64: string; +}) => + Effect.scoped( + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const targetDirectory = path.dirname(input.absolutePath); + yield* fileSystem.makeDirectory(targetDirectory, { recursive: true }); + const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.absolutePath)}.`, + }); + const tempPath = path.join(tempDirectory, "screenshot.tmp"); + yield* fileSystem.writeFile( + tempPath, + new Uint8Array(Buffer.from(input.screenshotBase64, "base64")), + ); + // Rename replaces an existing symlink itself instead of following it, + // closing the check/write race around workspace save destinations. + yield* fileSystem.rename(tempPath, input.absolutePath); + }), + ); + +const saveSnapshotScreenshotArtifact = Effect.fn("PreviewToolkit.saveSnapshotScreenshotArtifact")( + function* (input: { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly screenshotBase64: string; + }) { + const config = yield* ServerConfig.ServerConfig; + const fileName = `browser-screenshot-${(yield* Clock.currentTimeMillis).toString(36)}-${NodeCrypto.randomUUID().slice(0, 8)}.png`; + const path = yield* Path.Path; + const absolutePath = path.join(config.browserArtifactsDir, fileName); + yield* writeScreenshotFile({ absolutePath, screenshotBase64: input.screenshotBase64 }).pipe( + Effect.mapError( + (cause) => + new PreviewAutomationScreenshotSaveError({ + operation: "snapshot", + environmentId: input.scope.environmentId, + threadId: input.scope.threadId, + providerSessionId: input.scope.providerSessionId, + providerInstanceId: input.scope.providerInstanceId, + savePath: fileName, + reason: "failed to write the screenshot artifact", + cause, + }), + ), + ); + return absolutePath; + }, +); + +const saveSnapshotScreenshot = Effect.fn("PreviewToolkit.saveSnapshotScreenshot")( + function* (input: { + readonly scope: McpInvocationContext.McpInvocationScope; + readonly savePath: string; + readonly screenshotBase64: string; + }) { + const { savePath, scope } = input; + const fail = (reason: string, cause?: unknown) => + new PreviewAutomationScreenshotSaveError({ + operation: "snapshot", + environmentId: scope.environmentId, + threadId: scope.threadId, + providerSessionId: scope.providerSessionId, + providerInstanceId: scope.providerInstanceId, + savePath, + reason, + ...(cause === undefined ? {} : { cause }), + }); + if (!savePath.toLowerCase().endsWith(".png")) { + return yield* fail("savePath must end with .png"); + } + + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const threadContext = yield* projectionSnapshotQuery + .getThreadCheckpointContext(scope.threadId) + .pipe(Effect.mapError((cause) => fail("failed to resolve the thread workspace", cause))); + if (Option.isNone(threadContext)) { + return yield* fail("thread was not found"); + } + const workspaceRoot = threadContext.value.worktreePath ?? threadContext.value.workspaceRoot; + + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const resolved = yield* workspacePaths + .resolveRelativePathWithinRoot({ workspaceRoot, relativePath: savePath }) + .pipe( + Effect.mapError((cause) => + fail("savePath must be a relative path inside the workspace", cause), + ), + ); + + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const realPathOrNull = (target: string) => + fileSystem.realPath(target).pipe( + Effect.map((canonical): string | null => canonical), + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail(fail("failed to resolve the save path", error)), + }), + ); + const isOutsideRoot = (canonicalRoot: string, canonical: string) => { + const relative = path.relative(canonicalRoot, canonical); + return relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); + }; + + const canonicalRoot = yield* fileSystem + .realPath(workspaceRoot) + .pipe(Effect.mapError((cause) => fail("failed to resolve the workspace root", cause))); + + const lexicalParent = path.dirname(resolved.absolutePath); + let existingAncestor = lexicalParent; + let canonicalAncestor = yield* realPathOrNull(existingAncestor); + while (canonicalAncestor === null) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) { + return yield* fail("failed to resolve the save directory"); + } + existingAncestor = parent; + canonicalAncestor = yield* realPathOrNull(existingAncestor); + } + if (isOutsideRoot(canonicalRoot, canonicalAncestor)) { + return yield* fail("savePath must stay inside the workspace root"); + } + + yield* fileSystem + .makeDirectory(lexicalParent, { recursive: true }) + .pipe(Effect.mapError((cause) => fail("failed to create the save directory", cause))); + const canonicalParent = yield* fileSystem + .realPath(lexicalParent) + .pipe(Effect.mapError((cause) => fail("failed to resolve the save directory", cause))); + if (isOutsideRoot(canonicalRoot, canonicalParent)) { + return yield* fail("savePath must stay inside the workspace root"); + } + + const destination = path.join(canonicalParent, path.basename(resolved.absolutePath)); + const destinationIsSymlink = yield* fileSystem.readLink(destination).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (destinationIsSymlink) { + return yield* fail("savePath must not be an existing symlink"); + } + + yield* writeScreenshotFile({ + absolutePath: destination, + screenshotBase64: input.screenshotBase64, + }).pipe(Effect.mapError((cause) => fail("failed to write the screenshot file", cause))); + return resolved.relativePath; + }, +); + const handlers = { preview_status: (input) => invokeTargeted("status", input ?? {}), preview_open: (input) => @@ -70,7 +237,29 @@ const handlers = { invokeTargeted("resize", input, input.timeoutMs), preview_set_appearance: (input) => invokeTargeted("setColorScheme", input), - preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), + preview_snapshot: (input) => + Effect.gen(function* () { + const { save, savePath, ...target } = input ?? {}; + const snapshot = yield* invokeTargeted("snapshot", target); + if (savePath !== undefined) { + const scope = yield* McpInvocationContext.McpInvocationContext; + const savedScreenshotPath = yield* saveSnapshotScreenshot({ + scope, + savePath, + screenshotBase64: snapshot.screenshot.data, + }); + return { ...snapshot, savedScreenshotPath }; + } + if (save === true) { + const scope = yield* McpInvocationContext.McpInvocationContext; + const savedScreenshotPath = yield* saveSnapshotScreenshotArtifact({ + scope, + screenshotBase64: snapshot.screenshot.data, + }); + return { ...snapshot, savedScreenshotPath }; + } + return snapshot; + }), preview_click: (input) => invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), preview_type: (input) => diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index a94d2b056f7..d8ca5cab4f0 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -13,14 +13,20 @@ import { PreviewAutomationSetColorSchemeInput, PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, + PreviewAutomationSnapshotInput, PreviewAutomationStatus, PreviewAutomationTabTargetInput, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "@t3tools/contracts"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/unstable/ai"; +import * as ServerConfig from "../../../config.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -29,6 +35,15 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; +const snapshotDependencies = [ + ...dependencies, + ServerConfig.ServerConfig, + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + WorkspacePaths.WorkspacePaths, + FileSystem.FileSystem, + Path.Path, +]; + const browserTool = (tool: T): T => tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; @@ -104,11 +119,11 @@ export const PreviewSetAppearanceTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", - parameters: PreviewAutomationTabTargetInput, + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. To keep visual evidence for the human, pass save:true; the result then includes savedScreenshotPath, which can be embedded with markdown image syntax. Use savePath only when the screenshot should live inside the repo.", + parameters: PreviewAutomationSnapshotInput, success: PreviewAutomationSnapshot, failure: PreviewAutomationError, - dependencies, + dependencies: snapshotDependencies, }).annotate(Tool.Title, "Inspect browser page"), ); @@ -192,7 +207,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( export const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: - "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact.", + "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and save it as a local evidence artifact (.webm). To show it to the human, embed the returned path with markdown image syntax; chat renders supported recordings as playable video. If the desktop app and server run on different machines, copy the file into the workspace and embed its workspace-relative path instead.", parameters: PreviewAutomationTabTargetInput, success: PreviewAutomationRecordingArtifact, failure: PreviewAutomationError, diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 773f4edc323..744f1f637e7 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -7,15 +7,18 @@ import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import { HttpBody, HttpClient, HttpRouter } from "effect/unstable/http"; +import * as ServerConfig from "../../../config.ts"; import * as ServerEnvironment from "../../../environment/ServerEnvironment.ts"; import * as GitWorkflowService from "../../../git/GitWorkflowService.ts"; import { ThreadManagementService } from "../../../orchestration-v2/ThreadManagementService.ts"; +import * as ProjectionSnapshotQuery from "../../../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../../../project/ProjectSetupScriptRunner.ts"; import { ProviderRegistry } from "../../../provider/Services/ProviderRegistry.ts"; import { ScheduledTaskService } from "../../../scheduledTasks/ScheduledTaskService.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../../vcs/VcsStatusBroadcaster.ts"; +import * as WorkspacePaths from "../../../workspace/WorkspacePaths.ts"; import * as McpHttpServer from "../../McpHttpServer.ts"; import * as McpSessionRegistry from "../../McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; @@ -29,6 +32,9 @@ const StubServicesLive = Layer.mergeAll( Layer.mock(GitWorkflowService.GitWorkflowService)({}), Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({}), Layer.mock(VcsStatusBroadcaster)({}), + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({}), + WorkspacePaths.layer, + ServerConfig.layerTest(process.cwd(), { prefix: "mcp-registration-test-" }), ); it.effect("production mcp layer lists worktree tools over http", () => @@ -52,6 +58,7 @@ it.effect("production mcp layer lists worktree tools over http", () => const registry = McpSessionRegistry.issueActiveMcpCredential({ threadId: ThreadId.make("thread-scratch"), providerInstanceId: ProviderInstanceId.make("claudeAgent"), + capabilities: new Set(["worktree"]), }); const credential = yield* registry; expect(credential).toBeDefined(); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index aa3f8bb3364..5fe86e971fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -474,7 +474,14 @@ function negotiatedCapabilities( const hasModelConfig = setup.configOptions?.some((option) => option.category === "model") === true; const hasModeConfig = setup.configOptions?.some((option) => option.category === "mode") === true; - const supportsMcp = agent.mcpCapabilities?.http === true || agent.mcpCapabilities?.sse === true; + // Some provider flavors implement ACP session MCP registration before they + // advertise the optional initialize capability. A flavor may explicitly + // opt in through its base capabilities; all other ACP providers remain + // gated by the negotiated inventory. + const supportsMcp = + base.tools.supportsMcpTools || + agent.mcpCapabilities?.http === true || + agent.mcpCapabilities?.sse === true; const canLoad = agent.loadSession === true; const canFork = session?.fork != null; return { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts index 0b55234cb3a..7e9c6e72a67 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.testkit.ts @@ -73,6 +73,7 @@ export function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); @@ -82,6 +83,7 @@ export function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -121,6 +123,7 @@ export function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 2632dd97d97..03ea0bd6888 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -523,6 +523,7 @@ function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); for (const directory of [ @@ -531,6 +532,7 @@ function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -569,6 +571,7 @@ function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts new file mode 100644 index 00000000000..3a593237e5b --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.test.ts @@ -0,0 +1,109 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; +import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import { + HERMES_ACP_DRIVER_KIND, + HermesAcpAdapterV2Driver, + HermesAcpProviderCapabilitiesV2, + makeHermesAcpAdapterV2, +} from "./HermesAcpAdapterV2.ts"; + +const decodeSettings = Schema.decodeUnknownEffect(HermesAcpAdapterV2Driver.configSchema); +const testLayer = Layer.mergeAll( + NodeServices.layer, + idAllocatorLayer, + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-hermes-acp-adapter-", + }).pipe(Layer.provide(NodeServices.layer)), +); + +const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; + +describe("HermesAcpAdapterV2", () => { + it("registers a session family distinct from the Hermes Work gateway", () => { + assert.isTrue(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(HERMES_ACP_DRIVER_KIND)); + assert.equal(HermesAcpAdapterV2Driver.driverKind, "hermesAcp"); + assert.isTrue(HermesAcpProviderCapabilitiesV2.tools.supportsMcpTools); + assert.deepEqual(HermesAcpAdapterV2Driver.defaultConfig(), { + enabled: true, + binaryPath: "hermes", + customModels: [], + }); + }); + + it.effect("opens a standard ACP child process and negotiates Hermes capabilities", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-hermes-acp-command-", + }); + const hermesPath = path.join(directory, "hermes"); + yield* fileSystem.writeFileString( + hermesPath, + [ + "#!/bin/sh", + 'if [ "$1" != "acp" ]; then exit 9; fi', + `exec ${shellQuote(process.execPath)} ${shellQuote(mockAgentPath)}`, + "", + ].join("\n"), + ); + yield* fileSystem.chmod(hermesPath, 0o755); + const instanceId = ProviderInstanceId.make("hermes-code-test"); + const settings = yield* decodeSettings({ + binaryPath: hermesPath, + }); + const adapter = makeHermesAcpAdapterV2({ + instanceId, + settings, + environment: { + T3_ACP_SESSION_LIFECYCLE: "1", + }, + childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, + crypto: yield* Crypto.Crypto, + fileSystem, + idAllocator: yield* IdAllocatorV2, + serverConfig: yield* ServerConfig, + }); + const threadId = ThreadId.make("thread-hermes-acp"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-hermes-acp"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + assert.equal(runtime.providerSession.driver, "hermesAcp"); + assert.equal(providerThread.nativeThreadRef?.nativeId, "mock-session-1"); + assert.isTrue(runtime.providerSession.capabilities.threads.canReadThreadSnapshot); + assert.isTrue(runtime.providerSession.capabilities.threads.canForkThread); + assert.isTrue(runtime.providerSession.capabilities.tools.supportsMcpTools); + }).pipe(Effect.provide(testLayer), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts new file mode 100644 index 00000000000..0c81173f147 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesAcpAdapterV2.ts @@ -0,0 +1,155 @@ +import { + defaultInstanceIdForDriver, + HermesAcpSettings, + type OrchestrationV2ProviderCapabilities, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { ServerConfig } from "../../config.ts"; +import { makeHermesAcpRuntime } from "../../provider/acp/HermesAcpSupport.ts"; +import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2RuntimeInput, +} from "./AcpAdapterV2.ts"; + +export const HERMES_ACP_PROVIDER = ProviderDriverKind.make("hermesAcp"); +export const HERMES_ACP_DRIVER_KIND = HERMES_ACP_PROVIDER; +export const HERMES_ACP_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(HERMES_ACP_DRIVER_KIND); + +const DEFAULT_HERMES_ACP_SETTINGS = Schema.decodeSync(HermesAcpSettings)({}); + +/** + * Hermes 0.19 accepts HTTP MCP servers (including headers) in session/new, + * session/load, session/resume, and session/fork, but its initialize response + * omits ACP mcpCapabilities. Keep this compatibility assertion isolated to + * Hermes instead of weakening negotiation for every ACP provider. + */ +export const HermesAcpProviderCapabilitiesV2 = { + ...AcpProviderCapabilitiesV2, + tools: { + ...AcpProviderCapabilitiesV2.tools, + supportsMcpTools: true, + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface HermesAcpAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: HermesAcpSettings; + readonly environment: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +export function makeHermesAcpAdapterV2(options: HermesAcpAdapterV2Options) { + return makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: { + driver: HERMES_ACP_PROVIDER, + capabilities: HermesAcpProviderCapabilitiesV2, + supportsImagePrompts: true, + makeRuntime: + options.makeRuntime ?? + ((input) => + makeHermesAcpRuntime({ + ...input, + hermesSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })), + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }, + crypto: options.crypto, + fileSystem: options.fileSystem, + idAllocator: options.idAllocator, + serverConfig: options.serverConfig, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +} + +export type HermesAcpAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const HermesAcpAdapterV2Driver: ProviderAdapterDriver< + HermesAcpSettings, + HermesAcpAdapterV2DriverEnv +> = { + driverKind: HERMES_ACP_DRIVER_KIND, + configSchema: HermesAcpSettings, + defaultConfig: (): HermesAcpSettings => DEFAULT_HERMES_ACP_SETTINGS, + create: Effect.fn("HermesAcpAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return makeHermesAcpAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + childProcessSpawner, + crypto, + fileSystem, + idAllocator, + serverConfig, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: HERMES_ACP_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: HERMES_ACP_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Hermes in Code ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts new file mode 100644 index 00000000000..7de892c54db --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.test.ts @@ -0,0 +1,1958 @@ +import { assert, describe, it } from "@effect/vitest"; +import { + ChatAttachmentId, + EnvironmentId, + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + ProviderThreadId, + RunAttemptId, + RunId, + ThreadId, + type HermesGatewayCompatibility, + type HermesGatewayInterruptResult, + type HermesGatewayMutationStatusResult, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult, + type HermesGatewaySessionHistoryResult, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult, + type HermesGatewaySessionStatusResult, + type OrchestrationV2AppThread, + type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { + HermesGatewayMutationIndeterminateError, + HermesGatewayRpcError, + type HermesGatewayMutationOptions, + type HermesGatewayOrderedEvent, +} from "../../hermes/HermesGatewayClient.ts"; +import { + HermesSessionBindingRepository, + layer as HermesSessionBindingRepositoryLayer, +} from "../../hermes/HermesSessionBindingRepository.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { IdAllocatorV2, layer as IdAllocatorV2Layer } from "../IdAllocator.ts"; +import type { ProviderAdapterV2TurnInput } from "../ProviderAdapter.ts"; +import { + diagnoseHermesMcpIntegration, + HermesProviderCapabilitiesV2, + hermesWireMutationId, + makeHermesServeAdapterV2, + type HermesGatewayClientLike, +} from "./HermesServeAdapterV2.ts"; + +it("does not advertise Git checkpointing for DM-like Hermes sessions", () => { + assert.isFalse(HermesProviderCapabilitiesV2.checkpointing.appCanCheckpointFilesystem); + assert.isFalse(HermesProviderCapabilitiesV2.checkpointing.supportsNestedCheckpointScopes); +}); + +const compatibility: HermesGatewayCompatibility = { + status: "supported", + protocol: { + major: 1, + minor: 0, + capabilities: ["mutation.stable_ids"], + }, + capabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "attachments.image", + "attachments.file", + "attachments.pdf", + "mutation.stable_ids", + ], + inventory: ["mutation.stable_ids"], + reason: "test", +}; + +class FakeHermesGatewayClient implements HermesGatewayClientLike { + compatibility: HermesGatewayCompatibility | undefined = compatibility; + readonly creates: Array<{ + readonly params: HermesGatewaySessionCreateParams; + readonly options: Omit; + }> = []; + readonly resumes: Array<{ + readonly params: HermesGatewaySessionResumeParams & { readonly model?: string }; + readonly options: Omit; + }> = []; + readonly prompts: Array<{ + readonly params: HermesGatewayPromptSubmitParams; + readonly options: Omit; + }> = []; + readonly mcpReplacements: Array = []; + readonly mcpRevocations: Array = []; + mcpReplacementResult: { + readonly scopeSessionId?: string; + readonly serverName?: string; + readonly toolNames?: Array; + } = {}; + readonly imageAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }; + readonly options: Omit; + }> = []; + readonly fileAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }; + readonly options: Omit; + }> = []; + readonly pdfAttachments: Array<{ + readonly params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }; + readonly options: Omit; + }> = []; + readonly interrupts: Array> = []; + readonly listeners = new Set<(event: HermesGatewayOrderedEvent) => void | Promise>(); + history: HermesGatewaySessionHistoryResult = { count: 0, messages: [] }; + statusOutput = "idle"; + resumeRunning = false; + resumeInflight: unknown = undefined; + resumeQueued: unknown = undefined; + resumeStatus = "idle"; + mutationStatus: HermesGatewayMutationStatusResult = { mutation_status: "completed" }; + readonly reconciliations: Array = []; + readonly reconciliationMutationIds: Array = []; + createError: Error | null = null; + titleError: Error | null = null; + promptError: Error | null = null; + promptResult: HermesGatewayPromptSubmitResult | null = null; + closeCount = 0; + connectError: Error | null = null; + private transportSequence = 0; + + async connect(): Promise { + if (this.connectError) throw this.connectError; + return this.compatibility ?? compatibility; + } + + hasCapability(capability: string): boolean { + return (this.compatibility ?? compatibility).capabilities.includes(capability); + } + + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + async createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise { + this.creates.push({ params, options }); + if (this.createError) throw this.createError; + const createOrdinal = this.creates.length; + return { + session_id: `live-create-${createOrdinal}`, + stored_session_id: `stored-session-${createOrdinal}`, + message_count: 0, + messages: [], + info: {}, + }; + } + + async resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise { + this.resumes.push({ params, options }); + return { + session_id: "live-resume-1", + resumed: params.session_id, + message_count: this.history.count, + messages: this.history.messages, + info: {}, + ...(this.resumeInflight === undefined ? {} : { inflight: this.resumeInflight }), + ...(this.resumeQueued === undefined ? {} : { queued: this.resumeQueued }), + running: this.resumeRunning, + session_key: params.session_id, + started_at: 1, + status: this.resumeStatus, + }; + } + + async replaceSessionMcp(params: HermesGatewaySessionMcpParams) { + this.mcpReplacements.push(params); + return { + lease_id: `lease-${this.mcpReplacements.length}`, + generation: this.mcpReplacements.length, + servers: [ + { + name: this.mcpReplacementResult.serverName ?? "t3-code", + runtime_name: "tui_session_fixture_t3_code", + }, + ], + tool_names: this.mcpReplacementResult.toolNames ?? [ + "mcp__tui_session_fixture_t3_code__delegate_task", + ], + scope: { + session_id: this.mcpReplacementResult.scopeSessionId ?? params.session_id, + session_key: "stored-session-1", + }, + persisted: false as const, + history_recorded: false as const, + }; + } + + async revokeSessionMcp(sessionId: string) { + this.mcpRevocations.push(sessionId); + return { + revoked: true, + lease_id: "lease-1", + persisted: false as const, + }; + } + + async readSessionStatus(): Promise { + return { output: this.statusOutput }; + } + + async readSessionHistory(): Promise { + return this.history; + } + + async readSessionTitle() { + return { + session_key: "stored-session-1", + title: "Hermes session", + revision: 1, + origin: "gateway", + }; + } + + async updateSessionTitle(params: { readonly title: string }) { + if (this.titleError) throw this.titleError; + return { + session_key: "stored-session-1", + title: params.title, + revision: 2, + origin: "t3", + }; + } + + async branchSession(params: { readonly session_id: string }) { + return { + session_id: "live-branch-1", + stored_session_id: "stored-branch-1", + title: "Hermes branch", + parent: params.session_id, + boundary: { + mode: "latest_only" as const, + exact: false as const, + message_id: "message-head", + message_count: this.history.count, + }, + }; + } + + async reconcileMutation( + operationId: string, + mutationId: string = operationId, + ): Promise { + this.reconciliations.push(operationId); + this.reconciliationMutationIds.push(mutationId); + return this.mutationStatus; + } + + async submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise { + this.prompts.push({ params, options }); + if (this.promptError) throw this.promptError; + if (this.promptResult) return this.promptResult; + return { + status: "streaming", + run_id: "hermes-run-1", + user_message_id: "hermes-user-1", + assistant_message_id: "hermes-assistant-1", + mutation_id: options.mutationId, + }; + } + + async attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise { + this.imageAttachments.push({ params, options }); + return { attached: true }; + } + + async respondToApproval( + _params: { readonly session_id: string; readonly choice: "once" | "session" | "deny" }, + _options: Omit, + ) { + return { resolved: true }; + } + + async respondToClarification( + _params: { readonly request_id: string; readonly answer: string }, + _options: Omit, + ) { + return { status: "ok" as const }; + } + + async attachFile( + params: { readonly session_id: string; readonly name: string; readonly data_url: string }, + options: Omit, + ): Promise { + this.fileAttachments.push({ params, options }); + return { attached: true }; + } + + async attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise { + this.pdfAttachments.push({ params, options }); + return { attached: true }; + } + + async interruptSession( + _params: { readonly session_id: string }, + options: Omit, + ): Promise { + this.interrupts.push(options); + queueMicrotask(() => void this.emit("message.complete", { text: "partial" })); + return { status: "interrupted" }; + } + + close(): void { + this.closeCount += 1; + } + + async emit( + type: string, + payload?: unknown, + overrides: Partial = {}, + ): Promise { + const event: HermesGatewayOrderedEvent = { + transportSequence: ++this.transportSequence, + sessionSequence: this.transportSequence, + sessionId: "live-create-1", + eventId: `event-${this.transportSequence}`, + eventSequence: this.transportSequence, + emittedAt: undefined, + sessionKey: "stored-session-1", + runId: "hermes-run-1", + messageId: "hermes-assistant-1", + cursor: this.transportSequence, + mutationId: undefined, + frame: { + jsonrpc: "2.0", + method: "event", + params: { + type, + session_id: "live-create-1", + payload, + event_id: `event-${this.transportSequence}`, + event_sequence: this.transportSequence, + session_key: "stored-session-1", + run_id: "hermes-run-1", + message_id: "hermes-assistant-1", + }, + }, + ...overrides, + }; + for (const listener of this.listeners) { + await listener(event); + } + } +} + +const TestLayer = Layer.mergeAll( + IdAllocatorV2Layer, + HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +const instanceId = ProviderInstanceId.make("hermes_test"); +const threadId = ThreadId.make("thread:hermes-test"); +const projectId = ProjectId.make("project:hermes-test"); +const providerSessionId = ProviderSessionId.make("provider-session:hermes-test"); +const modelSelection = { instanceId, model: "default" } as const; +const runtimePolicy = { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/hermes-project", +} as const; + +function appThread(): OrchestrationV2AppThread { + const now = DateTime.nowUnsafe(); + return { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId, + title: "Hermes test", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { + parentThreadId: null, + relationshipToParent: null, + rootThreadId: threadId, + }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + }; +} + +function turnInput(providerThread: OrchestrationV2ProviderThread): ProviderAdapterV2TurnInput { + return { + appThread: appThread(), + threadId, + runId: RunId.make("run:hermes-test"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt:hermes-test"), + rootNodeId: NodeId.make("node:hermes-root"), + providerThread, + message: { + messageId: MessageId.make("message:hermes-user"), + text: "hello Hermes", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection, + runtimePolicy, + }; +} + +const makeRuntime = Effect.fnUntraced(function* ( + fake: FakeHermesGatewayClient, + enabled = true, + readAttachment?: (attachment: { readonly id: string }) => Effect.Effect, + mcpEnabled = false, + resolveHistoryMedia?: Parameters[0]["resolveHistoryMedia"], +) { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + const adapter = makeHermesServeAdapterV2({ + instanceId, + settings: { + enabled: true, + endpoint: "ws://127.0.0.1:9119/api/ws", + remoteAccessEnabled: false, + profileKey: "real-profile", + managedServerEnabled: true, + customModels: [], + importEnabled: false, + mcpEnabled, + attachmentsEnabled: readAttachment !== undefined, + proactiveEnabled: false, + voiceEnabled: false, + }, + enabled, + authToken: "test-token", + idAllocator, + repository, + ...(readAttachment === undefined ? {} : { readAttachment }), + ...(resolveHistoryMedia === undefined ? {} : { resolveHistoryMedia }), + clientFactory: () => fake, + }); + return yield* adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); +}); + +describe("HermesServeAdapterV2", () => { + it("requires the upstream ephemeral session MCP lease capability", () => { + assert.deepEqual(diagnoseHermesMcpIntegration(compatibility), { + status: "blocked_upstream", + missingCapabilities: ["session_mcp"], + reason: + "Hermes MCP exposure is blocked: the negotiated gateway protocol does not advertise ephemeral per-session MCP leases.", + }); + + assert.deepEqual( + diagnoseHermesMcpIntegration({ + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }), + { + status: "ready", + missingCapabilities: [], + reason: + "Hermes advertises ephemeral per-session MCP leases with replace and revoke support.", + }, + ); + }); + + it.effect("creates a durable binding and omits the default model override", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const repository = yield* HermesSessionBindingRepository; + const binding = yield* repository.getByThreadId(String(threadId)); + + assert.isTrue(Option.isSome(binding)); + assert.equal( + Option.getOrNull(Option.map(binding, (value) => value.storedSessionKey)), + "stored-session-1", + ); + assert.equal(providerThread.nativeThreadRef?.strength, "strong"); + assert.notProperty(fake.creates[0]!.params, "model"); + assert.isTrue(fake.creates[0]!.params.persist_immediately); + assert.equal( + fake.creates[0]!.options.mutationId, + hermesWireMutationId(fake.creates[0]!.options.operationId), + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("replaces the live Hermes session MCP lease with the scoped T3 endpoint", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.compatibility = { + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }; + McpProviderSession.setMcpProviderSession({ + credentialId: "credential-hermes-test", + environmentId: EnvironmentId.make("environment-hermes-test"), + threadId, + providerSessionId: "mcp-provider-session-hermes-test", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer scoped-hermes-token", + capabilities: ["orchestration"], + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const runtime = yield* makeRuntime(fake, true, undefined, true); + yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + assert.isTrue(runtime.providerSession.capabilities.tools.supportsMcpTools); + assert.deepEqual(fake.mcpReplacements, [ + { + session_id: "live-create-1", + servers: { + "t3-code": { + url: "http://127.0.0.1:43123/mcp", + headers: { Authorization: "Bearer scoped-hermes-token" }, + }, + }, + }, + ]); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("rejects an MCP lease that is not scoped to the live Hermes session", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.compatibility = { + ...compatibility, + capabilities: [...compatibility.capabilities, "session_mcp"], + }; + fake.mcpReplacementResult = { scopeSessionId: "different-live-session" }; + McpProviderSession.setMcpProviderSession({ + credentialId: "credential-hermes-wrong-scope", + environmentId: EnvironmentId.make("environment-hermes-test"), + threadId, + providerSessionId: "mcp-provider-session-hermes-wrong-scope", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer scoped-hermes-token", + capabilities: ["orchestration"], + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)), + ); + const runtime = yield* makeRuntime(fake, true, undefined, true); + + const exit = yield* Effect.exit( + runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }), + ); + + assert.equal(exit._tag, "Failure"); + assert.equal(fake.mcpReplacements.length, 1); + }).pipe(Effect.provide(TestLayer)), + ), + ); + + it.effect("creates a fresh Hermes context for every distinct T3 thread", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const secondThreadId = ThreadId.make("thread:hermes-test:2"); + const first = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const second = yield* runtime.ensureThread({ + threadId: secondThreadId, + modelSelection, + runtimePolicy, + }); + const repository = yield* HermesSessionBindingRepository; + const firstBinding = yield* repository.getByThreadId(String(threadId)); + const secondBinding = yield* repository.getByThreadId(String(secondThreadId)); + + assert.equal(fake.creates.length, 2); + assert.notEqual(first.id, second.id); + assert.equal( + Option.getOrNull(Option.map(firstBinding, (binding) => binding.storedSessionKey)), + "stored-session-1", + ); + assert.equal( + Option.getOrNull(Option.map(secondBinding, (binding) => binding.storedSessionKey)), + "stored-session-2", + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resumes by durable stored key and hydrates history", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const resumeFake = new FakeHermesGatewayClient(); + const mediaPath = "/Users/maria/Downloads/history-render.png"; + resumeFake.history = { + count: 1, + messages: [ + { + message_id: "history-1", + role: "assistant", + text: `from history\nMEDIA:${mediaPath}`, + }, + ], + }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(resumeFake, true, undefined, false, ({ sourcePath }) => + Effect.succeed( + sourcePath === mediaPath + ? { + type: "image" as const, + id: ChatAttachmentId.make("hermes-history-media"), + name: "history-render.png", + mimeType: "image/png", + sizeBytes: 128, + } + : null, + ), + ); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const snapshot = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + + assert.equal(resumeFake.resumes[0]!.params.session_id, "stored-session-1"); + assert.notProperty(resumeFake.resumes[0]!.params, "model"); + assert.deepEqual( + snapshot.messages.map((message) => message.text), + ["from history"], + ); + assert.deepEqual(snapshot.messages[0]?.attachments, [ + { + type: "image", + id: "hermes-history-media", + name: "history-render.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ]); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("normalizes imported transport history with stable ordering and attachments", () => + Effect.gen(function* () { + const createFake = new FakeHermesGatewayClient(); + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(createFake); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const repository = yield* HermesSessionBindingRepository; + const now = "2026-07-26T12:00:00.000Z"; + const imported = yield* repository.prepareSessionImport({ + importId: "hermes-import:test-history", + providerInstanceId: String(instanceId), + profileKey: "real-profile", + projectId: String(threadId), + importKind: "session", + storedSessionKey: "stored-session-1", + threadId: String(threadId), + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "prepared", + to: "thread_created", + now, + }); + yield* repository.transitionSessionImport({ + importId: imported.importId, + from: "thread_created", + to: "completed", + now, + }); + + const fake = new FakeHermesGatewayClient(); + fake.history = { + count: 2, + messages: [ + { + message_id: "history-user", + role: "user", + text: [ + "[maria] rewrite better", + "", + "[Image attached at: /Users/maria/.hermes/cache/images/img_fixture.webp]", + "[screenshot]", + ].join("\n"), + }, + { message_id: "history-assistant", role: "assistant", text: "Rewritten." }, + ], + }; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(fake, true, undefined, false, () => + Effect.succeed({ + type: "image", + id: "thread-hermes-test-00000000-0000-0000-0000-000000000001", + name: "img_fixture.webp", + mimeType: "image/webp", + sizeBytes: 12, + }), + ); + const resumed = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + const first = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + const replay = yield* runtime.readThreadSnapshot({ providerThread: resumed }); + + assert.deepEqual( + first.messages.map((message) => [message.role, message.text]), + [ + ["user", "rewrite better"], + ["assistant", "Rewritten."], + ], + ); + assert.deepEqual(first.messages[0]?.attachments, [ + { + type: "image", + id: "thread-hermes-test-00000000-0000-0000-0000-000000000001", + name: "img_fixture.webp", + mimeType: "image/webp", + sizeBytes: 12, + }, + ]); + assert.isBelow( + DateTime.toEpochMillis(first.messages[0]!.createdAt), + DateTime.toEpochMillis(first.messages[1]!.createdAt), + ); + assert.deepEqual( + replay.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + attachments: message.attachments, + createdAt: DateTime.formatIso(message.createdAt), + })), + first.messages.map((message) => ({ + id: message.id, + role: message.role, + text: message.text, + attachments: message.attachments, + createdAt: DateTime.formatIso(message.createdAt), + })), + ); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("uploads image attachments to Hermes before submitting the prompt", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const readAttachmentIds: string[] = []; + const runtime = yield* makeRuntime(fake, true, (attachment) => { + readAttachmentIds.push(attachment.id); + return Effect.succeed(Uint8Array.from([0x89, 0x50, 0x4e, 0x47])); + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn({ + ...input, + message: { + ...input.message, + attachments: [ + { + type: "image", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000000", + name: "image.png", + mimeType: "image/png", + sizeBytes: 4, + }, + ], + }, + }); + + assert.deepEqual(readAttachmentIds, [ + "thread-hermes-test-00000000-0000-4000-8000-000000000000", + ]); + assert.deepEqual(fake.imageAttachments, [ + { + params: { + session_id: "live-create-1", + content_base64: "iVBORw==", + filename: "image.png", + }, + options: { + operationId: "hermes:image:attempt:hermes-test:0", + }, + }, + ]); + assert.equal(fake.prompts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("maps files, PDFs, and videos to their protocol-supported Hermes methods", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake, true, () => + Effect.succeed(Uint8Array.from([0x61, 0x62, 0x63])), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn({ + ...input, + message: { + ...input.message, + attachments: [ + { + type: "file", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000001", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 3, + }, + { + type: "pdf", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000002", + name: "report.pdf", + mimeType: "application/pdf", + sizeBytes: 3, + }, + { + type: "video", + id: "thread-hermes-test-00000000-0000-4000-8000-000000000003", + name: "clip.webm", + mimeType: "video/webm", + sizeBytes: 3, + }, + ], + }, + }); + + assert.deepEqual(fake.fileAttachments, [ + { + params: { + session_id: "live-create-1", + name: "notes.txt", + data_url: "data:text/plain;base64,YWJj", + }, + options: { operationId: "hermes:file:attempt:hermes-test:0" }, + }, + { + params: { + session_id: "live-create-1", + name: "clip.webm", + data_url: "data:video/webm;base64,YWJj", + }, + options: { operationId: "hermes:file:attempt:hermes-test:2" }, + }, + ]); + assert.deepEqual(fake.pdfAttachments, [ + { + params: { + session_id: "live-create-1", + filename: "report.pdf", + content_base64: "YWJj", + }, + options: { operationId: "hermes:pdf:attempt:hermes-test:1" }, + }, + ]); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "keeps prompt admitted through streaming, confirms on terminal, and ignores duplicates/unknown events", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const repository = yield* HermesSessionBindingRepository; + const operationId = `hermes:prompt:${input.attemptId}`; + const admitted = yield* repository.getMutationIntent(operationId); + assert.equal( + Option.getOrNull(Option.map(admitted, (intent) => intent.state)), + "admitted", + ); + + yield* Effect.promise(() => fake.emit("message.start", { text: "" })); + yield* Effect.promise(() => fake.emit("message.delta", { text: "hello " })); + yield* Effect.promise(() => fake.emit("future.event", { ignored: true })); + yield* Effect.promise(() => fake.emit("message.delta", { text: "world" })); + yield* Effect.promise(() => fake.emit("message.complete", { text: "hello world" })); + yield* Effect.promise(() => fake.emit("message.complete", { text: "duplicate" })); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminalCount = [...projected].filter( + (event) => event.type === "turn.terminal", + ).length; + const messages = [...projected].filter((event) => event.type === "message.updated"); + const confirmed = yield* repository.getMutationIntent(operationId); + + assert.equal(terminalCount, 1); + assert.equal( + messages.at(-1)?.type === "message.updated" ? messages.at(-1)!.message.text : "", + "hello world", + ); + assert.equal( + Option.getOrNull(Option.map(confirmed, (intent) => intent.state)), + "confirmed", + ); + assert.equal(fake.prompts[0]!.options.mutationId, hermesWireMutationId(operationId)); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("turns completed assistant MEDIA output into attachments without exposing paths", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const mediaPath = "/Users/maria/Downloads/rendered.jpg"; + const runtime = yield* makeRuntime(fake, true, undefined, false, ({ sourcePath }) => + Effect.succeed( + sourcePath === mediaPath + ? { + type: "image" as const, + id: ChatAttachmentId.make("hermes-media-output"), + name: "rendered.jpg", + mimeType: "image/jpeg", + sizeBytes: 128, + } + : null, + ), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.start", { + text: `Here is the result.\nMEDIA:${mediaPath}`, + }), + ); + yield* Effect.promise(() => + fake.emit("message.complete", { + text: `Here is the result.\nMEDIA:${mediaPath}`, + }), + ); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const messages = [...projected].filter((event) => event.type === "message.updated"); + const finalMessage = messages.at(-1); + assert.equal( + finalMessage?.type === "message.updated" ? finalMessage.message.text : "", + "Here is the result.", + ); + assert.deepEqual( + finalMessage?.type === "message.updated" ? finalMessage.message.attachments : [], + [ + { + type: "image", + id: "hermes-media-output", + name: "rendered.jpg", + mimeType: "image/jpeg", + sizeBytes: 128, + }, + ], + ); + assert.isFalse( + messages.some( + (event) => event.type === "message.updated" && event.message.text.includes(mediaPath), + ), + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "projects every Hermes tool lifecycle with stable identities, ordering, and sanitized data", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + + yield* Effect.promise(() => fake.emit("tool.generating", { name: "terminal" })); + yield* Effect.promise(() => fake.emit("tool.generating", { name: "terminal" })); + yield* Effect.promise(() => + fake.emit("tool.progress", { name: "terminal", preview: "preparing first command" }), + ); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "tool-1", + name: "terminal", + context: "first command", + args_text: '{ "command": "echo one" }', + }), + ); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "tool-2", + name: "terminal", + context: "second command", + args_text: '{ "command": "echo two" }', + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "tool-2", + name: "terminal", + args: { command: "echo two", api_key: "secret-two" }, + result: { output: "two", exit_code: 0 }, + duration_s: 0.2, + summary: "Ran second command", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "tool-1", + name: "terminal", + args: { + command: "curl https://example.test/?token=secret-one", + password: "secret-password", + }, + result: { + output: "Authorization: Bearer secret-result", + exit_code: 0, + }, + result_text: "command completed", + duration_s: 0.1, + summary: "Ran first command", + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const projected = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const toolItems = [...projected].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ); + const toolNodes = [...projected].flatMap((event) => + event.type === "node.updated" && event.node.kind === "tool_call" ? [event.node] : [], + ); + const firstCompleted = toolItems.find( + (item) => item.nativeItemRef?.nativeId === "tool-1" && item.status === "completed", + ); + const secondCompleted = toolItems.find( + (item) => item.nativeItemRef?.nativeId === "tool-2" && item.status === "completed", + ); + const first = toolItems.filter((item) => item.id === firstCompleted?.id); + const second = toolItems.filter((item) => item.id === secondCompleted?.id); + const assistant = [...projected].find( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "assistant_message", + ); + + assert.deepEqual( + first.map((item) => item.status), + ["pending", "running", "running", "completed"], + ); + assert.deepEqual( + second.map((item) => item.status), + ["pending", "running", "completed"], + ); + assert.equal(new Set(first.map((item) => item.id)).size, 1); + assert.equal(new Set(second.map((item) => item.id)).size, 1); + assert.deepEqual( + toolNodes + .filter((node) => node.id === firstCompleted?.nodeId) + .map((node) => node.status), + ["pending", "running", "running", "completed"], + ); + assert.equal( + new Set( + toolNodes + .filter((node) => node.id === secondCompleted?.nodeId) + .map((node) => node.id), + ).size, + 1, + ); + assert.equal(firstCompleted?.ordinal, 101); + assert.equal(secondCompleted?.ordinal, 102); + assert.equal( + assistant?.type === "turn_item.updated" ? assistant.turnItem.ordinal : null, + 103, + ); + assert.deepEqual(firstCompleted?.input, { + command: "curl https://example.test/?token=[REDACTED]", + password: "[REDACTED]", + }); + assert.deepEqual(firstCompleted?.output, { + output: "Authorization: Bearer [REDACTED]", + exit_code: 0, + }); + assert.deepEqual(secondCompleted?.input, { + command: "echo two", + api_key: "[REDACTED]", + }); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("projects start-less completions and failed tool results", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "failed-tool", + name: "terminal", + arguments: { command: "false" }, + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "failed-tool", + name: "terminal", + result: "Error executing tool 'terminal': permission denied", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "completion-only", + name: "web_search", + args: { query: "Hermes" }, + result: { results: ["one"] }, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const items = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ), + ), + ); + const byNativeId = (nativeId: string): OrchestrationV2TurnItem | undefined => + items.findLast((item) => item.nativeItemRef?.nativeId === nativeId); + const completionOnly = byNativeId("completion-only"); + + assert.equal(byNativeId("failed-tool")?.status, "failed"); + assert.equal(completionOnly?.status, "completed"); + assert.deepEqual( + completionOnly?.type === "dynamic_tool" ? completionOnly.output : undefined, + { results: ["one"] }, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("ignores stale and duplicate events while enriching tool risk output", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "stale-tool", name: "terminal", args: { command: "stale" } }, + { runId: "prior-hermes-run" }, + ), + ); + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "risk-tool", name: "web_extract", args: { url: "https://example.test" } }, + { eventId: "dedupe-tool-start" }, + ), + ); + yield* Effect.promise(() => + fake.emit( + "tool.start", + { tool_id: "risk-tool", name: "web_extract", args: { url: "https://example.test" } }, + { eventId: "dedupe-tool-start" }, + ), + ); + yield* Effect.promise(() => + fake.emit("tool.complete", { + tool_id: "risk-tool", + name: "web_extract", + result: "untrusted page contents", + }), + ); + yield* Effect.promise(() => + fake.emit("tool.output_risk", { + tool_id: "risk-tool", + name: "web_extract", + risk: "high", + findings: ["prompt injection"], + redacted: true, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const items = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events].flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ), + ), + ); + const staleItems = items.filter((item) => item.nativeItemRef?.nativeId === "stale-tool"); + const riskItems = items.filter((item) => item.nativeItemRef?.nativeId === "risk-tool"); + const runningRiskItems = riskItems.filter((item) => item.status === "running"); + const finalRiskItem = riskItems.at(-1); + + assert.equal(staleItems.length, 0); + assert.equal(runningRiskItems.length, 1); + assert.deepEqual( + finalRiskItem?.type === "dynamic_tool" ? finalRiskItem.output : undefined, + { + result: "[REDACTED BY HERMES]", + outputRisk: { + risk: "high", + findings: ["prompt injection"], + redacted: true, + }, + }, + ); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("closes a tool without a completion event conservatively", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("tool.start", { + tool_id: "incomplete-tool", + name: "terminal", + args: { command: "sleep 1" }, + }), + ); + yield* Effect.promise(() => fake.emit("message.complete", { text: "done" })); + + const finalToolItem = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.map((events) => + [...events] + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ) + .at(-1), + ), + ); + + assert.equal(finalToolItem?.status, "cancelled"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect( + "routes repeated turns through the projected thread id while retaining native identity", + () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const projectedThread = { + ...providerThread, + id: ProviderThreadId.make("provider-thread:hermes-placeholder"), + }; + + yield* runtime.startTurn(turnInput(projectedThread)); + assert.equal(fake.prompts.length, 1); + + yield* Effect.promise(() => fake.emit("message.complete", { text: "routed" })); + const firstEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const firstProviderTurn = [...firstEvents].find( + (event) => event.type === "provider_turn.updated", + ); + assert.equal( + firstProviderTurn?.type === "provider_turn.updated" + ? firstProviderTurn.providerTurn.providerThreadId + : null, + projectedThread.id, + ); + + fake.promptResult = { + status: "streaming", + run_id: "hermes-run-2", + user_message_id: "hermes-user-2", + assistant_message_id: "hermes-assistant-2", + mutation_id: "mutation-2", + }; + yield* runtime.startTurn({ + ...turnInput(projectedThread), + runId: RunId.make("run:hermes-test:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-test:2"), + }); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "again" }, { runId: "hermes-run-2" }), + ); + const secondEvents = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const secondProviderTurn = [...secondEvents].find( + (event) => event.type === "provider_turn.updated", + ); + assert.equal( + secondProviderTurn?.type === "provider_turn.updated" + ? secondProviderTurn.providerTurn.providerThreadId + : null, + projectedThread.id, + ); + assert.equal( + secondProviderTurn?.type === "provider_turn.updated" + ? secondProviderTurn.providerTurn.ordinal + : null, + 2, + ); + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("continues the default model after Hermes reports its resolved concrete model", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => fake.emit("session.info", { model: "openai/gpt-5.6-sol" })); + assert.equal(runtime.providerSession.model, "openai/gpt-5.6-sol"); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "first response", status: "complete" }), + ); + + fake.promptResult = { + status: "streaming", + run_id: "hermes-run-2", + user_message_id: "hermes-user-2", + assistant_message_id: "hermes-assistant-2", + mutation_id: "mutation-2", + }; + yield* runtime.startTurn({ + ...turnInput(providerThread), + runId: RunId.make("run:hermes-test:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-test:2"), + }); + + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("continues the first prompt when Hermes rejects a duplicate session title", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.titleError = new HermesGatewayRpcError(4022, "session.title", "fatal"); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + + yield* runtime.startTurn(turnInput(providerThread)); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "first response", status: "complete" }), + ); + yield* runtime.startTurn({ + ...turnInput(providerThread), + runId: RunId.make("run:hermes-title-conflict:2"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("attempt:hermes-title-conflict:2"), + }); + + assert.equal(fake.prompts.length, 2); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("reacquires an expired same-generation lease before terminal settlement", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE hermes_session_bindings + SET lease_expires_at = '2000-01-01T00:00:00.000Z' + WHERE thread_id = ${String(threadId)} + `; + yield* Effect.promise(() => fake.emit("message.complete", { text: "late result" })); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.isTrue(Option.isSome(terminal)); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "confirmed"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("waits for the terminal event when interrupting", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const providerTurn = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "provider_turn.updated"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(providerTurn)); + if (Option.isNone(providerTurn) || providerTurn.value.type !== "provider_turn.updated") + return; + + yield* runtime.interruptTurn({ + providerThread, + providerTurnId: providerTurn.value.providerTurn.id, + }); + assert.equal(fake.interrupts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("does not resubmit an indeterminate prompt mutation", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + fake.mutationStatus = { + mutation_id: "hermes:prompt:attempt:hermes-test", + mutation_status: "indeterminate", + run_id: "hermes-run-1", + replayed: true, + }; + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result(runtime.startTurn(input)); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.equal(fake.prompts.length, 1); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "indeterminate"); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("recovers a completed prompt replay without waiting for message events", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + + fake.promptError = null; + fake.mutationStatus = { mutation_status: "completed" }; + fake.promptResult = { + status: "complete", + mutation_id: `hermes:prompt:${input.attemptId}`, + mutation_status: "completed", + run_id: "hermes-run-1", + message_id: "hermes-assistant-1", + replayed: true, + }; + yield* runtime.startTurn(input); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const repository = yield* HermesSessionBindingRepository; + const intent = yield* repository.getMutationIntent(`hermes:prompt:${input.attemptId}`); + + assert.isTrue(Option.isSome(terminal)); + assert.equal(Option.getOrNull(Option.map(intent, (value) => value.state)), "reconciled"); + assert.equal(fake.prompts.length, 2); + assert.deepEqual(fake.reconciliations, [`hermes:prompt:${input.attemptId}`]); + assert.deepEqual(fake.reconciliationMutationIds, [ + hermesWireMutationId(`hermes:prompt:${input.attemptId}`), + ]); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("blocks an indeterminate replay and any different prompt behind it", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.promptError = new HermesGatewayMutationIndeterminateError( + "hermes:prompt:attempt:hermes-test", + "prompt.submit", + ); + fake.mutationStatus = { + mutation_id: "hermes:prompt:attempt:hermes-test", + mutation_status: "indeterminate", + run_id: "hermes-run-1", + replayed: true, + }; + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result(runtime.startTurn(input)); + yield* Effect.result( + runtime.startTurn({ + ...input, + attemptId: RunAttemptId.make("attempt:hermes-other"), + }), + ); + + assert.equal(fake.prompts.length, 1); + assert.deepEqual(fake.reconciliations, [`hermes:prompt:${input.attemptId}`]); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("recovers an indeterminate session create through completed stable replay", () => + Effect.gen(function* () { + const first = new FakeHermesGatewayClient(); + first.createError = new HermesGatewayMutationIndeterminateError( + `hermes:create:${instanceId}:${threadId}`, + "session.create", + ); + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(first); + yield* Effect.result(runtime.ensureThread({ threadId, modelSelection, runtimePolicy })); + }), + ); + + const recovered = new FakeHermesGatewayClient(); + recovered.mutationStatus = { mutation_status: "completed" }; + const providerThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(recovered); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + + assert.equal(providerThread.status, "idle"); + assert.equal(recovered.creates.length, 1); + assert.deepEqual(recovered.reconciliations, [`hermes:create:${instanceId}:${threadId}`]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("fences a recovered external run until an authoritative terminal event", () => + Effect.gen(function* () { + const createdThread = yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(new FakeHermesGatewayClient()); + return yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + }), + ); + const recovered = new FakeHermesGatewayClient(); + recovered.resumeRunning = true; + recovered.resumeInflight = { run_id: "external-run" }; + recovered.resumeStatus = "running"; + recovered.statusOutput = "Hermes TUI Status\n\nAgent Running: Yes"; + yield* Effect.scoped( + Effect.gen(function* () { + const runtime = yield* makeRuntime(recovered); + const providerThread = yield* runtime.resumeThread({ + providerThread: createdThread, + threadId, + modelSelection, + runtimePolicy, + }); + + assert.equal(providerThread.status, "active"); + assert.equal(runtime.providerSession.status, "running"); + yield* Effect.result(runtime.startTurn(turnInput(providerThread))); + assert.equal(recovered.prompts.length, 0); + + yield* Effect.promise(() => recovered.emit("message.complete", { status: "complete" })); + assert.equal(runtime.providerSession.status, "ready"); + recovered.statusOutput = "Hermes TUI Status\n\nAgent Running: No"; + yield* runtime.startTurn(turnInput(providerThread)); + assert.equal(recovered.prompts.length, 1); + }), + ); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("projects interrupted and error message.complete statuses", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const firstInput = turnInput(providerThread); + yield* runtime.startTurn(firstInput); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "interrupted", status: "interrupted" }), + ); + const interrupted = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(interrupted)); + if (Option.isSome(interrupted) && interrupted.value.type === "turn.terminal") { + assert.equal(interrupted.value.status, "interrupted"); + } + + yield* runtime.startTurn({ + ...firstInput, + attemptId: RunAttemptId.make("attempt:hermes-error"), + providerTurnOrdinal: 2, + }); + yield* Effect.promise(() => + fake.emit("message.complete", { text: "failed", status: "error" }), + ); + const failed = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(failed)); + if (Option.isSome(failed) && failed.value.type === "turn.terminal") { + assert.equal(failed.value.status, "failed"); + assert.isNotNull(failed.value.failure); + assert.equal(failed.value.threadDisposition, "broken"); + } + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("publishes a broken failure and poisons the stale owner after generation loss", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const runtime = yield* makeRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const input = turnInput(providerThread); + yield* runtime.startTurn(input); + const sql = yield* SqlClient.SqlClient; + yield* sql` + UPDATE hermes_session_bindings + SET + lease_owner_key = 'competing-owner', + lease_generation = lease_generation + 1, + lease_expires_at = '2999-01-01T00:00:00.000Z' + WHERE thread_id = ${String(threadId)} + `; + yield* Effect.promise(() => + fake.emit("message.complete", { text: "stale success", status: "complete" }), + ); + const terminal = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + yield* Effect.result( + runtime.startTurn({ + ...input, + attemptId: RunAttemptId.make("attempt:after-generation-loss"), + }), + ); + + assert.isTrue(Option.isSome(terminal)); + if (Option.isSome(terminal) && terminal.value.type === "turn.terminal") { + assert.equal(terminal.value.status, "failed"); + assert.equal(terminal.value.threadDisposition, "broken"); + } + assert.equal(fake.prompts.length, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects opening a disabled instance without connecting", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + const exit = yield* Effect.exit(makeRuntime(fake, false)); + assert.equal(exit._tag, "Failure"); + assert.equal(fake.creates.length, 0); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("closes the Hermes client when provider open cannot connect", () => + Effect.scoped( + Effect.gen(function* () { + const fake = new FakeHermesGatewayClient(); + fake.connectError = new Error("gateway unavailable"); + + const exit = yield* Effect.exit(makeRuntime(fake)); + + assert.equal(exit._tag, "Failure"); + assert.equal(fake.closeCount, 1); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects a fully configured remote gateway before constructing a client", () => + Effect.scoped( + Effect.gen(function* () { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + let clientCreations = 0; + const adapter = makeHermesServeAdapterV2({ + instanceId, + settings: { + enabled: true, + endpoint: "wss://gateway.example.com/api/ws", + remoteAccessEnabled: true, + profileKey: "remote-profile", + managedServerEnabled: false, + customModels: [], + importEnabled: false, + mcpEnabled: false, + attachmentsEnabled: false, + proactiveEnabled: false, + voiceEnabled: false, + }, + enabled: true, + authToken: "local-token", + remotePairingToken: "dedicated-pairing-token", + remoteTlsCertificateSha256: "ab".repeat(32), + idAllocator, + repository, + clientFactory: () => { + clientCreations += 1; + return new FakeHermesGatewayClient(); + }, + }); + + const exit = yield* Effect.exit( + adapter.openSession({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }), + ); + assert.equal(exit._tag, "Failure"); + assert.equal(clientCreations, 0); + }), + ).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts new file mode 100644 index 00000000000..f831eb2b67e --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/HermesServeAdapterV2.ts @@ -0,0 +1,3411 @@ +import { + type ChatAttachment, + type HermesGatewayCompatibility, + type HermesGatewayApprovalRespondResult, + type HermesGatewayClarificationRespondResult, + type HermesGatewayHistoryMessage, + type HermesGatewayInterruptResult, + type HermesGatewayMutationStatusResult, + type HermesGatewayPromptSubmitParams, + type HermesGatewayPromptSubmitResult, + type HermesGatewaySessionCreateParams, + type HermesGatewaySessionCreateResult, + type HermesGatewaySessionBranchParams, + type HermesGatewaySessionBranchResult, + type HermesGatewaySessionHistoryResult, + type HermesGatewaySessionMcpLeaseResult, + type HermesGatewaySessionMcpParams, + type HermesGatewaySessionMcpRevokeResult, + type HermesGatewaySessionResumeParams, + type HermesGatewaySessionResumeResult, + type HermesGatewaySessionStatusResult, + type HermesGatewaySessionTitleParams, + type HermesGatewaySessionTitleResult, + HermesGatewayTitleChangedEventPayload, + type HermesGatewayToolEventPayload, + HermesSettings, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderRef, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2TurnItem, + type OrchestrationV2RuntimeRequest, + type ProviderApprovalDecision, + type ProviderUserInputAnswers, + ProviderDriverKind, + type ProviderInstanceEnvironment, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as NodeCrypto from "node:crypto"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + assessHermesConnectionSecurity, + HERMES_REMOTE_PAIRING_TOKEN_ENV, + HERMES_REMOTE_TLS_CERT_SHA256_ENV, +} from "../../hermes/HermesConnectionSecurity.ts"; +import { + HermesGatewayClient, + HermesGatewayMutationIndeterminateError, + type HermesGatewayMutationOptions, + type HermesGatewayOrderedEvent, +} from "../../hermes/HermesGatewayClient.ts"; +import { + hermesHistoryMediaRoots, + normalizeHermesHistoryMessage, + parseHermesHistoryText, + persistHermesHistoryMedia, + type HermesHistoryMediaKind, +} from "../../hermes/HermesHistoryNormalization.ts"; +import { + resolveHermesServeEndpoint, + type HermesServeRuntimeShape, +} from "../../hermes/HermesServeRuntime.ts"; +import { + HermesSessionBindingRepository, + type HermesMutationIntent, + type HermesMutationIntentState, + type HermesOwnerLease, + type HermesSessionBinding, + type HermesSessionBindingRepositoryShape, +} from "../../hermes/HermesSessionBindingRepository.ts"; +import { IdAllocatorV2, type IdAllocatorV2Shape } from "../IdAllocator.ts"; +import { makeProviderFailure } from "../ProviderFailure.ts"; +import { + ProviderAdapterEnsureThreadError, + ProviderAdapterForkThreadError, + ProviderAdapterInterruptError, + ProviderAdapterOpenSessionError, + ProviderAdapterProtocolError, + ProviderAdapterReadThreadSnapshotError, + ProviderAdapterResumeThreadError, + ProviderAdapterRollbackThreadError, + ProviderAdapterRuntimeRequestResponseError, + ProviderAdapterSteerRunUnsupportedError, + ProviderAdapterTurnStartError, + type ProviderAdapterV2EnsureThreadInput, + type ProviderAdapterV2Event, + type ProviderAdapterV2OpenSessionInput, + type ProviderAdapterV2SessionRuntime, + type ProviderAdapterV2Shape, + type ProviderAdapterV2TurnInput, +} from "../ProviderAdapter.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; + +export const HERMES_PROVIDER = ProviderDriverKind.make("hermes"); +export const HERMES_DRIVER_KIND = HERMES_PROVIDER; + +const DEFAULT_HERMES_SETTINGS = Schema.decodeSync(HermesSettings)({}); + +const LEASE_MINUTES = 30; +const INTERRUPT_TERMINAL_TIMEOUT = "15 seconds"; + +/** + * Hermes may receive T3's provider-session credential only when the negotiated + * protocol advertises its ephemeral live-session MCP lease contract. This + * capability is intentionally absent from the legacy fallback inventory. + */ +export const HERMES_SESSION_MCP_REQUIRED_CAPABILITIES = ["session_mcp"] as const; + +export interface HermesMcpIntegrationDiagnostic { + readonly status: "ready" | "blocked_upstream"; + readonly missingCapabilities: ReadonlyArray; + readonly reason: string; +} + +export function diagnoseHermesMcpIntegration( + compatibility: HermesGatewayCompatibility, +): HermesMcpIntegrationDiagnostic { + const missingCapabilities = HERMES_SESSION_MCP_REQUIRED_CAPABILITIES.filter( + (capability) => !compatibility.capabilities.includes(capability), + ); + if (compatibility.status !== "supported" || missingCapabilities.length > 0) { + return { + status: "blocked_upstream", + missingCapabilities, + reason: + "Hermes MCP exposure is blocked: the negotiated gateway protocol does not advertise ephemeral per-session MCP leases.", + }; + } + return { + status: "ready", + missingCapabilities: [], + reason: "Hermes advertises ephemeral per-session MCP leases with replace and revoke support.", + }; +} + +export const HermesProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: false, + supportsProviderSwitchingViaHandoff: false, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: false, + canForkThread: true, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: false, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: false, + supportsQueuedMessages: false, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: false, + streamsPlanText: false, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: true, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: true, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: false, + emitsTodoList: false, + emitsProposedPlan: false, + supportsStructuredQuestions: true, + planDeltasHaveItemIds: false, + }, + subagents: { + supportsSubagents: false, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: false, + canWaitForSubagents: false, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: false, + canGenerateSummaries: false, + canConsumeHandoffSummaries: false, + supportsDeltaHandoff: false, + supportsFullThreadHandoff: false, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: false, + supportsNestedCheckpointScopes: false, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "none", + nativeItemIds: "weak", + nativeRequestIds: "none", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface HermesGatewayClientLike { + readonly compatibility: HermesGatewayCompatibility | undefined; + connect(): Promise; + hasCapability(capability: string): boolean; + onEvent(listener: (event: HermesGatewayOrderedEvent) => void | Promise): () => void; + createSession( + params: HermesGatewaySessionCreateParams, + options: Omit, + ): Promise; + resumeSession( + params: HermesGatewaySessionResumeParams, + options: Omit, + ): Promise; + replaceSessionMcp( + params: HermesGatewaySessionMcpParams, + options: Omit, + ): Promise; + revokeSessionMcp( + sessionId: string, + options: Omit, + ): Promise; + readSessionStatus(params: { + readonly session_id: string; + readonly profile?: string; + }): Promise; + readSessionHistory(params: { + readonly session_id: string; + readonly profile?: string; + }): Promise; + readSessionTitle( + params: Pick, + ): Promise; + updateSessionTitle( + params: HermesGatewaySessionTitleParams & { readonly title: string }, + options: Omit, + ): Promise; + branchSession( + params: HermesGatewaySessionBranchParams, + options: Omit, + ): Promise; + reconcileMutation( + operationId: string, + mutationId?: string, + ): Promise; + submitPrompt( + params: HermesGatewayPromptSubmitParams, + options: Omit, + ): Promise; + attachImageBytes( + params: { + readonly session_id: string; + readonly content_base64: string; + readonly filename?: string; + }, + options: Omit, + ): Promise; + respondToApproval( + params: { readonly session_id: string; readonly choice: "once" | "session" | "deny" }, + options: Omit, + ): Promise; + respondToClarification( + params: { readonly request_id: string; readonly answer: string }, + options: Omit, + ): Promise; + attachFile( + params: { + readonly session_id: string; + readonly name: string; + readonly data_url: string; + }, + options: Omit, + ): Promise; + attachPdf( + params: { + readonly session_id: string; + readonly filename: string; + readonly content_base64: string; + }, + options: Omit, + ): Promise; + interruptSession( + params: { readonly session_id: string }, + options: Omit, + ): Promise; + close(): void; +} + +export interface HermesServeAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: HermesSettings; + readonly enabled: boolean; + readonly authToken: string | undefined; + readonly remotePairingToken?: string | undefined; + readonly remoteTlsCertificateSha256?: string | undefined; + readonly connectionRuntime?: HermesServeRuntimeShape | undefined; + readonly idAllocator: IdAllocatorV2Shape; + readonly repository: HermesSessionBindingRepositoryShape; + readonly readAttachment?: ( + attachment: ChatAttachment, + ) => Effect.Effect; + readonly resolveHistoryMedia?: (input: { + readonly sourcePath: string; + readonly expectedKind: HermesHistoryMediaKind; + readonly threadId: string; + readonly stableKey: string; + }) => Effect.Effect; + readonly clientFactory?: (input: { + readonly endpoint: string; + readonly authToken: string; + }) => HermesGatewayClientLike; +} + +export function resolveHermesGatewayToken( + environment: ProviderInstanceEnvironment, +): string | undefined { + return environment.find( + (variable) => + variable.name === "HERMES_GATEWAY_TOKEN" && + variable.sensitive && + variable.value.trim().length > 0, + )?.value; +} + +export function resolveHermesRemotePairingToken( + environment: ProviderInstanceEnvironment, +): string | undefined { + return resolveSensitiveHermesEnvironment(environment, HERMES_REMOTE_PAIRING_TOKEN_ENV); +} + +export function resolveHermesRemoteTlsCertificateSha256( + environment: ProviderInstanceEnvironment, +): string | undefined { + return resolveSensitiveHermesEnvironment(environment, HERMES_REMOTE_TLS_CERT_SHA256_ENV); +} + +function resolveSensitiveHermesEnvironment( + environment: ProviderInstanceEnvironment, + name: string, +): string | undefined { + return environment.find( + (variable) => variable.name === name && variable.sensitive && variable.value.trim().length > 0, + )?.value; +} + +interface ActiveHermesTurn { + readonly input: ProviderAdapterV2TurnInput; + readonly operationId: string; + readonly completion: Deferred.Deferred; + readonly bufferedEvents: Array; + providerTurn: OrchestrationV2ProviderTurn | null; + assistantNativeId: string | null; + assistantText: string; + assistantStartedAt: DateTime.Utc | null; + reasoningText: string; + reasoningStartedAt: DateTime.Utc | null; + reasoningHasStreamedDelta: boolean; + readonly itemOrdinals: Map; + nextItemOrdinal: number; + readonly toolsByIdentity: Map; + readonly toolsByNativeId: Map; + readonly generatingToolsByName: Map>; + readonly seenEventIds: Set; + gatewayRunId: string | null; + interrupted: boolean; + finalized: boolean; + intentState: HermesMutationIntentState; +} + +interface ActiveHermesTool { + readonly identity: string; + nativeToolId: string | null; + name: string | null; + input: unknown; + output: unknown | undefined; + title: string | null; + status: OrchestrationV2TurnItem["status"]; + startedAt: DateTime.Utc | null; + completedAt: DateTime.Utc | null; +} + +interface HermesThreadState { + readonly binding: HermesSessionBinding; + readonly liveSessionId: string; + lease: HermesOwnerLease; + titleRevision: number; + title: string | null; + providerThread: OrchestrationV2ProviderThread; + activeTurn: ActiveHermesTurn | null; + externalRunActive: boolean; + ownershipLost: boolean; + readonly turns: Map; + readonly messages: Map; + readonly runtimeRequests: Map; +} + +interface PendingHermesRuntimeRequest { + readonly request: OrchestrationV2RuntimeRequest; + readonly state: HermesThreadState; + readonly active: ActiveHermesTurn; + readonly nodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly nativeIdentity: string; + readonly kind: "approval" | "clarification"; + readonly prompt: string; + readonly questionId?: string; + readonly choices?: ReadonlyArray; + readonly nativeRequestId?: string; +} + +type HermesMutationFence = Pick; + +const sha256 = (value: string): string => + NodeCrypto.createHash("sha256").update(value).digest("hex"); +const stableDigest = (...parts: ReadonlyArray): string => sha256(JSON.stringify(parts)); +export const hermesWireMutationId = (operationId: string): string => `t3_${sha256(operationId)}`; + +const providerRef = ( + nativeId: string, + strength: OrchestrationV2ProviderRef["strength"] = "strong", +): OrchestrationV2ProviderRef => ({ + driver: HERMES_PROVIDER, + nativeId, + strength, +}); + +class HermesGatewayCallError extends Schema.TaggedErrorClass()( + "HermesGatewayCallError", + { + cause: Schema.Defect(), + }, +) {} +const isHermesGatewayCallError = Schema.is(HermesGatewayCallError); +const isProviderAdapterRuntimeRequestResponseError = Schema.is( + ProviderAdapterRuntimeRequestResponseError, +); + +const gatewayEffect = (operation: () => Promise) => + Effect.tryPromise({ + try: operation, + catch: (cause) => new HermesGatewayCallError({ cause }), + }); + +const isIndeterminateGatewayCall = (cause: unknown): boolean => + isHermesGatewayCallError(cause) && cause.cause instanceof HermesGatewayMutationIndeterminateError; + +const decodeTitleChanged = Schema.decodeUnknownSync(HermesGatewayTitleChangedEventPayload); + +export function hermesModelOverride(model: string): { readonly model?: string } { + return model === "default" ? {} : { model }; +} + +export function hermesReasoningOverride( + modelSelection: ProviderAdapterV2EnsureThreadInput["modelSelection"], +): { readonly reasoning_effort?: string } { + const effort = getModelSelectionStringOptionValue(modelSelection, "reasoningEffort"); + return effort === undefined ? {} : { reasoning_effort: effort }; +} + +export function hermesFastOverride( + modelSelection: ProviderAdapterV2EnsureThreadInput["modelSelection"], +): { readonly fast?: boolean } { + const fast = getModelSelectionStringOptionValue(modelSelection, "fast"); + return fast === undefined ? {} : { fast: fast === "fast" }; +} + +export function hermesApprovalChoice( + decision: ProviderApprovalDecision, +): "once" | "session" | "deny" { + if (decision === "accept") return "once"; + if (decision === "acceptForSession") return "session"; + return "deny"; +} + +export function hermesClarificationAnswer( + answers: ProviderUserInputAnswers, + questionId: string, +): string | undefined { + const value = answers[questionId] ?? Object.values(answers)[0]; + if (typeof value === "string") return value.trim() || undefined; + if (Array.isArray(value)) { + const values = value.filter((entry): entry is string => typeof entry === "string"); + return values.length === 0 ? undefined : values.join(", "); + } + return undefined; +} + +function compatibilityFields(compatibility: HermesGatewayCompatibility) { + return { + protocolClassification: compatibility.status, + protocolMajor: compatibility.protocol?.major ?? null, + protocolMinor: compatibility.protocol?.minor ?? null, + capabilities: compatibility.capabilities, + } as const; +} + +function leaseTimes() { + const nowValue = DateTime.nowUnsafe(); + return { + now: DateTime.formatIso(nowValue), + expiresAt: DateTime.formatIso(DateTime.add(nowValue, { minutes: LEASE_MINUTES })), + }; +} + +function eventText(event: HermesGatewayOrderedEvent): string { + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return ""; + const text = (payload as { readonly text?: unknown }).text; + return typeof text === "string" ? text : ""; +} + +function eventStatus(event: HermesGatewayOrderedEvent): string | undefined { + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return undefined; + const status = (payload as { readonly status?: unknown }).status; + return typeof status === "string" ? status.toLowerCase() : undefined; +} + +function eventMessageId(event: HermesGatewayOrderedEvent): string | undefined { + if (event.messageId) return event.messageId; + const payload = event.frame.params.payload; + if (typeof payload !== "object" || payload === null) return undefined; + const messageId = (payload as { readonly message_id?: unknown }).message_id; + return typeof messageId === "string" && messageId.length > 0 ? messageId : undefined; +} + +function eventToolPayload(event: HermesGatewayOrderedEvent): HermesGatewayToolEventPayload { + const payload = event.frame.params.payload; + return typeof payload === "object" && payload !== null + ? (payload as HermesGatewayToolEventPayload) + : {}; +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function sensitiveToolKey(key: string): boolean { + const normalized = key.replace(/[^a-z0-9]/giu, "").toLowerCase(); + return ( + normalized.endsWith("authorization") || + normalized.endsWith("apikey") || + normalized.endsWith("accesstoken") || + normalized.endsWith("refreshtoken") || + normalized.endsWith("password") || + normalized.endsWith("passwd") || + normalized.endsWith("secret") || + normalized.endsWith("credential") || + normalized.endsWith("privatekey") || + normalized.endsWith("accesskey") || + normalized === "cookie" || + normalized === "setcookie" || + normalized === "token" + ); +} + +function redactToolString(value: string): string { + return value + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]") + .replace( + /([?&](?:access_token|api_key|apikey|password|secret|token)=)[^&#\s]*/giu, + "$1[REDACTED]", + ) + .replace( + /\b((?:api[_-]?key|access[_-]?token|password|secret|token)\s*[=:]\s*)([^\s,;]+)/giu, + "$1[REDACTED]", + ); +} + +/** + * Hermes complete events contain the raw tool arguments/result even when the + * optional verbose text is already redacted. Bound and redact those values + * before they enter durable orchestration projections. + */ +export function sanitizeHermesToolValue( + value: unknown, + options: { readonly maxChars?: number } = {}, +): unknown { + const maxChars = options.maxChars ?? 40_000; + let remaining = maxChars; + const seen = new WeakSet(); + const visit = (current: unknown, depth: number): unknown => { + if (remaining <= 0) return "[TRUNCATED]"; + if (typeof current === "string") { + const redacted = redactToolString(current); + const limit = Math.min(remaining, 16_000); + remaining -= Math.min(redacted.length, limit); + return redacted.length <= limit ? redacted : `${redacted.slice(0, limit)}… [TRUNCATED]`; + } + if (current === null || typeof current === "boolean" || typeof current === "number") { + remaining -= 8; + return current; + } + if (current === undefined) return undefined; + if (depth >= 8) return "[MAX_DEPTH]"; + if (typeof current !== "object") return String(current); + if (seen.has(current)) return "[CIRCULAR]"; + seen.add(current); + if (Array.isArray(current)) { + const values = current.slice(0, 100).map((entry) => visit(entry, depth + 1)); + if (current.length > values.length) values.push(`[${current.length - values.length} MORE]`); + return values; + } + const entries = Object.entries(current).slice(0, 100); + const result = Object.fromEntries( + entries.map(([key, nested]) => { + remaining -= key.length; + return [key, sensitiveToolKey(key) ? "[REDACTED]" : visit(nested, depth + 1)]; + }), + ); + if (Object.keys(current).length > entries.length) { + result["[TRUNCATED_KEYS]"] = Object.keys(current).length - entries.length; + } + return result; + }; + return visit(value, 0); +} + +function toolTerminalStatus( + payload: HermesGatewayToolEventPayload, +): OrchestrationV2TurnItem["status"] { + const explicit = nonEmptyString(payload.status)?.toLowerCase(); + if (explicit?.includes("interrupt")) return "interrupted"; + if (explicit?.includes("cancel")) return "cancelled"; + if (explicit?.includes("fail") || explicit?.includes("error")) return "failed"; + const result = + typeof payload.result === "object" && payload.result !== null + ? (payload.result as Record) + : undefined; + if ( + result?.success === false || + result?.ok === false || + (typeof result?.exit_code === "number" && result.exit_code !== 0) || + (typeof result?.returncode === "number" && result.returncode !== 0) + ) { + return "failed"; + } + if (typeof payload.result === "string") { + const normalized = payload.result.trim(); + if ( + /^Error executing tool\b/iu.test(normalized) || + /^Tool execution failed\b/iu.test(normalized) || + /\[Command timed out after \d+s\]\s*$/iu.test(normalized) + ) { + return "failed"; + } + } + return "completed"; +} + +function hasRecoveredWork(value: unknown): boolean { + if (value === undefined || value === null || value === false) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + if (typeof value === "string") return value.trim().length > 0; + return true; +} + +function isActiveHermesStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return ["active", "running", "streaming", "queued", "inflight", "busy"].some((value) => + normalized.includes(value), + ); +} + +function isTerminalHermesStatus(status: string): boolean { + const normalized = status.trim().toLowerCase(); + return ["idle", "complete", "completed", "interrupted", "error", "failed"].some((value) => + normalized.includes(value), + ); +} + +export function hermesSessionRuntimeStatus(status: HermesGatewaySessionStatusResult): string { + const runningLine = /^Agent Running:\s*(Yes|No)\s*$/imu.exec(status.output); + if (runningLine?.[1]?.toLowerCase() === "yes") return "running"; + if (runningLine?.[1]?.toLowerCase() === "no") return "idle"; + return status.output; +} + +function historyRole( + role: HermesGatewayHistoryMessage["role"], +): OrchestrationV2ConversationMessage["role"] { + if (role === "user" || role === "assistant" || role === "system") return role; + return "system"; +} + +export function makeHermesServeAdapterV2( + options: HermesServeAdapterV2Options, +): ProviderAdapterV2Shape { + const configuredCapabilities: OrchestrationV2ProviderCapabilities = { + ...HermesProviderCapabilitiesV2, + tools: { + ...HermesProviderCapabilitiesV2.tools, + supportsMcpTools: options.settings.mcpEnabled, + }, + }; + const makeClient = + options.clientFactory ?? + ((input) => + new HermesGatewayClient({ + endpoint: input.endpoint, + authToken: input.authToken, + criticalCapabilities: [ + "session.lifecycle", + "session.history", + "turn.prompt", + "turn.interrupt", + "events.tools", + ], + })); + + return { + instanceId: options.instanceId, + driver: HERMES_PROVIDER, + getCapabilities: () => Effect.succeed(configuredCapabilities), + planSelectionTransition: (input) => + Effect.succeed( + input.current.model === input.target.model + ? ({ type: "apply_on_next_turn" } as const) + : ({ + type: "reject", + reason: "Hermes model switching requires a new provider thread.", + } as const), + ), + openSession: Effect.fn("HermesServeAdapterV2.openSession")(function* ( + input: ProviderAdapterV2OpenSessionInput, + ) { + if (!options.enabled) { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error("Hermes requires both enableHermes and an enabled provider instance."), + }); + } + if (!options.settings.profileKey.trim()) { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error("Hermes profileKey must be configured."), + }); + } + let endpoint = resolveHermesServeEndpoint(options.settings.endpoint); + let authToken = options.authToken; + if (options.connectionRuntime !== undefined) { + const resolved = yield* options.connectionRuntime.ensureReady.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + endpoint = resolved.endpoint; + authToken = resolved.authToken; + } + const connectionSecurity = assessHermesConnectionSecurity({ + endpoint, + gatewayToken: authToken, + remoteGloballyEnabled: options.enabled, + remoteInstanceEnabled: options.settings.remoteAccessEnabled, + remotePairingToken: options.remotePairingToken, + remoteTlsCertificateSha256: options.remoteTlsCertificateSha256, + }); + if (connectionSecurity.status !== "ready") { + return yield* new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause: new Error(connectionSecurity.message), + }); + } + + const client = makeClient({ + endpoint: connectionSecurity.endpoint, + authToken: connectionSecurity.authToken, + }); + const compatibility = yield* gatewayEffect(() => client.connect()).pipe( + Effect.tapError(() => Effect.sync(() => client.close())), + Effect.mapError( + (cause) => + new ProviderAdapterOpenSessionError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + const mcpDiagnostic = diagnoseHermesMcpIntegration(compatibility); + const mcpAvailable = options.settings.mcpEnabled && mcpDiagnostic.status === "ready"; + if (options.settings.mcpEnabled && !mcpAvailable) { + yield* Effect.logWarning("hermes.mcp-integration-blocked", { + providerSessionId: input.providerSessionId, + providerInstanceId: options.instanceId, + status: mcpDiagnostic.status, + missingCapabilities: mcpDiagnostic.missingCapabilities, + reason: mcpDiagnostic.reason, + }); + } + const events = yield* Queue.unbounded(); + const runtimeContext = yield* Effect.context(); + const runPromise = Effect.runPromiseWith(runtimeContext); + const statesByProviderThread = new Map(); + const statesByLiveSession = new Map(); + const mcpCredentialByLiveSession = new Map(); + const pendingRuntimeRequests = new Map(); + const stateForProviderThread = ( + providerThread: OrchestrationV2ProviderThread, + ): HermesThreadState | undefined => { + const exact = statesByProviderThread.get(String(providerThread.id)); + if (exact !== undefined) return exact; + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId === undefined) return undefined; + return [...statesByProviderThread.values()].find( + (candidate) => candidate.providerThread.nativeThreadRef?.nativeId === nativeThreadId, + ); + }; + const alignStateProviderThread = ( + state: HermesThreadState, + projectedThread: OrchestrationV2ProviderThread, + ): OrchestrationV2ProviderThread => { + const previousId = String(state.providerThread.id); + state.providerThread = { + ...state.providerThread, + id: projectedThread.id, + providerSessionId: projectedThread.providerSessionId, + appThreadId: projectedThread.appThreadId, + ownerNodeId: projectedThread.ownerNodeId, + firstRunOrdinal: projectedThread.firstRunOrdinal ?? state.providerThread.firstRunOrdinal, + lastRunOrdinal: projectedThread.lastRunOrdinal ?? state.providerThread.lastRunOrdinal, + handoffIds: projectedThread.handoffIds, + forkedFrom: projectedThread.forkedFrom, + }; + if (previousId !== String(projectedThread.id)) { + statesByProviderThread.delete(previousId); + } + statesByProviderThread.set(String(projectedThread.id), state); + return state.providerThread; + }; + const providerCapabilities: OrchestrationV2ProviderCapabilities = { + ...HermesProviderCapabilitiesV2, + tools: { + ...HermesProviderCapabilitiesV2.tools, + supportsMcpTools: mcpAvailable, + }, + }; + let providerSession: OrchestrationV2ProviderSession = { + id: input.providerSessionId, + driver: HERMES_PROVIDER, + providerInstanceId: options.instanceId, + status: "ready" as const, + cwd: input.runtimePolicy.cwd ?? process.cwd(), + model: input.modelSelection.model, + capabilities: providerCapabilities, + createdAt: yield* DateTime.now, + updatedAt: yield* DateTime.now, + lastError: null, + }; + + const emit = (event: ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + const reconcileTitle = Effect.fnUntraced(function* ( + state: HermesThreadState, + titleState: HermesGatewaySessionTitleResult, + ) { + const title = titleState.title?.trim(); + const origin = titleState.origin.trim(); + if (!title || !origin) return; + if (titleState.revision <= state.titleRevision) { + if (titleState.revision === state.titleRevision) state.title = title; + return; + } + const { now } = leaseTimes(); + const updated = yield* options.repository.updateTitleState({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + revision: titleState.revision, + origin, + }); + if (!updated) return; + state.titleRevision = titleState.revision; + state.title = title; + yield* emit({ + type: "app_thread.title_reconciled", + driver: HERMES_PROVIDER, + threadId: state.providerThread.appThreadId ?? input.threadId, + title, + revision: titleState.revision, + origin, + }); + }); + const updateSession = ( + status: OrchestrationV2ProviderSession["status"], + lastError: string | null = providerSession.lastError, + ) => + Effect.gen(function* () { + providerSession = { + ...providerSession, + status, + lastError, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_session.updated", + driver: HERMES_PROVIDER, + providerSession, + }); + }); + + const updateThread = ( + state: HermesThreadState, + patch: Partial, + ) => + Effect.gen(function* () { + state.providerThread = { + ...state.providerThread, + ...patch, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_thread.updated", + driver: HERMES_PROVIDER, + providerThread: state.providerThread, + }); + }); + + const transitionIntent = ( + state: HermesMutationFence, + operationId: string, + from: HermesMutationIntentState, + to: HermesMutationIntentState, + ) => { + const { now } = leaseTimes(); + return options.repository + .transitionMutationIntent({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + operationId, + from, + to, + }) + .pipe( + Effect.flatMap((changed) => + changed + ? Effect.void + : new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation intent ${operationId} lost its lease fence`, + }), + ), + ); + }; + + const reconcileIntent = Effect.fnUntraced(function* ( + intent: HermesMutationIntent, + expected: { + readonly bindingId: string | null; + readonly mutationKind: string; + readonly method: string; + readonly payloadDigest: string; + }, + ) { + if ( + intent.bindingId !== expected.bindingId || + intent.mutationKind !== expected.mutationKind || + intent.method !== expected.method || + intent.payloadDigest !== expected.payloadDigest + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${intent.operationId} conflicts with its durable intent`, + }); + } + if ( + intent.state === "confirmed" || + intent.state === "reconciled" || + intent.state === "rejected" + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${intent.operationId} is already terminal (${intent.state})`, + }); + } + return yield* gatewayEffect(() => + client.reconcileMutation(intent.operationId, hermesWireMutationId(intent.operationId)), + ); + }); + + const prepareBoundMutation = Effect.fnUntraced(function* ( + state: HermesMutationFence, + input: { + readonly operationId: string; + readonly mutationKind: string; + readonly method: string; + readonly payloadDigest: string; + }, + ) { + const { now, expiresAt } = leaseTimes(); + const renewed = yield* options.repository.renewOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + expiresAt, + }); + if (!renewed) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes owner lease is no longer held", + }); + } + const result = yield* options.repository.prepareMutationIntent({ + ...input, + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + }); + if (result.status === "prepared") { + yield* transitionIntent(state, input.operationId, "prepared", "admitted"); + return { + replay: false, + intentState: "admitted" as HermesMutationIntentState, + }; + } + if (result.status === "operation_exists") { + const outcome = yield* reconcileIntent(result.intent, { + bindingId: state.binding.bindingId, + mutationKind: input.mutationKind, + method: input.method, + payloadDigest: input.payloadDigest, + }); + if (outcome.mutation_status === "indeterminate") { + if (result.intent.state === "prepared" || result.intent.state === "admitted") { + yield* transitionIntent( + state, + input.operationId, + result.intent.state, + "indeterminate", + ); + } + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes mutation ${input.operationId} remains indeterminate`, + }); + } + return { + replay: true, + intentState: result.intent.state, + }; + } + { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: + result.status === "unsettled_prompt" + ? `Hermes prompt ${result.operationId} is still unsettled` + : `Hermes mutation ${input.operationId} cannot be safely resubmitted (${result.status})`, + }); + } + }); + + const mutationOptions = (operationId: string) => ({ + operationId, + ...(client.hasCapability("mutation.stable_ids") + ? { mutationId: hermesWireMutationId(operationId) } + : {}), + }); + + const settleMutation = ( + state: HermesMutationFence, + operationId: string, + operation: () => Promise, + ) => + gatewayEffect(operation).pipe( + Effect.tap(() => transitionIntent(state, operationId, "admitted", "confirmed")), + Effect.tapError((cause) => + transitionIntent( + state, + operationId, + "admitted", + isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + ).pipe(Effect.ignore), + ), + ); + + const resolveItemOrdinal = (active: ActiveHermesTurn, identity: string): number => { + const existing = active.itemOrdinals.get(identity); + if (existing !== undefined) return existing; + const ordinal = active.nextItemOrdinal++; + active.itemOrdinals.set(identity, ordinal); + return ordinal; + }; + + const toolArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + tool: ActiveHermesTool, + ) { + const turn = active.providerTurn; + if (turn === null) return; + const now = yield* DateTime.now; + const nativeItemRef = + tool.nativeToolId === null + ? providerRef(tool.identity, "weak") + : providerRef(tool.nativeToolId); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: tool.identity, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: tool.identity, + }); + const nodeStatus: OrchestrationV2ExecutionNode["status"] = + tool.status === "pending" || + tool.status === "running" || + tool.status === "waiting" || + tool.status === "completed" || + tool.status === "interrupted" || + tool.status === "failed" || + tool.status === "cancelled" + ? tool.status + : "running"; + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "tool_call", + status: nodeStatus, + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: tool.startedAt, + completedAt: tool.completedAt, + }, + }); + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef, + parentItemId: null, + ordinal: resolveItemOrdinal(active, tool.identity), + status: tool.status, + title: tool.title, + startedAt: tool.startedAt, + completedAt: tool.completedAt, + updatedAt: now, + type: "dynamic_tool", + toolName: tool.name, + input: tool.input, + ...(tool.output === undefined ? {} : { output: tool.output }), + }; + yield* emit({ type: "turn_item.updated", driver: HERMES_PROVIDER, turnItem }); + }); + + const messageArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + completed: boolean, + ) { + const turn = active.providerTurn; + if (turn === null) return; + const now = yield* DateTime.now; + const nativeMessageId = + active.assistantNativeId ?? + stableDigest(state.binding.storedSessionKey, active.operationId, "assistant"); + const messageId = options.idAllocator.derive.messageFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const turnItemId = options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeMessageId, + }); + const startedAt = active.assistantStartedAt ?? now; + active.assistantStartedAt = startedAt; + const normalizedAssistant = completed + ? yield* normalizeHermesHistoryMessage({ + role: "assistant", + text: active.assistantText, + resolveMedia: (media, index) => + options.resolveHistoryMedia?.({ + sourcePath: media.path, + expectedKind: media.kind, + threadId: String(active.input.threadId), + stableKey: `${state.binding.providerInstanceId}:${state.binding.profileKey}:${state.binding.storedSessionKey}:${nativeMessageId}:${index}`, + }) ?? Effect.succeed(null), + }) + : { + text: parseHermesHistoryText({ + role: "assistant", + text: active.assistantText, + }).text, + attachments: [], + }; + const message: OrchestrationV2ConversationMessage = { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + role: "assistant", + text: normalizedAssistant.text, + attachments: normalizedAssistant.attachments, + streaming: !completed, + createdAt: startedAt, + updatedAt: now, + }; + state.messages.set(String(messageId), message); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "assistant_message", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: providerRef(nativeMessageId), + runtimeRequestId: null, + checkpointScopeId: null, + startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emit({ type: "message.updated", driver: HERMES_PROVIDER, message }); + const turnItem: OrchestrationV2TurnItem = { + id: turnItemId, + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: providerRef(nativeMessageId), + parentItemId: null, + ordinal: resolveItemOrdinal(active, `assistant:${nativeMessageId}`), + status: completed ? "completed" : "running", + title: null, + startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "assistant_message", + messageId, + text: normalizedAssistant.text, + streaming: !completed, + }; + yield* emit({ type: "turn_item.updated", driver: HERMES_PROVIDER, turnItem }); + }); + + const reasoningArtifacts = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + completed: boolean, + ) { + const turn = active.providerTurn; + if (turn === null || active.reasoningText.length === 0) return; + const now = yield* DateTime.now; + active.reasoningStartedAt ??= now; + const nativeIdentity = `hermes-reasoning:${active.gatewayRunId ?? active.operationId}`; + const nativeRef = providerRef(nativeIdentity, "weak"); + const nodeId = options.idAllocator.derive.nodeFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeIdentity, + }); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: "reasoning", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: nativeRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: active.reasoningStartedAt, + completedAt: completed ? now : null, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: { + id: options.idAllocator.derive.turnItemFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeIdentity, + }), + threadId: active.input.threadId, + runId: active.input.runId, + nodeId, + providerThreadId: state.providerThread.id, + providerTurnId: turn.id, + nativeItemRef: nativeRef, + parentItemId: null, + ordinal: resolveItemOrdinal(active, nativeIdentity), + status: completed ? "completed" : "running", + title: "Reasoning", + startedAt: active.reasoningStartedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "reasoning", + text: active.reasoningText, + streaming: !completed, + }, + }); + }); + + const runtimeRequestTurnItem = ( + pending: PendingHermesRuntimeRequest, + status: OrchestrationV2TurnItem["status"], + completedAt: DateTime.Utc | null, + updatedAt: DateTime.Utc, + ): OrchestrationV2TurnItem => { + const base = { + id: pending.turnItemId, + threadId: pending.active.input.threadId, + runId: pending.active.input.runId, + nodeId: pending.nodeId, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.active.providerTurn?.id ?? null, + nativeItemRef: providerRef( + pending.nativeIdentity, + pending.nativeRequestId === undefined ? "weak" : "strong", + ), + parentItemId: null, + ordinal: resolveItemOrdinal(pending.active, pending.nativeIdentity), + status, + title: pending.kind === "approval" ? "Approval required" : "User input", + startedAt: pending.request.createdAt, + completedAt, + updatedAt, + } as const; + if (pending.kind === "approval") { + return { + ...base, + type: "approval_request", + requestId: pending.request.id, + requestKind: "command", + prompt: pending.prompt, + }; + } + return { + ...base, + type: "user_input_request", + requestId: pending.request.id, + questions: [ + { + id: pending.questionId ?? "question", + header: "Question", + question: pending.prompt, + options: (pending.choices ?? []).map((choice) => ({ + label: choice, + description: choice, + })), + }, + ], + }; + }; + + const emitRuntimeRequest = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + kind: PendingHermesRuntimeRequest["kind"], + ) { + if (active.providerTurn === null) return; + const payload = + typeof event.frame.params.payload === "object" && event.frame.params.payload !== null + ? (event.frame.params.payload as Record) + : {}; + const nativeRequestId = + kind === "clarification" ? nonEmptyString(payload.request_id) : undefined; + if (kind === "clarification" && nativeRequestId === undefined) return; + const nativeIdentity = + nativeRequestId ?? + `approval:${event.eventId ?? event.eventSequence ?? event.transportSequence}`; + if ( + [...pendingRuntimeRequests.values()].some( + (pending) => + pending.state === state && + pending.kind === kind && + pending.nativeIdentity === nativeIdentity, + ) + ) { + return; + } + const prompt = + kind === "approval" + ? (nonEmptyString(payload.command) ?? + nonEmptyString(payload.description) ?? + "Hermes requests permission to run a command.") + : (nonEmptyString(payload.question) ?? "Hermes requests more information."); + const requestId = yield* options.idAllocator.allocate.runtimeRequest({ + driver: HERMES_PROVIDER, + providerTurnId: active.providerTurn.id, + nativeRequestId: nativeIdentity, + }); + const now = yield* DateTime.now; + const nodeId = options.idAllocator.derive.approvalNode({ requestId }); + const request: OrchestrationV2RuntimeRequest = { + id: requestId, + nodeId, + providerTurnId: active.providerTurn.id, + nativeRequestRef: + nativeRequestId === undefined ? null : providerRef(nativeRequestId, "strong"), + kind: kind === "approval" ? "command" : "user_input", + status: "pending", + responseCapability: { + type: "live", + providerSessionId: input.providerSessionId, + }, + createdAt: now, + resolvedAt: null, + }; + const pending: PendingHermesRuntimeRequest = { + request, + state, + active, + nodeId, + turnItemId: options.idAllocator.derive.approvalTurnItem({ requestId }), + nativeIdentity, + kind, + prompt, + ...(kind === "clarification" && nativeRequestId !== undefined + ? { questionId: nativeRequestId } + : {}), + ...(Array.isArray(payload.choices) + ? { + choices: payload.choices.filter( + (choice): choice is string => typeof choice === "string", + ), + } + : {}), + ...(nativeRequestId === undefined ? {} : { nativeRequestId }), + }; + pendingRuntimeRequests.set(String(requestId), pending); + state.runtimeRequests.set(String(requestId), request); + const nativeRef = providerRef( + nativeIdentity, + nativeRequestId === undefined ? "weak" : "strong", + ); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: nodeId, + threadId: active.input.threadId, + runId: active.input.runId, + parentNodeId: active.input.rootNodeId, + rootNodeId: active.input.rootNodeId, + kind: kind === "approval" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId: state.providerThread.id, + providerTurnId: active.providerTurn.id, + nativeItemRef: nativeRef, + runtimeRequestId: requestId, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }, + }); + yield* emit({ + type: "runtime_request.updated", + driver: HERMES_PROVIDER, + threadId: active.input.threadId, + runtimeRequest: request, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: runtimeRequestTurnItem(pending, "waiting", null, now), + }); + yield* updateSession("waiting", null); + }); + + const settleRuntimeRequest = Effect.fnUntraced(function* ( + pending: PendingHermesRuntimeRequest, + status: "resolved" | "cancelled", + ) { + const now = yield* DateTime.now; + const resolved: OrchestrationV2RuntimeRequest = { + ...pending.request, + status, + resolvedAt: now, + }; + pending.state.runtimeRequests.set(String(resolved.id), resolved); + pendingRuntimeRequests.delete(String(resolved.id)); + yield* emit({ + type: "runtime_request.updated", + driver: HERMES_PROVIDER, + threadId: pending.active.input.threadId, + runtimeRequest: resolved, + }); + yield* emit({ + type: "node.updated", + driver: HERMES_PROVIDER, + node: { + id: pending.nodeId, + threadId: pending.active.input.threadId, + runId: pending.active.input.runId, + parentNodeId: pending.active.input.rootNodeId, + rootNodeId: pending.active.input.rootNodeId, + kind: pending.kind === "approval" ? "approval_request" : "user_input_request", + status: status === "resolved" ? "completed" : "cancelled", + countsForRun: false, + providerThreadId: pending.state.providerThread.id, + providerTurnId: pending.active.providerTurn?.id ?? null, + nativeItemRef: providerRef( + pending.nativeIdentity, + pending.nativeRequestId === undefined ? "weak" : "strong", + ), + runtimeRequestId: pending.request.id, + checkpointScopeId: null, + startedAt: pending.request.createdAt, + completedAt: now, + }, + }); + yield* emit({ + type: "turn_item.updated", + driver: HERMES_PROVIDER, + turnItem: runtimeRequestTurnItem( + pending, + status === "resolved" ? "completed" : "cancelled", + now, + now, + ), + }); + }); + + const removeGeneratingTool = (active: ActiveHermesTurn, tool: ActiveHermesTool): void => { + const key = tool.name ?? ""; + const queued = active.generatingToolsByName.get(key); + if (queued === undefined) return; + const index = queued.indexOf(tool); + if (index >= 0) queued.splice(index, 1); + if (queued.length === 0) active.generatingToolsByName.delete(key); + }; + + const handleToolEvent = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + ) { + const payload = eventToolPayload(event); + const eventType = event.frame.params.type; + const name = nonEmptyString(payload.name) ?? null; + const toolId = nonEmptyString(payload.tool_id); + const runIdentity = event.runId ?? active.gatewayRunId ?? active.operationId; + const generatingIdentity = `hermes-tool:${event.runId ?? active.operationId}:${ + event.eventId ?? event.eventSequence ?? event.transportSequence + }`; + let tool: ActiveHermesTool | undefined = + toolId === undefined ? undefined : active.toolsByNativeId.get(toolId); + + if (tool === undefined && eventType !== "tool.generating") { + const queued = active.generatingToolsByName.get(name ?? "")?.[0]; + if (queued !== undefined) { + tool = queued; + if (eventType !== "tool.progress") removeGeneratingTool(active, queued); + } + } + if (tool === undefined && eventType === "tool.complete" && toolId === undefined) { + tool = [...active.toolsByIdentity.values()] + .toReversed() + .find( + (candidate) => + candidate.name === name && + (candidate.status === "pending" || + candidate.status === "running" || + candidate.status === "waiting"), + ); + } + if (tool === undefined) { + const identity = + toolId === undefined ? generatingIdentity : `hermes-tool:${runIdentity}:${toolId}`; + tool = { + identity, + nativeToolId: toolId ?? null, + name, + input: {}, + output: undefined, + title: name, + status: eventType === "tool.generating" ? "pending" : "running", + startedAt: null, + completedAt: null, + }; + active.toolsByIdentity.set(identity, tool); + if ( + eventType === "tool.generating" || + (eventType === "tool.progress" && toolId === undefined) + ) { + const queued = active.generatingToolsByName.get(name ?? "") ?? []; + queued.push(tool); + active.generatingToolsByName.set(name ?? "", queued); + } + } + if (toolId !== undefined) { + tool.nativeToolId = toolId; + active.toolsByNativeId.set(toolId, tool); + } + if (name !== null) tool.name = name; + + const now = yield* DateTime.now; + if (eventType === "tool.generating") { + tool.status = "pending"; + } else if (eventType === "tool.start" || eventType === "tool.progress") { + if (eventType === "tool.start") removeGeneratingTool(active, tool); + tool.status = "running"; + tool.startedAt ??= now; + const startInput = + payload.args !== undefined + ? payload.args + : payload.arguments !== undefined + ? payload.arguments + : (nonEmptyString(payload.args_text) ?? + nonEmptyString(payload.context) ?? + nonEmptyString(payload.preview) ?? + {}); + tool.input = sanitizeHermesToolValue(startInput, { maxChars: 20_000 }); + tool.title = + nonEmptyString(payload.context) ?? nonEmptyString(payload.preview) ?? tool.name; + } else { + removeGeneratingTool(active, tool); + tool.status = toolTerminalStatus(payload); + tool.startedAt ??= now; + tool.completedAt ??= now; + const completedInput = + payload.args !== undefined + ? payload.args + : payload.arguments !== undefined + ? payload.arguments + : undefined; + if (completedInput !== undefined) { + tool.input = sanitizeHermesToolValue(completedInput, { maxChars: 20_000 }); + } + const completedOutput = + payload.result !== undefined + ? payload.result + : (nonEmptyString(payload.result_text) ?? + nonEmptyString(payload.summary) ?? + nonEmptyString(payload.inline_diff)); + if (completedOutput !== undefined) { + tool.output = sanitizeHermesToolValue(completedOutput); + } + tool.title = + nonEmptyString(payload.summary) ?? + nonEmptyString(payload.context) ?? + nonEmptyString(payload.preview) ?? + tool.name; + } + yield* toolArtifacts(state, active, tool); + }); + + const handleToolOutputRisk = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + event: HermesGatewayOrderedEvent, + ) { + const payload = eventToolPayload(event); + const toolId = nonEmptyString(payload.tool_id); + if (toolId === undefined) return; + const tool = active.toolsByNativeId.get(toolId); + if (tool === undefined) return; + const risk = nonEmptyString(payload.risk); + const findings = payload.findings; + const riskDetails = sanitizeHermesToolValue( + { + ...(risk === undefined ? {} : { risk }), + ...(findings === undefined ? {} : { findings }), + ...(payload.redacted === undefined ? {} : { redacted: payload.redacted }), + }, + { maxChars: 10_000 }, + ); + tool.output = + payload.redacted === true + ? { result: "[REDACTED BY HERMES]", outputRisk: riskDetails } + : { result: tool.output, outputRisk: riskDetails }; + yield* toolArtifacts(state, active, tool); + }); + + const finalizeUnsettledTools = Effect.fnUntraced(function* ( + state: HermesThreadState, + active: ActiveHermesTurn, + turnStatus: "completed" | "interrupted" | "failed", + ) { + const now = yield* DateTime.now; + for (const tool of active.toolsByIdentity.values()) { + if (tool.status !== "pending" && tool.status !== "running" && tool.status !== "waiting") { + continue; + } + tool.status = + turnStatus === "failed" + ? "failed" + : turnStatus === "interrupted" + ? "interrupted" + : "cancelled"; + tool.startedAt ??= tool.status === "cancelled" ? null : now; + tool.completedAt = now; + yield* toolArtifacts(state, active, tool); + } + }); + + const finalizeTurn = Effect.fnUntraced(function* ( + state: HermesThreadState, + status: "completed" | "interrupted" | "failed", + failureMessage?: string, + ) { + const active = state.activeTurn; + if (active === null || active.providerTurn === null || active.finalized) return; + const terminalIntentSettlement = yield* Effect.gen(function* () { + const { now, expiresAt } = leaseTimes(); + const renewed = yield* options.repository.renewOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + expiresAt, + }); + if (!renewed) { + const reacquired = yield* options.repository.acquireOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + expectedGeneration: state.lease.generation, + now, + expiresAt, + }); + if (Option.isNone(reacquired)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes terminal mutation settlement lost its generation fence", + }); + } + state.lease = reacquired.value; + } + yield* transitionIntent( + state, + active.operationId, + active.intentState, + active.intentState === "admitted" ? "confirmed" : "reconciled", + ); + }).pipe(Effect.result); + const settlementFailed = terminalIntentSettlement._tag === "Failure"; + if (terminalIntentSettlement._tag === "Failure") { + state.ownershipLost = true; + yield* Effect.logError("Hermes terminal event could not settle its durable intent", { + operationId: active.operationId, + cause: terminalIntentSettlement.failure, + }); + } + const projectedStatus = settlementFailed ? "failed" : status; + const projectedFailureMessage = settlementFailed + ? "Hermes ownership was lost before terminal settlement." + : failureMessage; + active.finalized = true; + const now = yield* DateTime.now; + yield* finalizeUnsettledTools(state, active, projectedStatus); + yield* reasoningArtifacts(state, active, true); + yield* messageArtifacts(state, active, true); + for (const pending of pendingRuntimeRequests.values()) { + if (pending.active === active) { + yield* settleRuntimeRequest(pending, "cancelled"); + } + } + const providerTurn: OrchestrationV2ProviderTurn = { + ...active.providerTurn, + status: projectedStatus, + completedAt: now, + }; + active.providerTurn = providerTurn; + state.turns.set(String(providerTurn.id), providerTurn); + yield* emit({ + type: "provider_turn.updated", + driver: HERMES_PROVIDER, + threadId: active.input.threadId, + providerTurn, + }); + yield* updateThread(state, { + status: projectedStatus === "failed" ? "error" : "idle", + }); + yield* updateSession( + projectedStatus === "failed" ? "error" : "ready", + projectedFailureMessage ?? null, + ); + yield* emit( + projectedStatus === "failed" + ? { + type: "turn.terminal", + driver: HERMES_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: providerTurn.id, + runOrdinal: active.input.runOrdinal, + failureItemOrdinal: active.nextItemOrdinal, + status: "failed", + failure: makeProviderFailure({ + class: "provider_error", + message: projectedFailureMessage ?? "Hermes turn failed.", + }), + threadDisposition: "broken", + } + : { + type: "turn.terminal", + driver: HERMES_PROVIDER, + providerThreadId: state.providerThread.id, + providerTurnId: providerTurn.id, + runOrdinal: active.input.runOrdinal, + status: projectedStatus, + failure: null, + threadDisposition: "reusable", + }, + ); + yield* Deferred.succeed(active.completion, undefined); + state.activeTurn = null; + }); + + const settleExternalRun = Effect.fnUntraced(function* ( + state: HermesThreadState, + status: string | undefined, + ) { + if (!state.externalRunActive || status === undefined || !isTerminalHermesStatus(status)) { + return; + } + state.externalRunActive = false; + const failed = status.includes("error") || status.includes("failed"); + yield* updateThread(state, { status: failed ? "error" : "idle" }); + yield* updateSession( + failed ? "error" : "ready", + failed ? "Hermes recovered external run failed." : null, + ); + }); + + const handleGatewayEvent = Effect.fnUntraced(function* (event: HermesGatewayOrderedEvent) { + const state = + (event.sessionId === undefined ? undefined : statesByLiveSession.get(event.sessionId)) ?? + [...statesByProviderThread.values()].find( + (candidate) => candidate.binding.storedSessionKey === event.sessionKey, + ); + if (state === undefined) return; + if (event.frame.params.type === "title.changed") { + let titleState: HermesGatewaySessionTitleResult; + try { + titleState = decodeTitleChanged(event.frame.params.payload); + } catch { + return; + } + yield* reconcileTitle(state, titleState); + return; + } + if (event.frame.params.type === "session.info") { + const payload = + typeof event.frame.params.payload === "object" && event.frame.params.payload !== null + ? (event.frame.params.payload as Record) + : {}; + const model = nonEmptyString(payload.model); + if (model !== undefined && model !== providerSession.model) { + providerSession = { + ...providerSession, + model, + updatedAt: yield* DateTime.now, + }; + yield* emit({ + type: "provider_session.updated", + driver: HERMES_PROVIDER, + providerSession, + }); + } + return; + } + const active = state.activeTurn; + if (active === null) { + const externalStatus = + event.frame.params.type === "message.complete" + ? (eventStatus(event) ?? "complete") + : event.frame.params.type === "error" + ? "error" + : eventStatus(event); + yield* settleExternalRun(state, externalStatus); + return; + } + if (active.providerTurn === null) { + active.bufferedEvents.push(event); + return; + } + if ( + active.gatewayRunId !== null && + event.runId !== undefined && + event.runId !== active.gatewayRunId + ) { + return; + } + if (event.eventId !== undefined) { + if (active.seenEventIds.has(event.eventId)) return; + active.seenEventIds.add(event.eventId); + } + + switch (event.frame.params.type) { + case "thinking.delta": { + const text = eventText(event); + if (text.length > 0 && !active.reasoningHasStreamedDelta) { + active.reasoningText += text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "reasoning.delta": { + const text = eventText(event); + if (text.length > 0) { + if (!active.reasoningHasStreamedDelta) active.reasoningText = ""; + active.reasoningHasStreamedDelta = true; + active.reasoningText += text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "reasoning.available": { + const text = eventText(event); + if (text.length > 0 && !active.reasoningHasStreamedDelta) { + active.reasoningText = text; + yield* reasoningArtifacts(state, active, false); + } + return; + } + case "approval.request": + yield* emitRuntimeRequest(state, active, event, "approval"); + return; + case "clarify.request": + yield* emitRuntimeRequest(state, active, event, "clarification"); + return; + case "tool.generating": + case "tool.progress": + case "tool.start": + case "tool.complete": { + yield* handleToolEvent(state, active, event); + return; + } + case "tool.output_risk": + yield* handleToolOutputRisk(state, active, event); + return; + case "message.start": { + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + const text = eventText(event); + if (text) active.assistantText = text; + yield* messageArtifacts(state, active, false); + return; + } + case "message.delta": + case "message.interim": { + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + active.assistantText += eventText(event); + yield* messageArtifacts(state, active, false); + return; + } + case "message.complete": { + if (active.finalized) return; + active.assistantNativeId = + eventMessageId(event) ?? active.assistantNativeId ?? active.operationId; + const text = eventText(event); + if (text && text !== active.assistantText) { + active.assistantText = text.startsWith(active.assistantText) + ? text + : `${active.assistantText}${text}`; + } + const status = eventStatus(event); + if (status === "error" || status === "failed") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else if (status === "interrupted") { + yield* finalizeTurn(state, "interrupted"); + } else { + yield* finalizeTurn(state, active.interrupted ? "interrupted" : "completed"); + } + return; + } + case "status.update": + case "session.status": { + const status = eventStatus(event); + if (status === "error" || status === "failed") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else if (status === "interrupted") { + yield* finalizeTurn(state, "interrupted"); + } else if (status === "idle" || status === "complete" || status === "completed") { + yield* finalizeTurn(state, active.interrupted ? "interrupted" : "completed"); + } + return; + } + case "error": + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + return; + default: + // Unsupported and future event families are isolated from text streaming. + return; + } + }); + + const unsubscribe = client.onEvent((event) => + runPromise( + handleGatewayEvent(event).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Ignored malformed Hermes gateway event", { + eventType: event.frame.params.type, + cause, + }), + ), + ), + ), + ); + + const acquireLease = Effect.fnUntraced(function* (binding: HermesSessionBinding) { + const { now, expiresAt } = leaseTimes(); + const acquired = yield* options.repository.acquireOwnerLease({ + bindingId: binding.bindingId, + ownerKey: String(input.providerSessionId), + expectedGeneration: binding.leaseGeneration, + now, + expiresAt, + }); + if (Option.isNone(acquired)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes binding ${binding.bindingId} is owned by another generation`, + }); + } + return acquired.value; + }); + + const ensureSessionMcp = Effect.fnUntraced(function* ( + liveSessionId: string, + threadId: ProviderAdapterV2EnsureThreadInput["threadId"], + ) { + if (!mcpAvailable) return; + const mcpSession = McpProviderSession.readMcpProviderSession(threadId); + if (mcpSession === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `T3 MCP credential was not prepared for Hermes thread ${threadId}`, + }); + } + const credentialIdentity = mcpSession.credentialId ?? mcpSession.providerSessionId; + if (mcpCredentialByLiveSession.get(liveSessionId) === credentialIdentity) { + return; + } + const operationId = `hermes:mcp:replace:${stableDigest( + options.instanceId, + liveSessionId, + credentialIdentity, + )}`; + const lease = yield* gatewayEffect(() => + client.replaceSessionMcp( + { + session_id: liveSessionId, + servers: { + "t3-code": { + url: mcpSession.endpoint, + headers: { + Authorization: mcpSession.authorizationHeader, + }, + }, + }, + }, + mutationOptions(operationId), + ), + ); + if ( + lease.scope.session_id !== liveSessionId || + !lease.servers.some((server) => server.name === "t3-code") || + !lease.tool_names.some((toolName) => toolName.endsWith("__delegate_task")) + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes did not scope the T3 orchestration MCP lease to live session ${liveSessionId}`, + }); + } + mcpCredentialByLiveSession.set(liveSessionId, credentialIdentity); + }); + + const historyMessages = Effect.fnUntraced(function* ( + threadId: ProviderAdapterV2EnsureThreadInput["threadId"], + binding: HermesSessionBinding, + history: HermesGatewaySessionHistoryResult, + ) { + const createdAt = DateTime.makeUnsafe(binding.createdAt); + const imported = yield* options.repository.getSessionImportByStoredIdentity({ + providerInstanceId: binding.providerInstanceId, + profileKey: binding.profileKey, + projectId: binding.projectId, + storedSessionKey: binding.storedSessionKey, + }); + return yield* Effect.forEach( + history.messages, + Effect.fnUntraced(function* (message, ordinal) { + const text = message.text ?? ""; + if (!text) return []; + const nativeId = + message.message_id ?? + stableDigest( + options.instanceId, + options.settings.profileKey, + binding.storedSessionKey, + ordinal, + message.role, + text, + ); + // Imported transport messages need sender/envelope cleanup, while + // every assistant/tool history message may contain Hermes' native + // MEDIA: output protocol (including sessions created in T3 Work). + const normalized = + Option.isSome(imported) || message.role === "assistant" || message.role === "tool" + ? yield* normalizeHermesHistoryMessage({ + role: message.role, + text, + resolveMedia: (media, index) => + options.resolveHistoryMedia?.({ + sourcePath: media.path, + expectedKind: media.kind, + threadId: String(threadId), + stableKey: `${binding.providerInstanceId}:${binding.profileKey}:${binding.storedSessionKey}:${nativeId}:${index}`, + }) ?? Effect.succeed(null), + }) + : { text, attachments: [] }; + if (!normalized.text && normalized.attachments.length === 0) return []; + return [ + { + createdBy: message.role === "user" ? "user" : "agent", + creationSource: "provider", + id: options.idAllocator.derive.messageFromProviderItem({ + driver: HERMES_PROVIDER, + nativeItemId: nativeId, + }), + threadId, + runId: null, + nodeId: null, + role: historyRole(message.role), + text: normalized.text, + attachments: normalized.attachments, + streaming: false, + // The pinned history payload has no timestamps. Preserve its + // authoritative array order in projection queries, which sort + // messages by createdAt before id. + createdAt: DateTime.add(createdAt, { milliseconds: ordinal }), + updatedAt: DateTime.add(createdAt, { milliseconds: ordinal }), + } satisfies OrchestrationV2ConversationMessage, + ]; + }), + { concurrency: 1 }, + ).pipe(Effect.map((messages) => messages.flat())); + }); + + const registerState = Effect.fnUntraced(function* ( + binding: HermesSessionBinding, + liveSessionId: string, + lease: HermesOwnerLease, + appThreadId: ProviderAdapterV2EnsureThreadInput["threadId"], + history: HermesGatewaySessionHistoryResult, + externalRunActive: boolean, + titleState?: HermesGatewaySessionTitleResult, + ) { + const now = yield* DateTime.now; + const nativeThreadId = `${options.instanceId}:${options.settings.profileKey}:${binding.storedSessionKey}`; + const providerThread: OrchestrationV2ProviderThread = { + id: options.idAllocator.derive.providerThread({ + driver: HERMES_PROVIDER, + nativeThreadId, + }), + driver: HERMES_PROVIDER, + providerInstanceId: options.instanceId, + providerSessionId: input.providerSessionId, + appThreadId, + ownerNodeId: null, + nativeThreadRef: providerRef(nativeThreadId), + nativeConversationHeadRef: null, + status: externalRunActive ? "active" : "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }; + const messages = yield* historyMessages(appThreadId, binding, history); + const state: HermesThreadState = { + binding, + liveSessionId, + lease, + titleRevision: binding.titleRevision, + title: null, + providerThread, + activeTurn: null, + externalRunActive, + ownershipLost: false, + turns: new Map(), + messages: new Map(messages.map((message) => [String(message.id), message])), + runtimeRequests: new Map(), + }; + statesByProviderThread.set(String(providerThread.id), state); + statesByLiveSession.set(liveSessionId, state); + if (externalRunActive) { + yield* updateSession("running", null); + } + if (titleState !== undefined) { + yield* reconcileTitle(state, titleState); + } + return providerThread; + }); + + const resumeBinding = Effect.fnUntraced(function* ( + binding: HermesSessionBinding, + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + const lease = yield* acquireLease(binding); + const operationId = `hermes:resume:${binding.bindingId}:g${lease.generation}`; + const temporaryState = { + binding, + lease, + } satisfies HermesMutationFence; + const prepared = yield* prepareBoundMutation(temporaryState, { + operationId, + mutationKind: "session_resume", + method: "session.resume", + payloadDigest: stableDigest(binding.storedSessionKey, options.settings.profileKey), + }); + const resumeParams = { + session_id: binding.storedSessionKey, + profile: options.settings.profileKey, + source: "t3-code", + close_on_disconnect: false, + } satisfies HermesGatewaySessionResumeParams; + const resumed = prepared.replay + ? yield* gatewayEffect(() => + client.resumeSession(resumeParams, mutationOptions(operationId)), + ).pipe( + Effect.tap(() => + transitionIntent( + temporaryState, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : yield* settleMutation(temporaryState, operationId, () => + client.resumeSession(resumeParams, mutationOptions(operationId)), + ); + // Hermes revokes an ephemeral MCP lease as part of session.resume. + mcpCredentialByLiveSession.delete(resumed.session_id); + yield* ensureSessionMcp(resumed.session_id, threadInput.threadId); + const authoritativeStatus = yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: resumed.session_id, + profile: options.settings.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: resumed.session_id, + profile: options.settings.profileKey, + }), + ); + const titleState = yield* gatewayEffect(() => + client.readSessionTitle({ session_id: resumed.session_id }), + ); + const recoveredWork = + resumed.running || + hasRecoveredWork(resumed.inflight) || + hasRecoveredWork(resumed.queued) || + isActiveHermesStatus(resumed.status); + const authoritativeRuntimeStatus = hermesSessionRuntimeStatus(authoritativeStatus); + const externalRunActive = isTerminalHermesStatus(authoritativeRuntimeStatus) + ? false + : recoveredWork || isActiveHermesStatus(authoritativeRuntimeStatus); + return yield* registerState( + binding, + resumed.session_id, + lease, + threadInput.threadId, + history, + externalRunActive, + titleState, + ); + }); + + const createBinding = Effect.fnUntraced(function* ( + threadInput: ProviderAdapterV2EnsureThreadInput, + ) { + const operationId = `hermes:create:${options.instanceId}:${threadInput.threadId}`; + const now = DateTime.formatIso(yield* DateTime.now); + const payloadDigest = stableDigest( + options.instanceId, + options.settings.profileKey, + threadInput.threadId, + threadInput.runtimePolicy.cwd, + threadInput.modelSelection.model, + ); + const prepared = yield* options.repository.prepareSessionCreateIntent({ + operationId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(threadInput.threadId), + threadId: String(threadInput.threadId), + method: "session.create", + payloadDigest, + now, + }); + let replay = false; + if (prepared.status === "prepared") { + const admitted = yield* options.repository.transitionSessionCreateIntent({ + operationId, + from: "prepared", + to: "admitted", + now, + }); + if (!admitted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes session.create intent could not be admitted", + }); + } + } else if (prepared.status === "operation_exists") { + const outcome = yield* reconcileIntent(prepared.intent, { + bindingId: null, + mutationKind: "session_create", + method: "session.create", + payloadDigest, + }); + if (outcome.mutation_status === "indeterminate") { + if (prepared.intent.state === "prepared" || prepared.intent.state === "admitted") { + yield* options.repository.transitionSessionCreateIntent({ + operationId, + from: prepared.intent.state, + to: "indeterminate", + now, + }); + } + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes session.create remains indeterminate (${operationId})`, + }); + } + replay = true; + } else { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes session.create cannot be safely resubmitted (${prepared.status})`, + }); + } + const createParams = { + profile: options.settings.profileKey, + source: "t3-code", + cwd: threadInput.runtimePolicy.cwd ?? providerSession.cwd, + close_on_disconnect: false, + // T3 persists this stored identity before the first prompt, so Hermes + // must make the otherwise-lazy empty session resumable as well. + persist_immediately: true, + ...hermesModelOverride(threadInput.modelSelection.model), + ...hermesReasoningOverride(threadInput.modelSelection), + ...hermesFastOverride(threadInput.modelSelection), + } satisfies HermesGatewaySessionCreateParams; + const created = yield* gatewayEffect(() => + client.createSession(createParams, mutationOptions(operationId)), + ).pipe( + Effect.tapError((cause) => + replay + ? Effect.void + : options.repository + .transitionSessionCreateIntent({ + operationId, + from: "admitted", + to: isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + now: DateTime.formatIso(DateTime.nowUnsafe()), + }) + .pipe(Effect.ignore), + ), + ); + const existingIdentity = yield* options.repository.getByStoredIdentity({ + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + storedSessionKey: created.stored_session_id, + }); + if ( + Option.isSome(existingIdentity) && + existingIdentity.value.threadId !== String(threadInput.threadId) + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes stored session is already bound to another T3 thread", + }); + } + const bindingId = `hermes-binding:${stableDigest( + options.instanceId, + options.settings.profileKey, + created.stored_session_id, + )}`; + const bindingCreatedAt = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* options.repository.createBinding({ + bindingId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(threadInput.threadId), + storedSessionKey: created.stored_session_id, + threadId: String(threadInput.threadId), + ...compatibilityFields(compatibility), + reconciliationCursor: null, + reconciliationFingerprint: null, + now: bindingCreatedAt, + createOperationId: operationId, + }); + if (!inserted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes binding creation conflicted with existing durable identity", + }); + } + const loaded = yield* options.repository.getByThreadId(String(threadInput.threadId)); + if (Option.isNone(loaded)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes binding was not readable after creation", + }); + } + const lease = yield* acquireLease(loaded.value); + yield* ensureSessionMcp(created.session_id, threadInput.threadId); + yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: created.session_id, + profile: options.settings.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: created.session_id, + profile: options.settings.profileKey, + }), + ); + const titleState = yield* gatewayEffect(() => + client.readSessionTitle({ session_id: created.session_id }), + ); + return yield* registerState( + loaded.value, + created.session_id, + lease, + threadInput.threadId, + history, + false, + titleState, + ); + }); + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + unsubscribe(); + for (const [liveSessionId, credentialIdentity] of mcpCredentialByLiveSession) { + const operationId = `hermes:mcp:revoke:${stableDigest( + options.instanceId, + liveSessionId, + credentialIdentity, + )}`; + yield* gatewayEffect(() => + client.revokeSessionMcp(liveSessionId, mutationOptions(operationId)), + ).pipe(Effect.ignore); + } + mcpCredentialByLiveSession.clear(); + for (const state of statesByProviderThread.values()) { + const { now } = leaseTimes(); + yield* options.repository + .releaseOwnerLease({ + bindingId: state.binding.bindingId, + ownerKey: state.lease.ownerKey, + generation: state.lease.generation, + now, + }) + .pipe(Effect.ignore); + } + client.close(); + yield* Queue.shutdown(events); + }), + ); + + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: options.instanceId, + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + get providerSession() { + return providerSession; + }, + events: Stream.fromEffectRepeat(Queue.take(events)), + ensureThread: (threadInput) => + Effect.gen(function* () { + if (threadInput.existingProviderThread !== undefined) { + return yield* runtime.resumeThread({ + providerThread: threadInput.existingProviderThread, + threadId: threadInput.threadId, + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }); + } + const existing = yield* options.repository.getByThreadId(String(threadInput.threadId)); + return Option.isSome(existing) + ? yield* resumeBinding(existing.value, threadInput) + : yield* createBinding(threadInput); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterEnsureThreadError({ + driver: HERMES_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + resumeThread: (threadInput) => + Effect.gen(function* () { + const existingState = stateForProviderThread(threadInput.providerThread); + if (existingState !== undefined) { + const appThreadId = + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId; + yield* ensureSessionMcp(existingState.liveSessionId, appThreadId); + return alignStateProviderThread(existingState, threadInput.providerThread); + } + const appThreadId = + threadInput.threadId ?? threadInput.providerThread.appThreadId ?? input.threadId; + const binding = yield* options.repository.getByThreadId(String(appThreadId)); + if (Option.isNone(binding)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `No Hermes binding exists for thread ${appThreadId}`, + }); + } + const resumedThread = yield* resumeBinding(binding.value, { + threadId: appThreadId, + modelSelection: threadInput.modelSelection ?? input.modelSelection, + runtimePolicy: threadInput.runtimePolicy ?? input.runtimePolicy, + existingProviderThread: threadInput.providerThread, + }); + const resumedState = stateForProviderThread(resumedThread); + return resumedState === undefined + ? resumedThread + : alignStateProviderThread(resumedState, threadInput.providerThread); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterResumeThreadError({ + driver: HERMES_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + startTurn: (turnInput) => + Effect.gen(function* () { + const requestedModel = hermesModelOverride(turnInput.modelSelection.model).model; + if (requestedModel !== undefined && requestedModel !== providerSession.model) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes model switching is not supported in an open session", + }); + } + const state = stateForProviderThread(turnInput.providerThread); + if (state === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes provider thread ${turnInput.providerThread.id} is not resumed`, + }); + } + if (state.activeTurn !== null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes does not support queued or steered prompts", + }); + } + if (state.ownershipLost) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes thread ownership was lost and cannot accept another prompt", + }); + } + if (state.externalRunActive) { + const authoritative = yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: state.liveSessionId, + profile: options.settings.profileKey, + }), + ); + yield* settleExternalRun(state, hermesSessionRuntimeStatus(authoritative)); + if (state.externalRunActive) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes has a recovered external run in progress", + }); + } + } + alignStateProviderThread(state, turnInput.providerThread); + const projectedTitle = turnInput.appThread.title.trim(); + if ( + projectedTitle && + projectedTitle !== state.title && + (turnInput.appThread.titleRevision ?? state.titleRevision) === state.titleRevision + ) { + const titleOperationId = `hermes:title:${state.binding.bindingId}:r${state.titleRevision + 1}:${stableDigest(projectedTitle).slice(0, 12)}`; + const titleSyncResult = yield* Effect.gen(function* () { + const preparedTitle = yield* prepareBoundMutation(state, { + operationId: titleOperationId, + mutationKind: "session_title", + method: "session.title", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + projectedTitle, + "client:t3-code", + ), + }); + const titleState = yield* preparedTitle.replay + ? gatewayEffect(() => + client.updateSessionTitle( + { + session_id: state.liveSessionId, + title: projectedTitle, + origin: "client:t3-code", + }, + mutationOptions(titleOperationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + state, + titleOperationId, + preparedTitle.intentState, + preparedTitle.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : settleMutation(state, titleOperationId, () => + client.updateSessionTitle( + { + session_id: state.liveSessionId, + title: projectedTitle, + origin: "client:t3-code", + }, + mutationOptions(titleOperationId), + ), + ); + yield* reconcileTitle(state, titleState); + }).pipe(Effect.result); + if (titleSyncResult._tag === "Failure") { + yield* Effect.logWarning("Hermes title sync failed; continuing prompt", { + providerThreadId: state.providerThread.id, + operationId: titleOperationId, + cause: titleSyncResult.failure, + }); + } + } + if (turnInput.message.attachments.length > 0 && !options.settings.attachmentsEnabled) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Attachments are disabled for this Hermes instance", + }); + } + if (turnInput.message.attachments.length > 0 && options.readAttachment === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes attachment storage is unavailable", + }); + } + yield* Effect.forEach( + turnInput.message.attachments, + (attachment, attachmentOrdinal) => + Effect.gen(function* () { + const bytes = yield* options.readAttachment!(attachment); + const contentBase64 = Buffer.from(bytes).toString("base64"); + if (attachment.type === "image") { + if (!client.hasCapability("attachments.image")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support image attachments", + }); + } + yield* gatewayEffect(() => + client.attachImageBytes( + { + session_id: state.liveSessionId, + content_base64: contentBase64, + filename: attachment.name, + }, + { + operationId: `hermes:image:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + return; + } + if (attachment.type === "pdf") { + if (!client.hasCapability("attachments.pdf")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support PDF attachments", + }); + } + yield* gatewayEffect(() => + client.attachPdf( + { + session_id: state.liveSessionId, + content_base64: contentBase64, + filename: attachment.name, + }, + { + operationId: `hermes:pdf:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + return; + } + if (!client.hasCapability("attachments.file")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `This Hermes gateway does not support ${attachment.type === "video" ? "video" : "file"} attachments`, + }); + } + yield* gatewayEffect(() => + client.attachFile( + { + session_id: state.liveSessionId, + name: attachment.name, + data_url: `data:${attachment.mimeType};base64,${contentBase64}`, + }, + { + operationId: `hermes:file:${turnInput.attemptId}:${attachmentOrdinal}`, + }, + ), + ); + }), + { concurrency: 1 }, + ); + const operationId = `hermes:prompt:${turnInput.attemptId}`; + const prepared = yield* prepareBoundMutation(state, { + operationId, + mutationKind: "prompt", + method: "prompt.submit", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + turnInput.message.text, + turnInput.attemptId, + ), + }); + const completion = yield* Deferred.make(); + const active: ActiveHermesTurn = { + input: turnInput, + operationId, + completion, + bufferedEvents: [], + providerTurn: null, + assistantNativeId: null, + assistantText: "", + assistantStartedAt: null, + reasoningText: "", + reasoningStartedAt: null, + reasoningHasStreamedDelta: false, + itemOrdinals: new Map(), + nextItemOrdinal: turnInput.providerTurnOrdinal * 100 + 1, + toolsByIdentity: new Map(), + toolsByNativeId: new Map(), + generatingToolsByName: new Map(), + seenEventIds: new Set(), + gatewayRunId: null, + interrupted: false, + finalized: false, + intentState: prepared.intentState, + }; + state.activeTurn = active; + const submitted = yield* gatewayEffect(() => + client.submitPrompt( + { + session_id: state.liveSessionId, + text: turnInput.message.text, + }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tapError((cause) => + prepared.replay + ? Effect.void + : transitionIntent( + state, + operationId, + "admitted", + isIndeterminateGatewayCall(cause) ? "indeterminate" : "rejected", + ).pipe(Effect.ignore), + ), + ); + const terminalReplay = submitted.mutation_status === "completed"; + if (prepared.replay && prepared.intentState === "prepared" && !terminalReplay) { + yield* transitionIntent(state, operationId, "prepared", "admitted"); + active.intentState = "admitted"; + } + const nativeRunId = + submitted.run_id ?? stableDigest(state.binding.storedSessionKey, operationId, "run"); + active.gatewayRunId = submitted.run_id ?? null; + const startedAt = yield* DateTime.now; + const providerTurn: OrchestrationV2ProviderTurn = { + id: options.idAllocator.derive.providerTurn({ + driver: HERMES_PROVIDER, + nativeTurnId: nativeRunId, + }), + providerThreadId: state.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: providerRef( + nativeRunId, + submitted.run_id === undefined ? "weak" : "strong", + ), + ordinal: turnInput.providerTurnOrdinal, + status: "running", + startedAt, + completedAt: null, + }; + active.providerTurn = providerTurn; + active.assistantNativeId = submitted.assistant_message_id ?? null; + state.turns.set(String(providerTurn.id), providerTurn); + yield* emit({ + type: "provider_turn.updated", + driver: HERMES_PROVIDER, + threadId: turnInput.threadId, + providerTurn, + }); + yield* updateThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, + }); + yield* updateSession("running", null); + for (const buffered of active.bufferedEvents.splice(0)) { + yield* handleGatewayEvent(buffered); + } + if (terminalReplay && !active.finalized) { + if (submitted.status === "error") { + yield* finalizeTurn(state, "failed", "Hermes reported a turn error."); + } else { + yield* finalizeTurn( + state, + submitted.status === "interrupted" ? "interrupted" : "completed", + ); + } + } + }).pipe( + Effect.tapError(() => + Effect.sync(() => { + const state = stateForProviderThread(turnInput.providerThread); + if (state?.activeTurn?.providerTurn === null) state.activeTurn = null; + }), + ), + Effect.mapError( + (cause) => + new ProviderAdapterTurnStartError({ + driver: HERMES_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + steerTurn: (steerInput) => + new ProviderAdapterSteerRunUnsupportedError({ + driver: HERMES_PROVIDER, + providerThreadId: steerInput.providerThread.id, + }), + interruptTurn: (interruptInput) => + Effect.gen(function* () { + const state = stateForProviderThread(interruptInput.providerThread); + const active = state?.activeTurn; + if (state === undefined || active == null || active.providerTurn === null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes provider turn is not active", + }); + } + const operationId = `hermes:interrupt:${interruptInput.providerTurnId}`; + const prepared = yield* prepareBoundMutation(state, { + operationId, + mutationKind: "interrupt", + method: "session.interrupt", + payloadDigest: stableDigest( + state.binding.storedSessionKey, + interruptInput.providerTurnId, + ), + }); + active.interrupted = true; + if (prepared.replay) { + yield* gatewayEffect(() => + client.interruptSession( + { session_id: state.liveSessionId }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + state, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ); + } else { + yield* settleMutation(state, operationId, () => + client.interruptSession( + { session_id: state.liveSessionId }, + mutationOptions(operationId), + ), + ); + } + const terminal = yield* Deferred.await(active.completion).pipe( + Effect.timeoutOption(INTERRUPT_TERMINAL_TIMEOUT), + ); + if (Option.isNone(terminal)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes interrupt did not produce a terminal event", + }); + } + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterInterruptError({ + driver: HERMES_PROVIDER, + providerThreadId: interruptInput.providerThread.id, + providerTurnId: interruptInput.providerTurnId, + cause, + }), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.gen(function* () { + const pending = pendingRuntimeRequests.get(String(requestInput.requestId)); + if (pending === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `No pending Hermes runtime request ${requestInput.requestId}.`, + }); + } + const operationId = `hermes:runtime-response:${requestInput.requestId}`; + if (pending.kind === "approval") { + if (requestInput.decision === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes approval ${requestInput.requestId} requires a decision.`, + }); + } + const result = yield* gatewayEffect(() => + client.respondToApproval( + { + session_id: pending.state.liveSessionId, + choice: hermesApprovalChoice(requestInput.decision!), + }, + mutationOptions(operationId), + ), + ); + if (!result.resolved) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: + "Hermes no longer has a live approval for this session; the response was not applied.", + }); + } + } else { + if (requestInput.answers === undefined || pending.nativeRequestId === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes clarification ${requestInput.requestId} requires an answer.`, + }); + } + const answer = hermesClarificationAnswer( + requestInput.answers, + pending.questionId ?? pending.nativeRequestId, + ); + if (answer === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Hermes clarification ${requestInput.requestId} received no usable answer.`, + }); + } + const result = yield* gatewayEffect(() => + client.respondToClarification( + { request_id: pending.nativeRequestId!, answer }, + mutationOptions(operationId), + ), + ); + if (result.status === "expired") { + yield* settleRuntimeRequest(pending, "cancelled"); + return; + } + } + yield* settleRuntimeRequest(pending, "resolved"); + yield* updateSession("running", null); + }).pipe( + Effect.mapError((cause) => + isProviderAdapterRuntimeRequestResponseError(cause) + ? cause + : new ProviderAdapterRuntimeRequestResponseError({ + driver: HERMES_PROVIDER, + requestId: requestInput.requestId, + cause, + }), + ), + ), + readThreadSnapshot: (snapshotInput) => + Effect.gen(function* () { + const state = stateForProviderThread(snapshotInput.providerThread); + if (state === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes thread must be resumed before reading its snapshot", + }); + } + yield* gatewayEffect(() => + client.readSessionStatus({ + session_id: state.liveSessionId, + profile: options.settings.profileKey, + }), + ); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: state.liveSessionId, + profile: options.settings.profileKey, + }), + ); + const messages = yield* historyMessages( + state.providerThread.appThreadId ?? input.threadId, + state.binding, + history, + ); + for (const message of messages) { + state.messages.set(String(message.id), message); + } + return { + providerThread: state.providerThread, + providerTurns: [...state.turns.values()], + messages: [...state.messages.values()], + runtimeRequests: [...state.runtimeRequests.values()], + }; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterReadThreadSnapshotError({ + driver: HERMES_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + rollbackThread: (rollbackInput) => + new ProviderAdapterRollbackThreadError({ + driver: HERMES_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause: new Error("Hermes rollback is not supported."), + }), + forkThread: (forkInput) => + Effect.gen(function* () { + if (!client.hasCapability("session.branch.latest")) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "This Hermes gateway does not support latest-head native branches", + }); + } + const source = stateForProviderThread(forkInput.sourceProviderThread); + if (source === undefined) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes source thread must be resumed before branching", + }); + } + if (source.activeTurn !== null || source.externalRunActive) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes can branch only from an idle latest transcript head", + }); + } + const existing = yield* options.repository.getByThreadId( + String(forkInput.targetThreadId), + ); + if (Option.isSome(existing)) { + const resumed = yield* resumeBinding(existing.value, { + threadId: forkInput.targetThreadId, + modelSelection: forkInput.modelSelection ?? input.modelSelection, + runtimePolicy: forkInput.runtimePolicy ?? input.runtimePolicy, + }); + const resumedState = stateForProviderThread(resumed); + if (resumedState === undefined) return resumed; + yield* updateThread(resumedState, { + ownerNodeId: forkInput.ownerNodeId ?? null, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + }); + return resumedState.providerThread; + } + + const operationId = `hermes:branch:${source.binding.bindingId}:${forkInput.targetThreadId}`; + const prepared = yield* prepareBoundMutation(source, { + operationId, + mutationKind: "session_branch", + method: "session.branch", + payloadDigest: stableDigest( + source.binding.storedSessionKey, + forkInput.targetThreadId, + "latest_only", + ), + }); + const branched = prepared.replay + ? yield* gatewayEffect(() => + client.branchSession( + { session_id: source.liveSessionId }, + mutationOptions(operationId), + ), + ).pipe( + Effect.tap(() => + transitionIntent( + source, + operationId, + prepared.intentState, + prepared.intentState === "admitted" ? "confirmed" : "reconciled", + ), + ), + ) + : yield* settleMutation(source, operationId, () => + client.branchSession( + { session_id: source.liveSessionId }, + mutationOptions(operationId), + ), + ); + if ( + branched.parent !== source.binding.storedSessionKey || + branched.boundary.mode !== "latest_only" || + branched.boundary.exact !== false + ) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes returned an unsupported branch boundary", + }); + } + const bindingId = `hermes-binding:${stableDigest( + options.instanceId, + options.settings.profileKey, + branched.stored_session_id, + )}`; + const now = DateTime.formatIso(yield* DateTime.now); + const inserted = yield* options.repository.createBinding({ + bindingId, + providerInstanceId: options.instanceId, + profileKey: options.settings.profileKey, + projectId: String(forkInput.targetThreadId), + storedSessionKey: branched.stored_session_id, + threadId: String(forkInput.targetThreadId), + ...compatibilityFields(compatibility), + reconciliationCursor: null, + reconciliationFingerprint: null, + parentBindingId: source.binding.bindingId, + branchBoundaryMode: branched.boundary.mode, + branchBoundaryMessageId: branched.boundary.message_id, + branchBoundaryMessageCount: branched.boundary.message_count, + now, + }); + if (!inserted) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes child binding conflicted with an existing durable identity", + }); + } + const childBinding = yield* options.repository.getByThreadId( + String(forkInput.targetThreadId), + ); + if (Option.isNone(childBinding)) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: "Hermes child binding was not readable after native branching", + }); + } + const lease = yield* acquireLease(childBinding.value); + yield* ensureSessionMcp(branched.session_id, forkInput.targetThreadId); + const history = yield* gatewayEffect(() => + client.readSessionHistory({ + session_id: branched.session_id, + profile: options.settings.profileKey, + }), + ); + const titleState = yield* gatewayEffect(() => + client.readSessionTitle({ session_id: branched.session_id }), + ); + const child = yield* registerState( + childBinding.value, + branched.session_id, + lease, + forkInput.targetThreadId, + history, + false, + titleState, + ); + const childState = stateForProviderThread(child); + if (childState === undefined) return child; + yield* updateThread(childState, { + ownerNodeId: forkInput.ownerNodeId ?? null, + forkedFrom: { + providerThreadId: forkInput.sourceProviderThread.id, + ...(forkInput.providerTurnId === undefined + ? {} + : { providerTurnId: forkInput.providerTurnId }), + }, + }); + return childState.providerThread; + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterForkThreadError({ + driver: HERMES_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause, + }), + ), + ), + }; + return runtime; + }), + }; +} + +export type HermesServeAdapterV2DriverEnv = + | FileSystem.FileSystem + | IdAllocatorV2 + | HermesSessionBindingRepository + | ServerConfig; + +export const makeHermesServeAdapterV2Driver = Effect.fn("makeHermesServeAdapterV2Driver")( + function* ( + input: ProviderAdapterDriverCreateInput, + options: { readonly connectionRuntime?: HermesServeRuntimeShape } = {}, + ) { + const idAllocator = yield* IdAllocatorV2; + const repository = yield* HermesSessionBindingRepository; + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* ServerConfig; + const hostPlatform = yield* HostProcessPlatform; + const configuredHermesHome = input.environment.find( + (variable) => variable.name === "HERMES_HOME" && variable.value.trim().length > 0, + )?.value; + const configuredMediaRoots = input.environment + .find( + (variable) => + variable.name === "HERMES_MEDIA_ALLOW_DIRS" && variable.value.trim().length > 0, + ) + ?.value.split(hostPlatform === "win32" ? ";" : ":") + .flatMap((chunk) => chunk.split(",")) + .map((root) => root.trim()) + .filter(Boolean); + const approvedHistoryMediaRoots = hermesHistoryMediaRoots({ + hermesHome: configuredHermesHome ?? process.env.HERMES_HOME, + profileKey: input.config.profileKey, + extraRoots: configuredMediaRoots, + }); + const token = resolveHermesGatewayToken(input.environment); + return makeHermesServeAdapterV2({ + instanceId: input.instanceId, + settings: input.config, + enabled: input.enabled, + authToken: token, + remotePairingToken: resolveHermesRemotePairingToken(input.environment), + remoteTlsCertificateSha256: resolveHermesRemoteTlsCertificateSha256(input.environment), + ...(options.connectionRuntime === undefined + ? {} + : { connectionRuntime: options.connectionRuntime }), + idAllocator, + repository, + readAttachment: (attachment) => + Effect.gen(function* () { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (attachmentPath === null) { + return yield* new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Invalid Hermes attachment id '${attachment.id}'`, + }); + } + return yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProtocolError({ + driver: HERMES_PROVIDER, + detail: `Failed to read Hermes attachment '${attachment.id}'`, + payload: cause, + }), + ), + ); + }), + resolveHistoryMedia: ({ sourcePath, expectedKind, threadId, stableKey }) => + persistHermesHistoryMedia({ + sourcePath, + expectedKind, + approvedRoots: approvedHistoryMediaRoots, + attachmentsDir: serverConfig.attachmentsDir, + threadId, + stableKey, + }), + }); + }, +); + +export const HermesServeAdapterV2Driver: ProviderAdapterDriver< + HermesSettings, + HermesServeAdapterV2DriverEnv +> = { + driverKind: HERMES_DRIVER_KIND, + configSchema: HermesSettings, + defaultConfig: () => DEFAULT_HERMES_SETTINGS, + create: Effect.fn("HermesServeAdapterV2Driver.create")( + (input: ProviderAdapterDriverCreateInput) => + makeHermesServeAdapterV2Driver(input), + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: HERMES_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Hermes adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts new file mode 100644 index 00000000000..fb65489924d --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2 } from "../builtInProviderAdapterDrivers.ts"; +import { OPENCLAW_DRIVER_KIND, OpenClawAdapterV2Driver } from "./OpenClawAdapterV2.ts"; + +describe("OpenClawAdapterV2", () => { + it("registers the standard ACP adapter flavor", () => { + expect(BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2.has(OPENCLAW_DRIVER_KIND)).toBe(true); + expect(OpenClawAdapterV2Driver.driverKind).toBe("openclaw"); + expect(OpenClawAdapterV2Driver.defaultConfig()).toEqual({ + enabled: true, + binaryPath: "openclaw", + url: "", + tokenFile: "", + passwordFile: "", + session: "", + resetSession: false, + customModels: [], + }); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts new file mode 100644 index 00000000000..b8be514d176 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/OpenClawAdapterV2.ts @@ -0,0 +1,139 @@ +import { + defaultInstanceIdForDriver, + OpenClawSettings, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import { ServerConfig } from "../../config.ts"; +import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts"; +import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { makeOpenClawRuntime } from "../../provider/acp/OpenClawSupport.ts"; +import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts"; +import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts"; +import { IdAllocatorV2 } from "../IdAllocator.ts"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "../ProviderAdapterDriver.ts"; +import { + AcpProviderCapabilitiesV2, + makeAcpAdapterV2, + type AcpAdapterV2RuntimeInput, +} from "./AcpAdapterV2.ts"; + +export const OPENCLAW_PROVIDER = ProviderDriverKind.make("openclaw"); +export const OPENCLAW_DRIVER_KIND = OPENCLAW_PROVIDER; +export const OPENCLAW_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(OPENCLAW_DRIVER_KIND); + +const DEFAULT_OPENCLAW_SETTINGS = Schema.decodeSync(OpenClawSettings)({}); + +export interface OpenClawAdapterV2Options { + readonly instanceId: Parameters[0]["instanceId"]; + readonly settings: OpenClawSettings; + readonly environment: NodeJS.ProcessEnv; + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly crypto: Crypto.Crypto; + readonly fileSystem: FileSystem.FileSystem; + readonly idAllocator: IdAllocatorV2["Service"]; + readonly serverConfig: ServerConfig["Service"]; + readonly nativeLogging?: Parameters[0]["nativeLogging"]; + readonly makeRuntime?: ( + input: AcpAdapterV2RuntimeInput, + ) => Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope + >; + readonly assertComplete?: Effect.Effect; +} + +export function makeOpenClawAdapterV2(options: OpenClawAdapterV2Options) { + return makeAcpAdapterV2({ + instanceId: options.instanceId, + flavor: { + driver: OPENCLAW_PROVIDER, + capabilities: AcpProviderCapabilitiesV2, + makeRuntime: + options.makeRuntime ?? + ((input) => + makeOpenClawRuntime({ + ...input, + openClawSettings: options.settings, + environment: options.environment, + childProcessSpawner: options.childProcessSpawner, + })), + ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), + }, + crypto: options.crypto, + fileSystem: options.fileSystem, + idAllocator: options.idAllocator, + serverConfig: options.serverConfig, + ...(options.nativeLogging === undefined ? {} : { nativeLogging: options.nativeLogging }), + }); +} + +export type OpenClawAdapterV2DriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | IdAllocatorV2 + | ProviderEventLoggers + | ServerConfig; + +export const OpenClawAdapterV2Driver: ProviderAdapterDriver< + OpenClawSettings, + OpenClawAdapterV2DriverEnv +> = { + driverKind: OPENCLAW_DRIVER_KIND, + configSchema: OpenClawSettings, + defaultConfig: (): OpenClawSettings => DEFAULT_OPENCLAW_SETTINGS, + create: Effect.fn("OpenClawAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocatorV2; + const providerEventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; + const makeNativeLogger = yield* makeAcpNativeLoggerFactory(); + return makeOpenClawAdapterV2({ + instanceId: input.instanceId, + settings: { ...input.config, enabled: input.enabled }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + childProcessSpawner, + crypto, + fileSystem, + idAllocator, + serverConfig, + nativeLogging: (threadId) => + makeNativeLogger({ + nativeEventLogger: providerEventLoggers.native, + provider: OPENCLAW_PROVIDER, + threadId, + }), + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: OPENCLAW_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create OpenClaw ACP adapter.", + cause, + }), + ), + ), + ), +}; diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts index 583e16e2ec7..a2283e6d30d 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.test.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts @@ -16,16 +16,24 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import { CheckpointRollbackServiceV2 } from "./CheckpointRollbackService.ts"; -import type { OrchestrationEffectV2 } from "./EffectOutbox.ts"; +import { EffectOutboxV2, type OrchestrationEffectV2 } from "./EffectOutbox.ts"; import { executorLayer, isNonRetryableProviderTurnControlFailure, + isNonRetryableProviderTurnStartPrerequisiteFailure, + layerWithOptions as effectWorkerLayerWithOptions, + OrchestrationEffectExecutionError, OrchestrationEffectExecutorV2, + OrchestrationEffectWorkerV2, } from "./EffectWorker.ts"; import { RunFinalizationService } from "./RunFinalizationService.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { ProviderTurnControlServiceV2 } from "./ProviderTurnControlService.ts"; -import { ProviderTurnStartError, ProviderTurnStartServiceV2 } from "./ProviderTurnStartService.ts"; +import { + canTerminalizeProviderTurnStartFailure, + ProviderTurnStartError, + ProviderTurnStartServiceV2, +} from "./ProviderTurnStartService.ts"; import { RuntimeRequestServiceV2 } from "./RuntimeRequestService.ts"; const threadId = ThreadId.make("thread:effect-worker-restart"); @@ -119,6 +127,7 @@ function makeExecutorLayer(input: { }); } }), + failPermanently: () => record("fail-permanently"), }), ), Layer.succeed( @@ -171,6 +180,24 @@ it("does not retry pure interrupt races where the turn is already gone", () => { ); }); +it("classifies checkpoint baseline prerequisites as non-retryable start failures", () => { + assert.isTrue( + isNonRetryableProviderTurnStartPrerequisiteFailure( + "provider-turn.start", + "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + ), + ); + assert.isFalse( + isNonRetryableProviderTurnStartPrerequisiteFailure( + "provider-turn.interrupt", + "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + ), + ); + assert.isTrue(canTerminalizeProviderTurnStartFailure("starting")); + assert.isTrue(canTerminalizeProviderTurnStartFailure("running")); + assert.isFalse(canTerminalizeProviderTurnStartFailure("completed")); +}); + it.effect("detaches a handed-off session only after the old turn terminalizes", () => Effect.gen(function* () { const now = yield* DateTime.now; @@ -217,3 +244,109 @@ it.effect("safely retries after replacement cleanup succeeds and start fails", ( ]); }), ); + +it.effect("routes exhausted restart effects to permanent start failure handling", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + + yield* Effect.gen(function* () { + const executor = yield* OrchestrationEffectExecutorV2; + assert.isDefined(executor.handlePermanentFailure); + yield* executor.handlePermanentFailure?.(restartEffect(now, { type: "detach" })); + }).pipe(Effect.provide(makeExecutorLayer({ events }))); + + assert.deepEqual(yield* Ref.get(events), ["fail-permanently"]); + }), +); + +it.effect("projects permanent failure before failing an exhausted outbox effect", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = { + ...restartEffect(now, { type: "detach" }), + attemptCount: 5, + }; + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + record("execute").pipe( + Effect.andThen( + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "simulated transport failure", + }), + ), + ), + ), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "permanent-failure-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + assert.deepEqual(yield* Ref.get(events), ["execute", "terminalize", "outbox-fail"]); + }), +); + +it.effect("terminalizes a non-retryable checkpoint prerequisite on its first attempt", () => + Effect.gen(function* () { + const now = yield* DateTime.now; + const events = yield* Ref.make>([]); + const record = (event: string) => Ref.update(events, (current) => [...current, event]); + const effect = restartEffect(now, { type: "detach" }); + const outboxLayer = Layer.mock(EffectOutboxV2)({ + claimNext: () => Effect.succeed(Option.some(effect)), + get: () => Effect.succeed(Option.some(effect)), + awaitCancellation: () => Effect.never, + clearCancellation: () => Effect.void, + fail: () => record("outbox-fail").pipe(Effect.as(true)), + }); + const executorLayer = Layer.succeed( + OrchestrationEffectExecutorV2, + OrchestrationEffectExecutorV2.of({ + execute: () => + Effect.fail( + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause: "CheckpointBaselineCaptureError: Failed to capture checkpoint baseline 0", + }), + ), + handlePermanentFailure: () => record("terminalize"), + }), + ); + const workerLayer = effectWorkerLayerWithOptions({ + workerId: "checkpoint-prerequisite-worker", + maxAttempts: 5, + }).pipe(Layer.provide(Layer.merge(outboxLayer, executorLayer))); + + assert.isTrue( + yield* OrchestrationEffectWorkerV2.pipe( + Effect.flatMap((worker) => worker.runOnce), + Effect.provide(workerLayer), + ), + ); + assert.deepEqual(yield* Ref.get(events), ["terminalize", "outbox-fail"]); + }), +); diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 85c915af3f1..813604aa0c0 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -49,10 +49,24 @@ export function isNonRetryableProviderTurnControlFailure( ); } +export function isNonRetryableProviderTurnStartPrerequisiteFailure( + effectType: string, + errorText: string, +): boolean { + return ( + (effectType === "provider-turn.start" || effectType === "provider-turn.restart") && + (/CheckpointBaselineCaptureError/iu.test(errorText) || + /Failed to capture checkpoint baseline/iu.test(errorText)) + ); +} + export interface OrchestrationEffectExecutorV2Shape { readonly execute: ( effect: OrchestrationEffectV2, ) => Effect.Effect; + readonly handlePermanentFailure?: ( + effect: OrchestrationEffectV2, + ) => Effect.Effect; } export class OrchestrationEffectExecutorV2 extends Context.Service< @@ -280,6 +294,29 @@ export const executorLayer: Layer.Layer< ); } }, + handlePermanentFailure: (effect) => { + switch (effect.request.type) { + case "provider-turn.start": + case "provider-turn.restart": + return providerTurnStart + .failPermanently({ + threadId: effect.threadId, + runId: effect.request.runId, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestrationEffectExecutionError({ + effectId: effect.id, + effectType: effect.request.type, + cause, + }), + ), + ); + default: + return Effect.void; + } + }, }); }), ); @@ -375,19 +412,38 @@ export const layerWithOptions = ( } const error = Cause.pretty(exit.cause); - const nonRetryable = isNonRetryableProviderTurnControlFailure(effect.request.type, error); + const ignorableControlFailure = isNonRetryableProviderTurnControlFailure( + effect.request.type, + error, + ); + const nonRetryableStartFailure = isNonRetryableProviderTurnStartPrerequisiteFailure( + effect.request.type, + error, + ); + const exhausted = !ignorableControlFailure && effect.attemptCount >= maxAttempts; + const permanentFailure = nonRetryableStartFailure || exhausted; yield* Effect.logWarning("Orchestration effect execution failed", { effectId: effect.id, effectType: effect.request.type, attemptCount: effect.attemptCount, - nonRetryable, + nonRetryable: ignorableControlFailure || nonRetryableStartFailure, error, }); + if (permanentFailure && executor.handlePermanentFailure !== undefined) { + yield* executor.handlePermanentFailure(effect).pipe( + Effect.catchCause(() => + Effect.logError("Failed to project permanent orchestration effect failure", { + effectId: effect.id, + effectType: effect.request.type, + }), + ), + ); + } // Prefer succeed for terminal interrupt races so the outbox does not // keep a failed interrupt around; fail only when we must not retry. - const updated = nonRetryable + const updated = ignorableControlFailure ? yield* outbox.succeed({ effectId: effect.id, workerId }) - : effect.attemptCount >= maxAttempts + : permanentFailure ? yield* outbox.fail({ effectId: effect.id, workerId, error }) : yield* outbox.retry({ effectId: effect.id, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ea49891f410..95e2db8ae34 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -142,6 +142,15 @@ export interface OrchestratorV2DispatchResult { export interface OrchestratorV2Shape { readonly resumeQueuedRuns: Effect.Effect; + /** + * Lazily materializes a provider-owned conversation snapshot into the durable + * T3 projection. Snapshot entities have provider-stable ids, so retries only + * append entities that are not already projected. + */ + readonly hydrateProviderThreadSnapshot: (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + }) => Effect.Effect; readonly dispatch: ( command: OrchestrationV2Command, ) => Effect.Effect; @@ -857,6 +866,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio archivedAt: null, settledOverride: null, settledAt: null, + pinnedAt: null, + workInboxRole: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -937,6 +948,27 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is archived.`, }); } + if ( + thread.workInboxRole === "main" && + (command.type === "thread.settle" || command.type === "thread.snooze") + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is the Work inbox main thread and cannot be settled or snoozed.`, + }); + } + if ( + command.type === "thread.metadata.update" && + command.pinned === true && + (thread.settledOverride === "settled" || thread.snoozedUntil != null) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} must be active before it can be pinned.`, + }); + } if ( command.type === "thread.settle" && (projection.runs.some((run) => @@ -1017,6 +1049,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, settledOverride: "settled", settledAt: alreadySettled ? thread.settledAt : now, + pinnedAt: null, updatedAt: alreadySettled ? thread.updatedAt : now, }; } @@ -1039,6 +1072,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil, snoozedAt: existingSnoozedAt ?? now, + pinnedAt: null, updatedAt: existingSnoozedAt === null ? now : thread.updatedAt, }; } @@ -1057,6 +1091,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(command.title === undefined ? {} : { title: command.title }), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), + ...(command.pinned === undefined + ? {} + : { pinnedAt: command.pinned || thread.workInboxRole === "main" ? now : null }), + ...(command.workInboxRole === undefined + ? {} + : { + workInboxRole: command.workInboxRole, + ...(command.workInboxRole === "main" + ? { + pinnedAt: thread.pinnedAt ?? now, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + } + : {}), + }), + ...(command.clearTimeline === true ? { timelineClearedAt: now } : {}), updatedAt: now, }; case "thread.runtime-mode.set": @@ -5632,6 +5684,103 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const dispatchWithReceipt = (command: OrchestrationV2Command) => threadDispatch.withLock(commandThreadId(command), dispatchWithReceiptEffect(command)); + const hydrateProviderThreadSnapshot = (input: { + readonly threadId: ThreadId; + readonly providerInstanceId: ProviderInstanceId; + }) => + threadDispatch.withLock( + input.threadId, + Effect.gen(function* () { + const threadId = input.threadId; + const projection = yield* projectionStore.getThreadProjection(threadId); + const modelSelection = projection.thread.modelSelection; + if (modelSelection.instanceId !== input.providerInstanceId) { + return; + } + const adapter = yield* providerAdapters.get(modelSelection.instanceId); + const existingProviderThread = rootProviderThreadsForProvider( + projection, + modelSelection.instanceId, + )[0]; + const providerSessionId = + existingProviderThread?.providerSessionId ?? + (yield* providerSessionIdFor({ + adapter, + providerInstanceId: modelSelection.instanceId, + threadId, + })); + const resolvedRuntimePolicy = yield* runtimePolicy.resolve({ + thread: projection.thread, + modelSelection, + }); + const session = yield* providerSessions.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: resolvedRuntimePolicy, + }); + const providerThread = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: resolvedRuntimePolicy, + ...(existingProviderThread === undefined ? {} : { existingProviderThread }), + }); + const snapshot = yield* session.readThreadSnapshot({ providerThread }); + + // Opening the runtime persists its provider-session attachment, so + // compare against a fresh projection before appending snapshot rows. + const current = yield* projectionStore.getThreadProjection(threadId); + const projectedMessageIds = new Set(current.messages.map((message) => String(message.id))); + const missingMessages = snapshot.messages.filter( + (message) => !projectedMessageIds.has(String(message.id)), + ); + const projectedProviderThread = current.providerThreads.find( + (candidate) => candidate.id === snapshot.providerThread.id, + ); + if (projectedProviderThread !== undefined && missingMessages.length === 0) { + return; + } + + const occurredAt = yield* DateTime.now; + const events: Array = []; + if (projectedProviderThread === undefined) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "provider-thread.updated", + threadId, + providerInstanceId: modelSelection.instanceId, + driver: adapter.driver, + occurredAt, + payload: snapshot.providerThread, + }); + } + for (const message of missingMessages) { + events.push({ + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "message.updated", + threadId, + ...(message.runId === null ? {} : { runId: message.runId }), + ...(message.nodeId === null ? {} : { nodeId: message.nodeId }), + providerInstanceId: modelSelection.instanceId, + driver: adapter.driver, + occurredAt, + payload: message, + }); + } + if (events.length > 0) { + yield* eventSink.write({ events }); + } + }).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: input.threadId, + cause, + }), + ), + ), + ); + yield* eventSink.stream().pipe( Stream.filter( (stored) => @@ -5664,6 +5813,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return OrchestratorV2.of({ resumeQueuedRuns, + hydrateProviderThreadSnapshot, dispatch: dispatchWithReceipt, getThreadProjection: (threadId) => projectionStore @@ -5743,6 +5893,13 @@ export const layerUnavailable: Layer.Layer = Layer.succeed( cause: "Orchestration V2 live runtime is not configured.", }), ), + hydrateProviderThreadSnapshot: (input) => + Effect.fail( + new OrchestratorProjectionError({ + threadId: input.threadId, + cause: "Orchestration V2 live runtime is not configured.", + }), + ), dispatch: (command) => Effect.fail( new OrchestratorDispatchError({ diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 63cc7837cc4..5e31d1fcb6a 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -190,6 +190,21 @@ export function applyToProjection( ...base, thread: event.payload, }; + case "thread.title-reconciled": { + const currentRevision = projection.thread.titleRevision ?? 0; + if (event.payload.revision <= currentRevision) { + return projection; + } + return { + ...base, + thread: { + ...base.thread, + title: event.payload.title, + titleRevision: event.payload.revision, + titleOrigin: event.payload.origin, + }, + }; + } case "run.created": case "run.updated": return withLocalVisibleTurnItems({ @@ -938,6 +953,8 @@ function shellFromState(input: { id: input.state.thread.id, projectId: input.state.thread.projectId, title: input.state.thread.title, + titleRevision: input.state.thread.titleRevision, + titleOrigin: input.state.thread.titleOrigin, providerInstanceId: input.state.thread.providerInstanceId, modelSelection: input.state.thread.modelSelection, runtimeMode: input.state.thread.runtimeMode, @@ -979,6 +996,9 @@ function shellFromState(input: { archivedAt: input.state.thread.archivedAt, settledOverride: input.state.thread.settledOverride, settledAt: input.state.thread.settledAt, + pinnedAt: input.state.thread.pinnedAt ?? null, + workInboxRole: input.state.thread.workInboxRole ?? null, + timelineClearedAt: input.state.thread.timelineClearedAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, deletedAt: input.state.thread.deletedAt, @@ -1056,6 +1076,35 @@ export const layer: Layer.Layer = `; break; } + case "thread.title-reconciled": { + const rows = yield* sql` + SELECT payload_json + FROM orchestration_v2_projection_threads + WHERE thread_id = ${event.threadId} + LIMIT 1 + `; + const row = rows[0]; + if (row === undefined) break; + const thread = yield* decodeThreadPayload(row.payload_json); + if (event.payload.revision <= (thread.titleRevision ?? 0)) break; + const updatedThread = { + ...thread, + title: event.payload.title, + titleRevision: event.payload.revision, + titleOrigin: event.payload.origin, + updatedAt: event.occurredAt, + }; + const payloadJson = yield* encodeThreadPayload(updatedThread); + yield* sql` + UPDATE orchestration_v2_projection_threads + SET + title = ${event.payload.title}, + updated_at = ${stringField(parseEncodedPayload(payloadJson), "updatedAt")}, + payload_json = ${payloadJson} + WHERE thread_id = ${event.threadId} + `; + break; + } case "run.created": case "run.updated": { const payloadJson = yield* encodeRunPayload(event.payload); @@ -1778,7 +1827,8 @@ export const layer: Layer.Layer = event.type !== "thread.runtime-mode-updated" && event.type !== "thread.interaction-mode-updated" && event.type !== "thread.model-selection-updated" && - event.type !== "thread.provider-switched" + event.type !== "thread.provider-switched" && + event.type !== "thread.title-reconciled" ) { const rows = yield* sql` SELECT payload_json diff --git a/apps/server/src/orchestration-v2/ProviderAdapter.ts b/apps/server/src/orchestration-v2/ProviderAdapter.ts index d8f3595bcf3..72853b90b43 100644 --- a/apps/server/src/orchestration-v2/ProviderAdapter.ts +++ b/apps/server/src/orchestration-v2/ProviderAdapter.ts @@ -77,6 +77,14 @@ export const ProviderAdapterV2Event = Schema.Union([ driver: ProviderDriverKind, appThread: OrchestrationV2AppThread, }), + Schema.Struct({ + type: Schema.Literal("app_thread.title_reconciled"), + driver: ProviderDriverKind, + threadId: ThreadId, + title: Schema.String, + revision: Schema.Number, + origin: Schema.String, + }), Schema.Struct({ type: Schema.Literal("provider_session.updated"), driver: ProviderDriverKind, diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts index 91e4a974390..012a9fbca66 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.ts @@ -147,6 +147,18 @@ export const layer: Layer.Layer + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-no-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.deepEqual(yield* Ref.get(mcpConfigs), [undefined]); + assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe( + Effect.provide( + makeTestLayer({ + state, + idleTimeoutMs: 1_000, + capabilities: NoMcpCapabilities, + mcpConfigs, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 scopes non-full-access credentials away from worktree mutation", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const mcpConfigs = yield* Ref.make< + ReadonlyArray + >([]); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSinkV2; + const idAllocator = yield* IdAllocatorV2; + const manager = yield* ProviderSessionManagerV2; + const registry = yield* McpSessionRegistry.McpSessionRegistry; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-provider-session-manager-scoped-mcp"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy: { ...runtimePolicy, runtimeMode: "approval-required" }, + }); + + const config = (yield* Ref.get(mcpConfigs))[0]; + assert.deepEqual(config?.capabilities, ["orchestration", "preview"]); + const token = config?.authorizationHeader.replace(/^Bearer\s+/, ""); + assert.isDefined(token); + assert.deepEqual( + (yield* registry.resolve(token!, registry.audience))?.capabilities, + new Set(["preview", "orchestration"]), + ); + yield* manager.close(providerSessionId); + }); + + yield* effect.pipe( + Effect.provide(makeTestLayer({ state, idleTimeoutMs: 1_000, mcpConfigs })), + ); + }), +); + it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persistence fails", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -814,12 +916,12 @@ it.effect("ProviderSessionManagerV2 revokes MCP credentials when release persist const captured = (yield* Ref.get(mcpConfigs))[0]; const token = captured?.authorizationHeader.replace(/^Bearer\s+/, ""); assert.isDefined(token); - assert.isDefined(yield* registry.resolve(token!)); + assert.isDefined(yield* registry.resolve(token!, registry.audience)); const closeError = yield* manager.close(providerSessionId).pipe(Effect.flip); assert.equal(closeError._tag, "ProviderSessionCloseError"); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); - assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(yield* registry.resolve(token!, registry.audience)); }); yield* effect.pipe( @@ -889,7 +991,10 @@ it.effect("ProviderSessionManagerV2 duplicate detach preserves replacement MCP c McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(replacementToken!, registry.audience))?.threadId, + threadId, + ); }); yield* effect.pipe( @@ -965,7 +1070,10 @@ it.effect( McpProviderSession.readMcpProviderSession(threadId)?.providerSessionId, replacement?.providerSessionId, ); - assert.equal((yield* registry.resolve(replacementToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(replacementToken!, registry.audience))?.threadId, + threadId, + ); }); yield* effect.pipe( @@ -1021,7 +1129,7 @@ it.effect( // process's MCP client keeps using the credential it was started with. yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, threadId, "detach must not revoke the credential the live provider process still holds", ); @@ -1040,11 +1148,14 @@ it.effect( original?.providerSessionId, "re-attach must reuse the existing credential, not rotate it", ); - assert.equal((yield* registry.resolve(originalToken!))?.threadId, threadId); + assert.equal( + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, + threadId, + ); // Releasing the session (provider process gone) still revokes. yield* manager.close(providerSessionId); - assert.isUndefined(yield* registry.resolve(originalToken!)); + assert.isUndefined(yield* registry.resolve(originalToken!, registry.audience)); }); yield* effect.pipe( @@ -1097,13 +1208,13 @@ it.effect( const rotated = McpProviderSession.readMcpProviderSession(threadId); assert.isDefined(rotated); const rotatedToken = rotated?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(yield* registry.resolve(rotatedToken!)); + assert.isDefined(yield* registry.resolve(rotatedToken!, registry.audience)); // Releasing S2 must revoke C2 even though S1 still carries a stale // record (of dead C1) for the same thread. yield* manager.close(s2); assert.isUndefined( - yield* registry.resolve(rotatedToken!), + yield* registry.resolve(rotatedToken!, registry.audience), "stale record on S1 must not veto revoking S2's rotated credential", ); yield* manager.close(s1); @@ -1164,7 +1275,7 @@ it.effect( "the credential the adapter was configured with must remain current", ); assert.equal( - (yield* registry.resolve(originalToken!))?.threadId, + (yield* registry.resolve(originalToken!, registry.audience))?.threadId, threadId, "the predecessor release must not revoke a credential reserved by an in-flight open", ); @@ -1214,7 +1325,7 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); const issued = (yield* Ref.get(mcpConfigs)).at(-1); const token = issued?.authorizationHeader.replace(/^Bearer\s+/, ""); - assert.isDefined(yield* registry.resolve(token!)); + assert.isDefined(yield* registry.resolve(token!, registry.audience)); // Archive/delete detaches carry revokeMcpCredential: the token must die // with the thread even though the shared provider process lives on. @@ -1224,7 +1335,7 @@ it.effect("ProviderSessionManagerV2 terminal detach revokes the thread's MCP cre detail: "Thread deleted.", revokeMcpCredential: true, }); - assert.isUndefined(yield* registry.resolve(token!)); + assert.isUndefined(yield* registry.resolve(token!, registry.audience)); assert.isUndefined(McpProviderSession.readMcpProviderSession(threadId)); }); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 8eeacd7f685..e36e271acb8 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -24,6 +24,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as McpProviderSession from "../mcp/McpProviderSession.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts"; import { EventSinkV2 } from "./EventSink.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; @@ -315,15 +316,21 @@ export const layerWithOptions = ( const prepareMcpSession = ( threadId: ThreadId, providerInstanceId: ProviderInstanceId, + supportsMcpTools: boolean, + runtimePolicy: ProviderAdapterV2RuntimePolicy, ): Effect.Effect => - options.configureMcp === false + options.configureMcp === false || !supportsMcpTools ? Effect.sync((): PreparedMcpCredential => { - McpProviderSession.clearMcpProviderSession(threadId); return { mcpCredentialId: undefined, issued: false }; }) : mcpPrepareLock.withLock( threadId, Effect.gen(function* () { + const capabilities = new Set([ + "preview", + "orchestration", + ...(runtimePolicy.runtimeMode === "full-access" ? (["worktree"] as const) : []), + ]); // Reuse a still-valid credential for this thread instead of // rotating: long-lived provider processes (codex app-server) // build their MCP client once per conversation and keep using @@ -336,20 +343,25 @@ export const layerWithOptions = ( // revoke the credential between validation and reservation. reserveMcpCredential(threadId, existing.providerSessionId); const rawToken = existing.authorizationHeader.replace(/^Bearer\s+/, ""); - const resolved = yield* mcpSessionRegistry.resolve(rawToken); + const resolved = yield* mcpSessionRegistry.resolve( + rawToken, + mcpSessionRegistry.audience, + ); if ( resolved !== undefined && resolved.threadId === threadId && - resolved.providerInstanceId === providerInstanceId + resolved.providerInstanceId === providerInstanceId && + resolved.capabilities.size === capabilities.size && + [...capabilities].every((capability) => resolved.capabilities.has(capability)) ) { return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); } - yield* mcpSessionRegistry.revokeThread(threadId); - const credential = yield* mcpSessionRegistry.issue({ + const credential = yield* mcpSessionRegistry.rotate({ threadId, providerInstanceId, + capabilities, }); McpProviderSession.setMcpProviderSession(credential.config); reserveMcpCredential(threadId, credential.config.providerSessionId); @@ -961,6 +973,8 @@ export const layerWithOptions = ( readonly providerSessionId: ProviderSessionId; readonly threadId: ThreadId; readonly providerInstanceId: ProviderInstanceId; + readonly supportsMcpTools: boolean; + readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; }) => Effect.suspend(() => { let preparedForCleanup: PreparedMcpCredential | undefined; @@ -974,7 +988,12 @@ export const layerWithOptions = ( return Effect.gen(function* () { const attached = yield* attachThread(input); if (attached) { - const prepared = yield* prepareMcpSession(input.threadId, input.providerInstanceId); + const prepared = yield* prepareMcpSession( + input.threadId, + input.providerInstanceId, + input.supportsMcpTools, + input.runtimePolicy, + ); preparedForCleanup = prepared; if (prepared.mcpCredentialId !== undefined) { const mcpCredentialId = prepared.mcpCredentialId; @@ -1146,6 +1165,8 @@ export const layerWithOptions = ( providerSessionId, threadId: input.threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }), ).pipe( Effect.andThen(runtime.ensureThread(input)), @@ -1179,6 +1200,12 @@ export const layerWithOptions = ( providerSessionId, threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy ?? { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: runtime.providerSession.cwd, + }, }), ).pipe( Effect.andThen( @@ -1211,6 +1238,12 @@ export const layerWithOptions = ( providerSessionId, threadId: input.targetThreadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy ?? { + runtimeMode: "approval-required", + interactionMode: "default", + cwd: runtime.providerSession.cwd, + }, }), ).pipe( Effect.andThen(runtime.forkThread(input)), @@ -1237,6 +1270,8 @@ export const layerWithOptions = ( providerSessionId, threadId: input.threadId, providerInstanceId: runtime.instanceId, + supportsMcpTools: runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }), ).pipe( Effect.andThen(observeActivity(providerSessionId, markBusy(providerSessionId))), @@ -1384,6 +1419,9 @@ export const layerWithOptions = ( providerSessionId: input.providerSessionId, threadId: input.threadId, providerInstanceId: existing.runtime.instanceId, + supportsMcpTools: + existing.runtime.providerSession.capabilities.tools.supportsMcpTools, + runtimePolicy: input.runtimePolicy, }); yield* touchActivity(input.providerSessionId); return existing.exposedRuntime; @@ -1399,9 +1437,21 @@ export const layerWithOptions = ( }), ), ); + const adapterCapabilities = yield* adapter.getCapabilities().pipe( + Effect.mapError( + (cause) => + new ProviderSessionOpenError({ + instanceId: input.modelSelection.instanceId, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); const prepared = yield* prepareMcpSession( input.threadId, input.modelSelection.instanceId, + adapterCapabilities.tools.supportsMcpTools, + input.runtimePolicy, ); const mcpCredentialId = prepared.mcpCredentialId; // The reservation from prepare protects the credential (which diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 3b6f74bf5f7..5511026ab93 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -21,6 +21,7 @@ import { } from "./ContextHandoffService.ts"; import { IdAllocatorV2 } from "./IdAllocator.ts"; import { ProjectionStoreV2 } from "./ProjectionStore.ts"; +import { makeProviderFailure } from "./ProviderFailure.ts"; import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { canRouteRelatedSubagent, RunExecutionServiceV2 } from "./RunExecutionService.ts"; import { RuntimePolicyV2 } from "./RuntimePolicy.ts"; @@ -35,11 +36,21 @@ export class ProviderTurnStartError extends Schema.TaggedErrorClass Effect.Effect; + readonly failPermanently: (input: { + readonly threadId: ThreadId; + readonly runId: RunId; + }) => Effect.Effect; } export class ProviderTurnStartServiceV2 extends Context.Service< @@ -459,6 +470,8 @@ export const layer: Layer.Layer< }), Effect.catchCause(() => Effect.succeed(false)), ), + captureFilesystemCheckpoint: + session.providerSession.capabilities.checkpointing.appCanCheckpointFilesystem, message: { messageId: message.id, text: @@ -477,6 +490,123 @@ export const layer: Layer.Layer< }); }); + const failPermanently = Effect.fn("orchestrationV2.providerTurnStart.failPermanently")( + function* (input: { readonly threadId: ThreadId; readonly runId: RunId }) { + const projection = yield* projectionStore.getThreadProjection(input.threadId); + const run = projection.runs.find((candidate) => candidate.id === input.runId); + if (run === undefined) { + return yield* new ProviderTurnStartError({ + runId: input.runId, + cause: `Run ${input.runId} was not found.`, + }); + } + if (!canTerminalizeProviderTurnStartFailure(run.status)) { + return; + } + const rootNode = projection.nodes.find((candidate) => candidate.id === run.rootNodeId); + const attempt = projection.attempts.find( + (candidate) => candidate.id === run.activeAttemptId, + ); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + ); + if (rootNode === undefined || attempt === undefined || providerThread === undefined) { + return yield* new ProviderTurnStartError({ + runId: input.runId, + cause: `Run ${input.runId} is missing its execution projection state.`, + }); + } + + const now = yield* DateTime.now; + const failure = makeProviderFailure({ + message: "Could not connect to the provider after repeated attempts.", + code: "provider_turn_start_failed", + class: "transport_error", + retryable: false, + }); + const errorItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: providerThread.driver, + nativeItemId: `provider-turn-start-failure:${run.id}`, + }); + const errorItemOrdinal = + Math.max( + run.ordinal * 1_000_000, + ...projection.turnItems + .filter((item) => item.runId === run.id) + .map((item) => item.ordinal), + ) + 1; + yield* eventSink.writeIfRunCurrent({ + threadId: projection.thread.id, + runId: run.id, + activeAttemptId: attempt.id, + expectedStatus: run.status, + events: [ + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run-attempt.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "failed", completedAt: now }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "node.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "failed", completedAt: now }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: errorItemId, + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: errorItemOrdinal, + status: "failed", + title: "Provider failed to start", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "error", + failure, + }, + }, + { + id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }), + type: "run.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "failed", completedAt: now }, + }, + ], + }); + yield* Effect.logWarning("Provider turn start permanently failed", { + threadId: input.threadId, + runId: input.runId, + }); + }, + ); + return ProviderTurnStartServiceV2.of({ start: (input) => start(input).pipe( @@ -486,6 +616,14 @@ export const layer: Layer.Layer< : new ProviderTurnStartError({ runId: input.runId, cause }), ), ), + failPermanently: (input) => + failPermanently(input).pipe( + Effect.mapError((cause) => + isProviderTurnStartError(cause) + ? cause + : new ProviderTurnStartError({ runId: input.runId, cause }), + ), + ), }); }), ); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index 28f2120e599..f8d51f8dfad 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -307,6 +307,73 @@ it.effect("rechecks run ownership immediately before calling the provider", () = }).pipe(Effect.provide(RunExecutionTestLayer)), ); +it.effect("skips Git baseline capture for projectless Hermes runs", () => + Effect.gen(function* () { + const captures = yield* Ref.make(0); + const testLayer = runExecutionServiceLayer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(CheckpointServiceV2)({ + captureBaseline: () => Ref.update(captures, (count) => count + 1), + }), + Layer.mock(EventSinkV2)({}), + idAllocatorLayer, + Layer.mock(ProviderEventIngestorV2)({ ingestNormalized: () => Effect.succeed([]) }), + ServerSettingsService.layerTest(), + ), + ), + ); + const threadId = ThreadId.make("thread:hermes-no-project"); + const runId = RunId.make("run:hermes-no-project"); + const attemptId = RunAttemptId.make("attempt:hermes-no-project"); + const providerInstanceId = ProviderInstanceId.make("hermes"); + const providerThreadId = ProviderThreadId.make("provider-thread:hermes-no-project"); + const providerSessionId = ProviderSessionId.make("session:hermes-no-project"); + + yield* RunExecutionServiceV2.pipe( + Effect.flatMap((runExecution) => + runExecution.startRootRun({ + commandId: CommandId.make("command:hermes-no-project"), + appThread: { id: threadId, worktreePath: null } as OrchestrationV2AppThread, + providerSessionId, + session: { events: Stream.never } as unknown as ProviderAdapterV2SessionRuntime, + run: { id: runId, threadId, ordinal: 1, providerInstanceId } as OrchestrationV2Run, + rootNode: { id: NodeId.make("node:hermes-no-project") } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:hermes-no-project"), + cwd: "/tmp/t3-work-no-git", + } as OrchestrationV2CheckpointScope, + providerThread: { + id: providerThreadId, + driver: ProviderDriverKind.make("hermes"), + } as OrchestrationV2ProviderThread, + attempt: { id: attemptId, providerTurnId: null } as OrchestrationV2RunAttempt, + attemptId, + providerTurnOrdinal: 1, + shouldStartProviderTurn: () => Effect.succeed(false), + captureFilesystemCheckpoint: false, + message: { + messageId: MessageId.make("message:hermes-no-project"), + text: "Hello Hermes", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "hermes" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/tmp/t3-work-no-git", + }, + }), + ), + Effect.provide(testLayer), + ); + + assert.equal(yield* Ref.get(captures), 0); + }), +); + it.effect("keeps ingesting owned child events after the root turn terminalizes", () => Effect.gen(function* () { const threadId = ThreadId.make("thread:run-execution-late-child"); diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index a3d72b065b7..3b7f298ea67 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -335,6 +335,8 @@ export function routeProviderEvent( }, ]; } + case "app_thread.title_reconciled": + return [ownsThread(event.threadId), state]; case "provider_thread.updated": { const belongs = state.ownedProviderThreadIds.has(event.providerThread.id) || @@ -432,6 +434,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldStartProviderTurn?: () => Effect.Effect; readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly captureFilesystemCheckpoint?: boolean; readonly message: ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; @@ -710,21 +713,23 @@ export const layer: Layer.Layer< }), ), ); - yield* checkpointService - .captureBaseline({ - scope: input.checkpointScope, - ordinalWithinScope: Math.max(0, input.run.ordinal - 1), - }) - .pipe( - Effect.mapError( - (cause) => - new RunExecutionStartError({ - commandId: input.commandId, - runId: input.run.id, - cause, - }), - ), - ); + if (input.captureFilesystemCheckpoint !== false) { + yield* checkpointService + .captureBaseline({ + scope: input.checkpointScope, + ordinalWithinScope: Math.max(0, input.run.ordinal - 1), + }) + .pipe( + Effect.mapError( + (cause) => + new RunExecutionStartError({ + commandId: input.commandId, + runId: input.run.id, + cause, + }), + ), + ); + } if ( input.shouldStartProviderTurn !== undefined && !(yield* input.shouldStartProviderTurn()) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index de45bac5ae1..4681d3f8230 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -125,6 +125,7 @@ function launchInput(input: { readonly thread: string; readonly message?: string; readonly workspace?: ThreadLaunch.ThreadLaunchWorkspaceStrategy; + readonly prepareWorkspace?: boolean; }) { return { commandId: CommandId.make(input.command), @@ -135,6 +136,7 @@ function launchInput(input: { runtimeMode: "full-access" as const, interactionMode: "default" as const, workspaceStrategy: input.workspace ?? { type: "root" as const }, + ...(input.prepareWorkspace === undefined ? {} : { prepareWorkspace: input.prepareWorkspace }), ...(input.message === undefined ? {} : { @@ -149,6 +151,36 @@ function launchInput(input: { }; } +it.effect("starts projectless launches without workspace preparation", () => + Effect.gen(function* () { + const harness = makeHarness(); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const launched = yield* launches.launch( + launchInput({ + command: "command:launch:projectless", + thread: "thread:launch:projectless", + message: "Hello Hermes", + prepareWorkspace: false, + }), + ); + + assert.equal(launched.projection.runs[0]?.status, "starting"); + assert.isUndefined( + launched.projection.turnItems.find( + (item) => item.type === "command_execution" && item.input === "Preparing workspace", + ), + ); + assert.equal(harness.runSetup.mock.calls.length, 0); + assert.lengthOf( + yield* outbox.listByCommandId(CommandId.make("command:launch:projectless:initial-message")), + 1, + ); + }).pipe(Effect.provide(harness.layer)); + }), +); + function waitUntil(predicate: () => Effect.Effect): Effect.Effect { return Effect.gen(function* () { for (let attempt = 0; attempt < 200; attempt += 1) { diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index 69e3fc83d86..6edcd636f03 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -61,6 +61,7 @@ export interface ThreadLaunchInput { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; readonly workspaceStrategy: ThreadLaunchWorkspaceStrategy; + readonly prepareWorkspace?: boolean; readonly initialMessage?: ThreadLaunchInitialMessage; readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; @@ -491,7 +492,10 @@ export const make = Effect.gen(function* () { text: input.initialMessage.text, attachments: input.initialMessage.attachments, modelSelection: input.modelSelection, - dispatchMode: { type: "defer_start" }, + dispatchMode: + input.prepareWorkspace === false + ? { type: "start_immediately" } + : { type: "defer_start" }, createdBy: input.createdBy, creationSource: input.creationSource, }) @@ -515,7 +519,9 @@ export const make = Effect.gen(function* () { const runIsPreparing = runId !== null && projection.runs.some((run) => run.id === runId && run.status === "preparing"); - const shouldSchedule = runId === null ? Option.isNone(launchReceipt) : runIsPreparing; + const shouldSchedule = + input.prepareWorkspace !== false && + (runId === null ? Option.isNone(launchReceipt) : runIsPreparing); if (shouldSchedule) { const ownsPreparation = yield* reservePreparation(input.commandId); if (ownsPreparation) { diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 1ef37a2bfa0..a688c2ed5fd 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -14,6 +14,18 @@ import { type CursorAdapterV2DriverEnv, } from "./Adapters/CursorAdapterV2.ts"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; +import { + HermesAcpAdapterV2Driver, + type HermesAcpAdapterV2DriverEnv, +} from "./Adapters/HermesAcpAdapterV2.ts"; +import { + HermesServeAdapterV2Driver, + type HermesServeAdapterV2DriverEnv, +} from "./Adapters/HermesServeAdapterV2.ts"; +import { + OpenClawAdapterV2Driver, + type OpenClawAdapterV2DriverEnv, +} from "./Adapters/OpenClawAdapterV2.ts"; import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, @@ -26,6 +38,9 @@ export type BuiltInProviderAdapterDriversV2Env = | CodexAdapterV2DriverEnv | CursorAdapterV2DriverEnv | GrokAdapterV2DriverEnv + | HermesAcpAdapterV2DriverEnv + | HermesServeAdapterV2DriverEnv + | OpenClawAdapterV2DriverEnv | OpenCodeAdapterV2DriverEnv; export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< @@ -37,6 +52,9 @@ export const BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2: ReadonlyArray< OpenCodeAdapterV2Driver, GrokAdapterV2Driver, AcpRegistryAdapterV2Driver, + HermesAcpAdapterV2Driver, + OpenClawAdapterV2Driver, + HermesServeAdapterV2Driver, ]; export const BUILT_IN_PROVIDER_ADAPTER_DRIVER_KINDS_V2: ReadonlySet = new Set( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index dd8084ea82e..84bedf85056 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5,9 +5,11 @@ import { CommandId, MessageId, type ModelSelection, + type OrchestrationV2ProviderThread, ProjectId, ProviderDriverKind, ProviderInstanceId, + ProviderThreadId, ThreadId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -15,6 +17,7 @@ import * as DateTime from "effect/DateTime"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; @@ -39,7 +42,8 @@ import { } from "./Orchestrator.ts"; import { OrchestrationEffectWorkerV2 } from "./EffectWorker.ts"; import { ProjectionMaintenanceV2 } from "./ProjectionMaintenance.ts"; -import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import { ProviderSessionManagerV2 } from "./ProviderSessionManager.ts"; import { OrchestrationV2LayerLive } from "./runtimeLayer.ts"; import { shellStreamItemFromSnapshot } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; @@ -65,12 +69,103 @@ const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( ); const driver = ProviderDriverKind.make("codex"); +const historyHydrationThreadId = ThreadId.make("runtime-layer-history-hydration-thread"); +let hydrationSnapshotReads = 0; const orchestrationAdapter = { instanceId: modelSelection.instanceId, driver, getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), - openSession: () => Effect.die("sessions are not used by lifecycle tests"), + openSession: (input) => { + if (input.threadId !== historyHydrationThreadId) { + return Effect.die("sessions are not used by lifecycle tests"); + } + const now = DateTime.nowUnsafe(); + const makeProviderThread = (threadId: ThreadId): OrchestrationV2ProviderThread => ({ + id: ProviderThreadId.make(`provider-thread:hydration:${threadId}`), + driver, + providerInstanceId: modelSelection.instanceId, + providerSessionId: input.providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: null, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: null, + createdAt: now, + updatedAt: now, + }); + const runtime: ProviderAdapterV2SessionRuntime = { + instanceId: modelSelection.instanceId, + driver, + providerSessionId: input.providerSessionId, + providerSession: { + id: input.providerSessionId, + driver, + providerInstanceId: modelSelection.instanceId, + status: "ready", + cwd: input.runtimePolicy.cwd ?? process.cwd(), + model: input.modelSelection.model, + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + events: Stream.never, + ensureThread: (threadInput) => Effect.succeed(makeProviderThread(threadInput.threadId)), + resumeThread: (threadInput) => Effect.succeed(threadInput.providerThread), + startTurn: () => Effect.die("unused startTurn"), + steerTurn: () => Effect.die("unused steerTurn"), + interruptTurn: () => Effect.die("unused interruptTurn"), + respondToRuntimeRequest: () => Effect.die("unused respondToRuntimeRequest"), + readThreadSnapshot: ({ providerThread }) => + Effect.sync(() => { + hydrationSnapshotReads += 1; + const threadId = providerThread.appThreadId!; + return { + providerThread, + providerTurns: [], + messages: [ + { + createdBy: "user", + creationSource: "provider", + id: MessageId.make("message:hydrated:user"), + threadId, + runId: null, + nodeId: null, + role: "user", + text: "Existing Hermes question", + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + createdBy: "agent", + creationSource: "provider", + id: MessageId.make("message:hydrated:assistant"), + threadId, + runId: null, + nodeId: null, + role: "assistant", + text: "Existing Hermes answer", + attachments: [], + streaming: false, + createdAt: DateTime.add(now, { milliseconds: 1 }), + updatedAt: DateTime.add(now, { milliseconds: 1 }), + }, + ], + runtimeRequests: [], + }; + }), + rollbackThread: () => Effect.die("unused rollbackThread"), + forkThread: () => Effect.die("unused forkThread"), + }; + return Effect.succeed(runtime); + }, } as ProviderAdapterV2Shape; const providerInstance = { instanceId: modelSelection.instanceId, @@ -382,6 +477,59 @@ it.layer(LegacyImportTestLayer)("OrchestrationV2 legacy import", (it) => { }); it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { + it.effect("hydrates provider history once and remains idempotent across retries and reopen", () => + Effect.gen(function* () { + hydrationSnapshotReads = 0; + const orchestrator = yield* OrchestratorV2; + const providerSessions = yield* ProviderSessionManagerV2; + const threadId = historyHydrationThreadId; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "system", + creationSource: "provider", + commandId: CommandId.make("runtime-layer-history-hydration-create"), + threadId, + projectId: ProjectId.make("runtime-layer-history-hydration-project"), + title: "Imported Hermes thread", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: "/tmp/runtime-layer-history-hydration", + }); + + const hydrationInput = { + threadId, + providerInstanceId: modelSelection.instanceId, + }; + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const first = yield* orchestrator.getThreadSnapshot(threadId); + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const retry = yield* orchestrator.getThreadSnapshot(threadId); + + assert.deepEqual( + retry.projection.messages.map((message) => message.text), + ["Existing Hermes question", "Existing Hermes answer"], + ); + assert.equal(retry.projection.messages.length, 2); + assert.equal(retry.snapshotSequence, first.snapshotSequence); + + const providerSessionId = retry.projection.providerThreads[0]?.providerSessionId; + assert.isNotNull(providerSessionId); + yield* providerSessions.close(providerSessionId!); + yield* orchestrator.hydrateProviderThreadSnapshot(hydrationInput); + const reopened = yield* orchestrator.getThreadProjection(threadId); + + assert.equal(hydrationSnapshotReads, 3); + assert.equal(reopened.messages.length, 2); + assert.deepEqual( + reopened.messages.map((message) => message.id), + [MessageId.make("message:hydrated:user"), MessageId.make("message:hydrated:assistant")], + ); + }), + ); + it.effect("applies lifecycle commands idempotently and emits archive/removal shell deltas", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -532,6 +680,86 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("persists Work inbox pin metadata and protects the main thread lifecycle", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-work-main-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-work-main-create"), + threadId, + projectId: ProjectId.make("runtime-layer-work-main-project"), + title: "Main", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-work-main-metadata"), + threadId, + pinned: true, + workInboxRole: "main", + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (candidate) => candidate.id === threadId, + ); + assert.equal(projection.thread.workInboxRole, "main"); + assert.isNotNull(projection.thread.pinnedAt); + assert.equal(shell?.workInboxRole, "main"); + assert.deepEqual(shell?.pinnedAt, projection.thread.pinnedAt); + + const settleError = yield* orchestrator + .dispatch({ + type: "thread.settle", + commandId: CommandId.make("runtime-layer-work-main-settle"), + threadId, + }) + .pipe(Effect.flip); + assert.equal(settleError._tag, "OrchestratorDispatchError"); + }), + ); + + it.effect("persists an in-place timeline clear boundary on the thread and shell", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const threadId = ThreadId.make("runtime-layer-cleared-thread"); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-cleared-create"), + threadId, + projectId: ProjectId.make("runtime-layer-cleared-project"), + title: "Cleared chat", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("runtime-layer-clear-timeline"), + threadId, + clearTimeline: true, + }); + + const projection = yield* orchestrator.getThreadProjection(threadId); + const shell = (yield* orchestrator.getShellSnapshot()).threads.find( + (candidate) => candidate.id === threadId, + ); + assert.isNotNull(projection.thread.timelineClearedAt); + assert.deepEqual(shell?.timelineClearedAt, projection.thread.timelineClearedAt); + }), + ); + it.effect("rejects settling a thread while a run is active", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; @@ -743,6 +971,84 @@ it.layer(SharedApplicationDataPlaneTestLayer)("snooze projection", (it) => { ); }); +it.layer(SharedApplicationDataPlaneTestLayer)("permanent provider start failure", (it) => { + it.effect("terminalizes a provider turn that permanently fails to start", () => + Effect.gen(function* () { + const applicationEngine = yield* OrchestrationEngineService; + const orchestrator = yield* OrchestratorV2; + const effectWorker = yield* OrchestrationEffectWorkerV2; + const projectId = ProjectId.make("runtime-layer-permanent-start-failure-project"); + const threadId = ThreadId.make("runtime-layer-permanent-start-failure-thread"); + + yield* applicationEngine.dispatch({ + type: "project.create", + commandId: CommandId.make("runtime-layer-permanent-start-failure-project-create"), + projectId, + title: "Permanent start failure", + workspaceRoot: "/tmp/runtime-layer-permanent-start-failure-project", + defaultModelSelection: modelSelection, + scripts: [], + createdAt: "2026-07-25T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-permanent-start-failure-create"), + threadId, + projectId, + title: "Permanent start failure", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("runtime-layer-permanent-start-failure-message"), + threadId, + messageId: MessageId.make("runtime-layer-permanent-start-failure-message"), + text: "Hello", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + + const before = yield* orchestrator.getThreadProjection(threadId); + const run = before.runs[0]; + assert.isDefined(run); + if (run === undefined) return; + + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.isTrue(yield* effectWorker.runOnce); + yield* TestClock.adjust("2 seconds"); + } + + const after = yield* orchestrator.getThreadProjection(threadId); + const failedRun = after.runs.find((candidate) => candidate.id === run.id); + const failedAttempt = after.attempts.find( + (candidate) => candidate.id === run.activeAttemptId, + ); + const failedRootNode = after.nodes.find((candidate) => candidate.id === run.rootNodeId); + const errorItem = after.turnItems.find( + (candidate) => candidate.runId === run.id && candidate.type === "error", + ); + + assert.equal(failedRun?.status, "failed"); + assert.isNotNull(failedRun?.completedAt); + assert.equal(failedAttempt?.status, "failed"); + assert.equal(failedRootNode?.status, "failed"); + assert.equal(errorItem?.status, "failed"); + if (errorItem?.type === "error") { + assert.equal(errorItem.failure.code, "provider_turn_start_failed"); + } + }).pipe(Effect.provide(TestClock.layer())), + ); +}); + it.layer(SharedApplicationDataPlaneTestLayer)("shared application data plane", (it) => { it.effect("orders retained project transactions and V2 thread transactions in one source", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 332db73af3a..c7a86cd1244 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -76,6 +76,7 @@ export function makeReplayServerConfig( const providerLogsDir = path.join(logsDir, "provider"); const terminalLogsDir = path.join(logsDir, "terminals"); const attachmentsDir = path.join(stateDir, "attachments"); + const browserArtifactsDir = path.join(stateDir, "browser-artifacts"); const worktreesDir = path.join(baseDir, "worktrees"); const providerStatusCacheDir = path.join(baseDir, "caches"); @@ -85,6 +86,7 @@ export function makeReplayServerConfig( providerLogsDir, terminalLogsDir, attachmentsDir, + browserArtifactsDir, worktreesDir, providerStatusCacheDir, ]) { @@ -124,6 +126,7 @@ export function makeReplayServerConfig( providerStatusCacheDir, worktreesDir, attachmentsDir, + browserArtifactsDir, logsDir, serverLogPath: path.join(logsDir, "server.log"), serverTracePath: path.join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 05ff25d81a2..72db3767709 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -56,6 +56,11 @@ import Migration0040 from "./Migrations/040_ApplicationEventSource.ts"; import Migration0041 from "./Migrations/041_OrchestrationV2EffectCancellation.ts"; import Migration0042 from "./Migrations/042_ScheduledTasks.ts"; import Migration0043 from "./Migrations/043_LegacyV1ImportState.ts"; +import Migration0044 from "./Migrations/044_HermesSessionBindings.ts"; +import Migration0045 from "./Migrations/045_HermesProactiveEvents.ts"; +import Migration0046 from "./Migrations/046_HermesTitleBranchLineage.ts"; +import Migration0047 from "./Migrations/047_HermesSessionImports.ts"; +import Migration0048 from "./Migrations/048_HermesImportProjectScope.ts"; /** * Migration loader with all migrations defined inline. @@ -111,6 +116,11 @@ export const migrationEntries = [ [41, "OrchestrationV2EffectCancellation", Migration0041], [42, "ScheduledTasks", Migration0042], [43, "LegacyV1ImportState", Migration0043], + [44, "HermesSessionBindings", Migration0044], + [45, "HermesProactiveEvents", Migration0045], + [46, "HermesTitleBranchLineage", Migration0046], + [47, "HermesSessionImports", Migration0047], + [48, "HermesImportProjectScope", Migration0048], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts index 5b217fb9c70..20d0ce3a2c7 100644 --- a/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/035_036_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("035_036_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 43 }, (_, index) => index + 1), + Array.from({ length: 48 }, (_, index) => index + 1), ); }), ); @@ -122,7 +122,7 @@ it.effect("upgrades a database already at released main migration 034", () => assert.ok(snoozeColumns.some((column) => column.name === "snoozed_until")); assert.ok(snoozeColumns.some((column) => column.name === "snoozed_at")); - yield* runMigrations({ toMigrationInclusive: 43 }); + yield* runMigrations({ toMigrationInclusive: 44 }); const migrations = yield* sql<{ readonly migration_id: number; @@ -130,7 +130,7 @@ it.effect("upgrades a database already at released main migration 034", () => }>` SELECT migration_id, name FROM effect_sql_migrations - WHERE migration_id BETWEEN 34 AND 43 + WHERE migration_id BETWEEN 34 AND 44 ORDER BY migration_id `; assert.deepStrictEqual( @@ -146,6 +146,7 @@ it.effect("upgrades a database already at released main migration 034", () => [41, "OrchestrationV2EffectCancellation"], [42, "ScheduledTasks"], [43, "LegacyV1ImportState"], + [44, "HermesSessionBindings"], ], ); @@ -162,5 +163,12 @@ it.effect("upgrades a database already at released main migration 034", () => WHERE type = 'table' AND name = 'orchestration_v2_legacy_imports' `; assert.strictEqual(legacyImportTables.length, 1); + + const hermesBindingTables = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'hermes_session_bindings' + `; + assert.strictEqual(hermesBindingTables.length, 1); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); diff --git a/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts new file mode 100644 index 00000000000..a47b47d35a8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.test.ts @@ -0,0 +1,149 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("044_HermesSessionBindings", (it) => { + it.effect("registers a privacy-minimized binding and mutation-intent schema", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 44 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id = 44 + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 44, + name: "HermesSessionBindings", + }, + ]); + + const bindingColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_bindings) + `; + const intentColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_mutation_intents) + `; + const importColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.deepStrictEqual( + bindingColumns.map(({ name }) => name), + [ + "binding_id", + "provider_instance_id", + "profile_key", + "project_id", + "stored_session_key", + "thread_id", + "protocol_classification", + "protocol_major", + "protocol_minor", + "capabilities_json", + "reconciliation_cursor", + "reconciliation_fingerprint", + "lease_owner_key", + "lease_generation", + "lease_expires_at", + "created_at", + "updated_at", + ], + ); + assert.deepStrictEqual( + intentColumns.map(({ name }) => name), + [ + "operation_id", + "binding_id", + "provider_instance_id", + "profile_key", + "project_id", + "thread_id", + "run_id", + "attempt_id", + "message_id", + "mutation_kind", + "method", + "payload_digest", + "owner_generation", + "state", + "prepared_at", + "admitted_at", + "settled_at", + "updated_at", + ], + ); + assert.deepStrictEqual( + importColumns.map(({ name }) => name), + [ + "import_id", + "provider_instance_id", + "profile_key", + "project_id", + "import_kind", + "stored_session_key", + "thread_id", + "state", + "created_at", + "updated_at", + ], + ); + + const forbiddenColumns = new Set([ + "session_id", + "token", + "prompt", + "transcript", + "payload", + "payload_json", + ]); + assert.ok( + [...bindingColumns, ...intentColumns, ...importColumns].every( + ({ name }) => !forbiddenColumns.has(name), + ), + ); + + const unsettledPromptIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_mutation_intents_one_unsettled_prompt_idx' + `; + assert.match(unsettledPromptIndex[0]!.sql, /CREATE UNIQUE INDEX/); + assert.match( + unsettledPromptIndex[0]!.sql, + /state IN \('prepared', 'admitted', 'indeterminate'\)/, + ); + + const unsettledCreateIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_mutation_intents_one_unsettled_create_idx' + `; + assert.match( + unsettledCreateIndex[0]!.sql, + /provider_instance_id, profile_key, project_id, thread_id/, + ); + + const oneMainIndex = yield* sql<{ readonly sql: string }>` + SELECT sql + FROM sqlite_master + WHERE type = 'index' + AND name = 'hermes_session_imports_one_main_idx' + `; + assert.match(oneMainIndex[0]!.sql, /CREATE UNIQUE INDEX/); + assert.match(oneMainIndex[0]!.sql, /WHERE import_kind = 'main'/); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts new file mode 100644 index 00000000000..c54867e975f --- /dev/null +++ b/apps/server/src/persistence/Migrations/044_HermesSessionBindings.ts @@ -0,0 +1,149 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Durable Hermes identity and write-safety state. + * + * The live gateway session id is intentionally absent: Hermes only guarantees + * the profile-scoped stored session key across reconnects. Mutation payloads + * are represented by a caller-computed digest and are never stored here. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE hermes_session_bindings ( + binding_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + stored_session_key TEXT NOT NULL, + thread_id TEXT NOT NULL UNIQUE, + protocol_classification TEXT NOT NULL + CHECK (protocol_classification IN ('legacy', 'supported', 'unsupported')), + protocol_major INTEGER, + protocol_minor INTEGER, + capabilities_json TEXT NOT NULL, + reconciliation_cursor TEXT, + reconciliation_fingerprint TEXT, + lease_owner_key TEXT, + lease_generation INTEGER NOT NULL DEFAULT 0 CHECK (lease_generation >= 0), + lease_expires_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (provider_instance_id, profile_key, stored_session_key), + CHECK ( + (protocol_major IS NULL AND protocol_minor IS NULL) + OR (protocol_major IS NOT NULL AND protocol_minor IS NOT NULL) + ), + CHECK ( + (lease_owner_key IS NULL AND lease_expires_at IS NULL) + OR (lease_owner_key IS NOT NULL AND lease_expires_at IS NOT NULL) + ) + ) + `; + + yield* sql` + CREATE TABLE hermes_mutation_intents ( + operation_id TEXT PRIMARY KEY, + binding_id TEXT REFERENCES hermes_session_bindings(binding_id) ON DELETE CASCADE, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + run_id TEXT, + attempt_id TEXT, + message_id TEXT, + mutation_kind TEXT NOT NULL, + method TEXT NOT NULL, + payload_digest TEXT NOT NULL + CHECK ( + length(payload_digest) = 64 + AND payload_digest NOT GLOB '*[^0-9a-f]*' + ), + owner_generation INTEGER NOT NULL CHECK (owner_generation >= 0), + state TEXT NOT NULL + CHECK ( + state IN ( + 'prepared', + 'admitted', + 'confirmed', + 'indeterminate', + 'reconciled', + 'rejected' + ) + ), + prepared_at TEXT NOT NULL, + admitted_at TEXT, + settled_at TEXT, + updated_at TEXT NOT NULL, + CHECK ( + binding_id IS NOT NULL + OR (mutation_kind = 'session_create' AND owner_generation = 0) + ) + ) + `; + + yield* sql` + CREATE INDEX hermes_mutation_intents_binding_state_idx + ON hermes_mutation_intents(binding_id, state, prepared_at, operation_id) + `; + + yield* sql` + CREATE INDEX hermes_mutation_intents_thread_state_idx + ON hermes_mutation_intents(thread_id, state, prepared_at, operation_id) + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_mutation_intents_one_unsettled_prompt_idx + ON hermes_mutation_intents(binding_id) + WHERE mutation_kind = 'prompt' + AND state IN ('prepared', 'admitted', 'indeterminate') + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_mutation_intents_one_unsettled_create_idx + ON hermes_mutation_intents(provider_instance_id, profile_key, project_id, thread_id) + WHERE mutation_kind = 'session_create' + AND state IN ('prepared', 'admitted', 'indeterminate') + `; + + /** + * Durable, replayable bridge from Hermes profile sessions to T3 shells. + * A row is written before the orchestration event. Deterministic thread and + * command IDs plus the phase column make retry after any crash idempotent. + * + * `main` has no stored session identity until it is first opened; the normal + * Hermes binding path then creates and attaches its durable Hermes session. + */ + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; +}); diff --git a/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts b/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts new file mode 100644 index 00000000000..6e0ca0beb30 --- /dev/null +++ b/apps/server/src/persistence/Migrations/045_HermesProactiveEvents.ts @@ -0,0 +1,131 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Durable, capability-gated storage for Hermes events that happen without an + * attached T3 turn. The source checkpoint and event page commit together. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE hermes_proactive_sources ( + source_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + capability_state TEXT NOT NULL + CHECK (capability_state IN ('ready', 'degraded')), + diagnostic_code TEXT NOT NULL + CHECK ( + diagnostic_code IN ( + 'ready', + 'missing_capability_inventory', + 'missing_durable_global_cursor', + 'missing_stable_event_ids' + ) + ), + missing_capabilities_json TEXT NOT NULL, + checkpoint_cursor TEXT, + checkpoint_sequence INTEGER NOT NULL DEFAULT 0 CHECK (checkpoint_sequence >= 0), + last_checked_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (provider_instance_id, profile_key) + ) + `; + + yield* sql` + CREATE TABLE hermes_proactive_events ( + event_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL + REFERENCES hermes_proactive_sources(source_id) ON DELETE CASCADE, + external_event_id TEXT NOT NULL, + external_cursor TEXT NOT NULL, + event_kind TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + project_id TEXT, + thread_id TEXT, + occurred_at TEXT NOT NULL, + received_at TEXT NOT NULL, + provenance_json TEXT NOT NULL, + UNIQUE (source_id, external_event_id) + ) + `; + + yield* sql` + CREATE INDEX hermes_proactive_events_source_cursor_idx + ON hermes_proactive_events(source_id, external_cursor, event_id) + `; + + yield* sql` + CREATE TABLE hermes_notification_outbox ( + outbox_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + state TEXT NOT NULL + CHECK (state IN ('pending', 'processing', 'retry', 'delivered', 'dead_letter')), + attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count >= 0), + available_at TEXT NOT NULL, + lease_owner TEXT, + lease_expires_at TEXT, + last_error_code TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + delivered_at TEXT, + CHECK ( + (state = 'processing' AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL) + OR (state <> 'processing' AND lease_owner IS NULL AND lease_expires_at IS NULL) + ) + ) + `; + + yield* sql` + CREATE INDEX hermes_notification_outbox_claim_idx + ON hermes_notification_outbox(state, available_at, created_at, outbox_id) + WHERE state IN ('pending', 'retry', 'processing') + `; + + yield* sql` + CREATE TABLE hermes_proactive_work_items ( + work_item_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + project_id TEXT, + thread_id TEXT, + title TEXT NOT NULL, + summary TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('unread', 'read', 'dismissed')), + occurred_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX hermes_proactive_work_items_status_idx + ON hermes_proactive_work_items(status, occurred_at DESC, work_item_id) + `; + + yield* sql` + CREATE TABLE hermes_in_app_notifications ( + notification_id TEXT PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_events(event_id) ON DELETE CASCADE, + work_item_id TEXT NOT NULL UNIQUE + REFERENCES hermes_proactive_work_items(work_item_id) ON DELETE CASCADE, + project_id TEXT, + thread_id TEXT, + title TEXT NOT NULL, + body TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('unread', 'read', 'dismissed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + + yield* sql` + CREATE INDEX hermes_in_app_notifications_status_idx + ON hermes_in_app_notifications(status, created_at DESC, notification_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts b/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts new file mode 100644 index 00000000000..4a776f6fd1d --- /dev/null +++ b/apps/server/src/persistence/Migrations/046_HermesTitleBranchLineage.ts @@ -0,0 +1,44 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Provider-authoritative title cursor and latest-head native branch provenance. + * + * Titles themselves remain in the orchestration projection; the binding stores + * only the monotonic upstream cursor/origin needed to suppress stale replay. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN title_revision INTEGER NOT NULL DEFAULT 0 CHECK (title_revision >= 0) + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN title_origin TEXT + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN parent_binding_id TEXT REFERENCES hermes_session_bindings(binding_id) + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_mode TEXT + CHECK (branch_boundary_mode IS NULL OR branch_boundary_mode = 'latest_only') + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_message_id TEXT + `; + yield* sql` + ALTER TABLE hermes_session_bindings + ADD COLUMN branch_boundary_message_count INTEGER + CHECK (branch_boundary_message_count IS NULL OR branch_boundary_message_count >= 0) + `; + + yield* sql` + CREATE INDEX hermes_session_bindings_parent_idx + ON hermes_session_bindings(parent_binding_id, created_at, binding_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts b/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts new file mode 100644 index 00000000000..100e79b73c6 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_HermesSessionImports.test.ts @@ -0,0 +1,70 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("047_HermesSessionImports", (it) => { + it.effect("repairs databases whose recorded migration 044 lacks the import ledger", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 46 }); + yield* sql`DROP TABLE hermes_session_imports`; + + yield* runMigrations({ toMigrationInclusive: 47 }); + + const migrations = yield* sql<{ + readonly migration_id: number; + readonly name: string; + }>` + SELECT migration_id, name + FROM effect_sql_migrations + WHERE migration_id = 47 + `; + assert.deepStrictEqual(migrations, [ + { + migration_id: 47, + name: "HermesSessionImports", + }, + ]); + + const importColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.deepStrictEqual( + importColumns.map(({ name }) => name), + [ + "import_id", + "provider_instance_id", + "profile_key", + "project_id", + "import_kind", + "stored_session_key", + "thread_id", + "state", + "created_at", + "updated_at", + ], + ); + + const indexes = yield* sql<{ readonly name: string }>` + SELECT name + FROM sqlite_master + WHERE type = 'index' + AND name IN ( + 'hermes_session_imports_stored_identity_idx', + 'hermes_session_imports_one_main_idx' + ) + ORDER BY name + `; + assert.deepStrictEqual( + indexes.map(({ name }) => name), + ["hermes_session_imports_one_main_idx", "hermes_session_imports_stored_identity_idx"], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts b/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts new file mode 100644 index 00000000000..ac18c3e78f3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/047_HermesSessionImports.ts @@ -0,0 +1,43 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Repair databases that ran the original Hermes bindings migration before the + * session-import ledger was added to it. Released migrations are immutable, so + * existing databases need a new forward migration while fresh databases keep + * receiving the same schema from migration 044. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE IF NOT EXISTS hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + + yield* sql` + CREATE UNIQUE INDEX IF NOT EXISTS hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; +}); diff --git a/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts new file mode 100644 index 00000000000..4ae0c03f4a8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.test.ts @@ -0,0 +1,109 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("048_HermesImportProjectScope", (it) => { + it.effect("backfills legacy import rows from their thread and drops unrecoverable rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 47 }); + yield* sql`DROP INDEX hermes_session_imports_stored_identity_idx`; + yield* sql`DROP INDEX hermes_session_imports_one_main_idx`; + yield* sql`DROP TABLE hermes_session_imports`; + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + import_kind TEXT NOT NULL, + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `; + yield* sql` + INSERT INTO orchestration_v2_projection_threads ( + thread_id, + project_id, + title, + default_provider, + runtime_mode, + interaction_mode, + active_provider_thread_id, + created_at, + updated_at, + archived_at, + deleted_at, + payload_json + ) VALUES ( + 'thread:imported', + 'project:t3-work', + 'Imported', + 'hermes', + 'full-access', + 'default', + NULL, + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z', + NULL, + NULL, + '{}' + ) + `; + yield* sql` + INSERT INTO hermes_session_imports VALUES + ( + 'import:resolved', + 'hermes-local', + 'default', + 'session', + 'stored:resolved', + 'thread:imported', + 'completed', + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z' + ), + ( + 'import:unresolved', + 'hermes-local', + 'default', + 'session', + 'stored:unresolved', + 'thread:missing', + 'prepared', + '2026-07-26T12:00:00.000Z', + '2026-07-26T12:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 48 }); + + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + assert.include( + columns.map(({ name }) => name), + "project_id", + ); + const rows = yield* sql<{ + readonly import_id: string; + readonly project_id: string; + }>` + SELECT import_id, project_id + FROM hermes_session_imports + ORDER BY import_id + `; + assert.deepStrictEqual(rows, [ + { import_id: "import:resolved", project_id: "project:t3-work" }, + ]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts new file mode 100644 index 00000000000..c225112161b --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_HermesImportProjectScope.ts @@ -0,0 +1,87 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Repair development databases that ran the pre-project-scoping version of + * the Hermes import ledger. Resolvable rows inherit their project from the + * durable binding or orchestration thread projection. Unresolvable prepared + * rows are safe to discard and will be recreated by the idempotent importer. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(hermes_session_imports) + `; + if (columns.some(({ name }) => name === "project_id")) return; + + yield* sql.withTransaction( + Effect.gen(function* () { + yield* sql`DROP INDEX IF EXISTS hermes_session_imports_stored_identity_idx`; + yield* sql`DROP INDEX IF EXISTS hermes_session_imports_one_main_idx`; + yield* sql` + ALTER TABLE hermes_session_imports + RENAME TO hermes_session_imports_legacy_048 + `; + yield* sql` + CREATE TABLE hermes_session_imports ( + import_id TEXT PRIMARY KEY, + provider_instance_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + project_id TEXT NOT NULL, + import_kind TEXT NOT NULL CHECK (import_kind IN ('session', 'main')), + stored_session_key TEXT, + thread_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ('prepared', 'thread_created', 'completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + CHECK ( + (import_kind = 'session' AND stored_session_key IS NOT NULL) + OR (import_kind = 'main' AND stored_session_key IS NULL) + ) + ) + `; + yield* sql` + INSERT INTO hermes_session_imports ( + import_id, + provider_instance_id, + profile_key, + project_id, + import_kind, + stored_session_key, + thread_id, + state, + created_at, + updated_at + ) + SELECT + legacy.import_id, + legacy.provider_instance_id, + legacy.profile_key, + COALESCE(binding.project_id, thread.project_id), + legacy.import_kind, + legacy.stored_session_key, + legacy.thread_id, + legacy.state, + legacy.created_at, + legacy.updated_at + FROM hermes_session_imports_legacy_048 AS legacy + LEFT JOIN hermes_session_bindings AS binding + ON binding.thread_id = legacy.thread_id + LEFT JOIN orchestration_v2_projection_threads AS thread + ON thread.thread_id = legacy.thread_id + WHERE COALESCE(binding.project_id, thread.project_id) IS NOT NULL + `; + yield* sql`DROP TABLE hermes_session_imports_legacy_048`; + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_stored_identity_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id, stored_session_key) + WHERE import_kind = 'session' + `; + yield* sql` + CREATE UNIQUE INDEX hermes_session_imports_one_main_idx + ON hermes_session_imports(provider_instance_id, profile_key, project_id) + WHERE import_kind = 'main' + `; + }), + ); +}); diff --git a/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts b/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts new file mode 100644 index 00000000000..fbd27bb6211 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesAcpDriver.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { HermesAcpDriver } from "./HermesAcpDriver.ts"; + +describe("HermesAcpDriver", () => { + it("is a first-class instance-only driver separate from Hermes Work", () => { + expect(BUILT_IN_DRIVERS).toContain(HermesAcpDriver); + expect(HermesAcpDriver.driverKind).toBe("hermesAcp"); + expect(HermesAcpDriver.metadata.displayName).toBe("Hermes in Code"); + expect(HermesAcpDriver.defaultConfig()).toEqual({ + enabled: true, + binaryPath: "hermes", + customModels: [], + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/HermesAcpDriver.ts b/apps/server/src/provider/Drivers/HermesAcpDriver.ts new file mode 100644 index 00000000000..0b071a7e6a0 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesAcpDriver.ts @@ -0,0 +1,139 @@ +import { + HermesAcpSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + HermesAcpAdapterV2Driver, + type HermesAcpAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/HermesAcpAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialHermesAcpProviderSnapshot, + checkHermesAcpProviderStatus, +} from "../Layers/HermesAcpProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("hermesAcp"); +const decodeSettings = Schema.decodeSync(HermesAcpSettings); + +const makeUnsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes in Code sessions do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +export type HermesAcpDriverEnv = + | HermesAcpAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const HermesAcpDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes in Code", + supportsMultipleInstances: true, + }, + configSchema: HermesAcpSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const processEnvironment = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies HermesAcpSettings; + const stampIdentity = (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId, + driver: DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + }); + + const orchestrationAdapter = yield* HermesAcpAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes in Code ACP orchestration adapter.", + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialHermesAcpProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider: checkHermesAcpProviderStatus(effectiveConfig, processEnvironment).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + refreshInterval: "5 minutes", + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes in Code provider diagnostics.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/HermesDriver.test.ts b/apps/server/src/provider/Drivers/HermesDriver.test.ts new file mode 100644 index 00000000000..07fa3b1b1e7 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.test.ts @@ -0,0 +1,328 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { HermesSettings, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerConfig from "../../config.ts"; +import { layer as HermesSessionBindingRepositoryLayer } from "../../hermes/HermesSessionBindingRepository.ts"; +import { BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2 } from "../../orchestration-v2/builtInProviderAdapterDrivers.ts"; +import { layer as IdAllocatorV2Layer } from "../../orchestration-v2/IdAllocator.ts"; +import { + hermesModelOverride, + hermesFastOverride, + resolveHermesGatewayToken, + resolveHermesRemotePairingToken, + resolveHermesRemoteTlsCertificateSha256, +} from "../../orchestration-v2/Adapters/HermesServeAdapterV2.ts"; +import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { HermesDriver, hermesProviderModels, hermesSlashCommands } from "./HermesDriver.ts"; + +const ServerConfigTestLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { + prefix: "t3-hermes-driver-test-", +}); +const TestLayer = Layer.mergeAll( + IdAllocatorV2Layer, + HermesSessionBindingRepositoryLayer.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ServerConfigTestLayer, +).pipe(Layer.provideMerge(NodeServices.layer)); +const decodeHermesSettingsEffect = Schema.decodeUnknownEffect(HermesSettings); + +describe("HermesDriver", () => { + it("presents the default sentinel as the effective Hermes model with live options", () => { + const models = hermesProviderModels( + { + model: "gpt-5.6-sol", + provider: "openai", + providers: [ + { + slug: "openai", + name: "OpenAI", + models: ["gpt-5.6-sol", "gpt-5.4"], + capabilities: { + "gpt-5.6-sol": { fast: true, reasoning: true }, + "gpt-5.4": { fast: false, reasoning: true }, + }, + }, + ], + }, + { value: "high", display: "show" }, + { value: "fast" }, + ["default"], + ); + + assert.deepInclude(models[0], { + slug: "default", + name: "GPT-5.6 Sol", + isDefault: true, + }); + assert.deepEqual( + models[0]!.capabilities?.optionDescriptors?.map((option) => [option.id, option.currentValue]), + [ + ["reasoningEffort", "high"], + ["fast", "fast"], + ], + ); + }); + + it("deduplicates model slugs exposed by multiple Hermes upstream providers", () => { + const models = hermesProviderModels( + { + model: "grok-4.5", + provider: "xai", + providers: [ + { + slug: "xai", + name: "xAI", + models: ["grok-4.5"], + capabilities: { "grok-4.5": { reasoning: true } }, + }, + { + slug: "opencode", + name: "OpenCode", + models: ["grok-4.5"], + capabilities: { "grok-4.5": { reasoning: true } }, + }, + ], + }, + { value: "medium", display: "show" }, + { value: "normal" }, + ["default"], + ); + + assert.strictEqual(models.filter((model) => model.slug === "grok-4.5").length, 1); + assert.strictEqual(models.find((model) => model.slug === "grok-4.5")?.subProvider, "xAI"); + }); + + it("surfaces catalog aliases and falls back to official gateway commands", () => { + const live = hermesSlashCommands({ + pairs: [["/background", "Run in background (usage: /background )"]], + canon: { "/background": "/background", "/bg": "/background" }, + sub: { "/background": [""] }, + }); + assert.deepInclude(live, { + name: "bg", + description: "Alias for /background", + input: { hint: "" }, + }); + + const fallback = hermesSlashCommands(undefined); + for (const gatewayCommand of [ + "new", + "reset", + "topic", + "approve", + "deny", + "reasoning", + "skills", + "commands", + "restart", + "platform", + ]) { + assert.isTrue( + fallback.some((command) => command.name === gatewayCommand), + `expected fallback /${gatewayCommand}`, + ); + } + assert.deepInclude( + fallback.find((command) => command.name === "model"), + { + name: "model", + input: { hint: "[model] [--provider name] [--global|--session] [--refresh]" }, + }, + ); + assert.deepInclude( + fallback.find((command) => command.name === "clear"), + { + name: "clear", + description: "Clear the visible T3 Work timeline without resetting Hermes context", + }, + ); + }); + + it("decodes a safe default config and accepts explicit loopback/profile settings", () => { + const decode = Schema.decodeSync(HermesSettings); + assert.deepEqual(decode({}), { + enabled: false, + endpoint: "", + remoteAccessEnabled: false, + profileKey: "default", + managedServerEnabled: true, + customModels: [], + importEnabled: false, + mcpEnabled: true, + attachmentsEnabled: true, + proactiveEnabled: false, + voiceEnabled: false, + }); + assert.deepEqual( + decode({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + customModels: ["custom/model"], + }), + { + enabled: false, + endpoint: "ws://127.0.0.1:9119/api/ws", + remoteAccessEnabled: false, + profileKey: "real-profile", + managedServerEnabled: true, + customModels: ["custom/model"], + importEnabled: false, + mcpEnabled: true, + attachmentsEnabled: true, + proactiveEnabled: false, + voiceEnabled: false, + }, + ); + }); + + it("only consumes a non-empty sensitive instance token", () => { + assert.equal( + resolveHermesGatewayToken([ + { name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }, + ]), + "secret", + ); + assert.isUndefined( + resolveHermesGatewayToken([ + { name: "HERMES_GATEWAY_TOKEN", value: "plain", sensitive: false }, + ]), + ); + assert.isUndefined( + resolveHermesGatewayToken([{ name: "HERMES_GATEWAY_TOKEN", value: "", sensitive: true }]), + ); + }); + + it("only consumes dedicated sensitive remote trust and pairing variables", () => { + const environment = [ + { name: "HERMES_GATEWAY_TOKEN", value: "broad-token", sensitive: true }, + { name: "HERMES_REMOTE_PAIRING_TOKEN", value: "pairing-token", sensitive: true }, + { + name: "HERMES_REMOTE_TLS_CERT_SHA256", + value: "ab".repeat(32), + sensitive: true, + }, + { name: "MCP_API_TOKEN", value: "must-not-be-used", sensitive: true }, + ]; + + assert.equal(resolveHermesRemotePairingToken(environment), "pairing-token"); + assert.equal(resolveHermesRemoteTlsCertificateSha256(environment), "ab".repeat(32)); + assert.isUndefined( + resolveHermesRemotePairingToken([ + { name: "HERMES_REMOTE_PAIRING_TOKEN", value: "plain", sensitive: false }, + ]), + ); + }); + + it("omits the profile default model and forwards only explicit custom slugs", () => { + assert.deepEqual(hermesModelOverride("default"), {}); + assert.deepEqual(hermesModelOverride("custom/model"), { model: "custom/model" }); + }); + + it("maps the effective fast option onto fresh Hermes sessions", () => { + const selection = { + instanceId: ProviderInstanceId.make("hermes"), + model: "default", + options: [{ id: "fast", value: "fast" }], + }; + assert.deepEqual(hermesFastOverride(selection), { fast: true }); + assert.deepEqual( + hermesFastOverride({ ...selection, options: [{ id: "fast", value: "normal" }] }), + { fast: false }, + ); + }); + + it("is registered in both built-in driver catalogs", () => { + assert.isTrue(BUILT_IN_DRIVERS.includes(HermesDriver)); + assert.isTrue( + BUILT_IN_PROVIDER_ADAPTER_DRIVERS_V2.some((driver) => driver.driverKind === "hermes"), + ); + }); + + it.effect( + "advertises default/custom models while disabled and keeps text generation unsupported", + () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* decodeHermesSettingsEffect({ + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + customModels: ["custom/model"], + }); + const instance = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_disabled"), + displayName: "Hermes local", + accentColor: undefined, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }], + enabled: false, + config, + }); + const snapshot = yield* instance.snapshot.getSnapshot; + const textGeneration = yield* Effect.result( + instance.textGeneration.generateThreadTitle({ + cwd: "/tmp/hermes-project", + message: "ignored", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes_disabled"), + model: "default", + }, + }), + ); + + assert.equal(snapshot.status, "disabled"); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default", "custom/model"], + ); + assert.equal(textGeneration._tag, "Failure"); + assert.isNull(instance.snapshot.maintenanceCapabilities.update); + }), + ).pipe(Effect.provide(TestLayer)), + ); + + it.effect("only reports an enabled Hermes instance ready when setup is complete", () => + Effect.scoped( + Effect.gen(function* () { + const incompleteConfig = yield* decodeHermesSettingsEffect({ + profileKey: "default", + }); + const incomplete = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_incomplete"), + displayName: "Hermes incomplete", + accentColor: undefined, + environment: [], + enabled: true, + config: incompleteConfig, + }); + const incompleteSnapshot = yield* incomplete.snapshot.getSnapshot; + + assert.equal(incompleteSnapshot.status, "warning"); + assert.equal(incompleteSnapshot.auth.status, "unauthenticated"); + assert.include(incompleteSnapshot.message, "HERMES_GATEWAY_TOKEN"); + assert.include(incompleteSnapshot.message, "attach to an existing Hermes Serve"); + + const configuredConfig = yield* decodeHermesSettingsEffect({ + endpoint: "ws://127.0.0.1:49119/api/ws", + profileKey: "default", + managedServerEnabled: false, + }); + const configured = yield* HermesDriver.create({ + instanceId: ProviderInstanceId.make("hermes_configured"), + displayName: "Hermes configured", + accentColor: undefined, + environment: [{ name: "HERMES_GATEWAY_TOKEN", value: "secret", sensitive: true }], + enabled: true, + config: configuredConfig, + }); + const configuredSnapshot = yield* configured.snapshot.getSnapshot; + + assert.equal(configuredSnapshot.status, "warning"); + assert.equal(configuredSnapshot.auth.status, "unknown"); + assert.include(configuredSnapshot.message, "automatic startup is disabled"); + }), + ).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..1513b1c40ef --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,644 @@ +import { + HermesSettings, + ProviderDriverKind, + TextGenerationError, + type HermesGatewayCommandsCatalogResult, + type HermesGatewayFastConfigResult, + type HermesGatewayModelOptionsResult, + type HermesGatewayReasoningConfigResult, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { assessHermesConnectionSecurity } from "../../hermes/HermesConnectionSecurity.ts"; +import { HermesGatewayClient } from "../../hermes/HermesGatewayClient.ts"; +import { + makeHermesServeRuntime, + type HermesServeOwnership, +} from "../../hermes/HermesServeRuntime.ts"; +import { + makeHermesServeAdapterV2Driver, + resolveHermesGatewayToken, + resolveHermesRemotePairingToken, + resolveHermesRemoteTlsCertificateSha256, + type HermesServeAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/HermesServeAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { makeHermesSessionCatalog } from "../../hermes/HermesSessionCatalog.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const decodeSettings = Schema.decodeSync(HermesSettings); + +const unsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes instances do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +const HERMES_REASONING_EFFORTS = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", + "ultra", +] as const; + +const reasoningLabel = (effort: string): string => + effort === "xhigh" ? "Extra High" : effort.charAt(0).toUpperCase() + effort.slice(1); + +const HERMES_FALLBACK_COMMANDS = [ + ["start", "Acknowledge platform start pings without a reply"], + ["new", "Start a new chat (usage: /new [name])"], + ["clear", "Clear the visible T3 Work timeline without resetting Hermes context"], + ["topic", "Enable or inspect Telegram DM topic sessions (usage: /topic [off|help|session-id])"], + ["retry", "Retry the last message (resend to agent)"], + ["undo", "Back up N user turns and re-prompt (default 1) (usage: /undo [N])"], + ["title", "Set a title for the current session (usage: /title [name])"], + ["branch", "Branch the current session (explore a different path) (usage: /branch [name])"], + [ + "compress", + "Compress conversation context (usage: /compress [here [N] | focus topic | --preview|--dry-run])", + ], + ["rollback", "List or restore filesystem checkpoints (usage: /rollback [number])"], + ["stop", "Kill all running background processes"], + ["approve", "Approve a pending dangerous command (usage: /approve [session|always])"], + ["deny", "Deny a pending dangerous command (usage: /deny [all] [reason])"], + ["background", "Run a prompt in the background (usage: /background )"], + ["agents", "Show active agents and running tasks"], + ["queue", "Queue a prompt for the next turn (usage: /queue )"], + ["steer", "Inject a message after the next tool call (usage: /steer )"], + [ + "goal", + "Set or manage a standing goal (usage: /goal [text | draft | show | pause | resume | clear | status | wait | unwait])", + ], + ["moa", "Run one prompt through the default Mixture of Agents preset (usage: /moa )"], + [ + "subgoal", + "Add or manage criteria on the active goal (usage: /subgoal [text | remove N | clear])", + ], + ["status", "Show session, model, token, and context info"], + ["egress", "Show Docker egress proxy status (usage: /egress [status])"], + ["whoami", "Show your slash command access"], + ["profile", "Show active profile name and home directory"], + ["sethome", "Set this chat as the home channel"], + ["resume", "Resume a previously-named session (usage: /resume [name])"], + ["sessions", "Browse and resume previous sessions"], + [ + "model", + "Switch model (usage: /model [model] [--provider name] [--global|--session] [--refresh])", + ], + [ + "codex-runtime", + "Toggle codex app-server runtime for OpenAI/Codex models (usage: /codex-runtime [auto|codex_app_server])", + ], + ["personality", "Set a predefined personality (usage: /personality [name])"], + ["verbose", "Cycle tool progress display: off → new → all → verbose → log"], + ["footer", "Toggle gateway runtime-metadata footer (usage: /footer [on|off|status])"], + ["yolo", "Toggle YOLO mode"], + ["reasoning", "Manage reasoning effort and display"], + ["fast", "Toggle fast processing mode (usage: /fast [normal|fast|status] [--global])"], + ["voice", "Toggle voice mode (usage: /voice [on|off|tts|status])"], + ["skills", "Search, install, inspect, or manage skills"], + ["memory", "Review pending memory writes or toggle the approval gate"], + ["bundles", "List skill bundles"], + ["learn", "Learn a reusable skill (usage: /learn )"], + ["suggestions", "Review suggested automations"], + ["blueprint", "Set up an automation from a blueprint template"], + ["curator", "Manage background skill maintenance"], + ["kanban", "Manage the multi-profile collaboration board"], + ["reload-mcp", "Reload MCP servers from config"], + ["reload-skills", "Re-scan installed skills"], + ["commands", "Browse all commands and skills (usage: /commands [page])"], + ["help", "Show available commands"], + ["restart", "Gracefully restart the gateway after draining active runs"], + ["usage", "Show token usage and rate limits"], + ["topup", "Show Nous balance and billing options"], + ["insights", "Show usage insights and analytics (usage: /insights [days])"], + [ + "platform", + "Pause, resume, or list a failing gateway platform (usage: /platform [name])", + ], + ["update", "Update Hermes Agent to the latest version"], + ["version", "Show Hermes Agent version"], + ["debug", "Upload a debug report (usage: /debug [nous|local])"], +] as const; + +export const HERMES_FALLBACK_COMMAND_CATALOG: HermesGatewayCommandsCatalogResult = { + pairs: HERMES_FALLBACK_COMMANDS.map(([name, description]) => [`/${name}`, description]), + canon: { + "/reset": "/new", + "/fork": "/branch", + "/compact": "/compress", + "/bg": "/background", + "/btw": "/background", + "/tasks": "/agents", + "/q": "/queue", + "/set-home": "/sethome", + "/codex_runtime": "/codex-runtime", + "/suggest": "/suggestions", + "/bp": "/blueprint", + "/reload_mcp": "/reload-mcp", + "/reload_skills": "/reload-skills", + "/v": "/version", + }, + sub: { + "/topic": ["off", "help", "session-id"], + "/approve": ["session", "always"], + "/deny": ["all"], + "/goal": ["draft", "show", "pause", "resume", "clear", "status", "wait", "unwait"], + "/subgoal": ["remove", "clear"], + "/egress": ["status"], + "/codex-runtime": ["auto", "codex_app_server"], + "/verbose": ["off", "new", "all", "verbose", "log"], + "/footer": ["on", "off", "status"], + "/reasoning": [...HERMES_REASONING_EFFORTS, "show", "hide", "full", "clamp", "--global"], + "/fast": ["normal", "fast", "status", "on", "off", "--global"], + "/voice": ["on", "off", "tts", "status"], + "/skills": [ + "search", + "browse", + "inspect", + "install", + "audit", + "pending", + "approve", + "reject", + "diff", + "approval", + ], + "/memory": ["pending", "approve", "reject", "approval"], + "/suggestions": ["accept", "dismiss", "catalog", "clear"], + "/curator": ["status", "run", "pause", "resume", "pin", "unpin", "restore", "list-archived"], + "/platform": ["pause", "resume", "list"], + }, + warning: "Using the built-in Hermes command catalog because gateway probing is unavailable.", +}; + +const displayModelName = (model: string): string => { + const parts = model.replace(/^.*[/:]/u, "").split("-"); + if (/^gpt$/iu.test(parts[0] ?? "") && /^\d/u.test(parts[1] ?? "")) { + return [ + `GPT-${parts[1]}`, + ...parts.slice(2).map((part) => part.charAt(0).toUpperCase() + part.slice(1)), + ].join(" "); + } + return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" "); +}; + +export function hermesProviderModels( + inventory: HermesGatewayModelOptionsResult | undefined, + reasoning: HermesGatewayReasoningConfigResult | undefined, + fast: HermesGatewayFastConfigResult | undefined, + fallbackModels: ReadonlyArray, +): ReadonlyArray { + const effectiveModel = inventory?.model?.trim(); + const inventoryModels = (() => { + const bySlug = new Map< + string, + ServerProviderModel & { + readonly providerCapabilities?: { + readonly fast?: boolean | undefined; + readonly reasoning?: boolean | undefined; + }; + } + >(); + for (const provider of inventory?.providers ?? []) { + for (const model of provider.models ?? []) { + if (bySlug.has(model)) continue; + const providerCapabilities = provider.capabilities?.[model]; + bySlug.set(model, { + slug: model, + name: displayModelName(model), + subProvider: provider.name, + isCustom: false, + capabilities: null, + ...(providerCapabilities === undefined ? {} : { providerCapabilities }), + }); + } + } + return [...bySlug.values()]; + })(); + const models: Array< + ServerProviderModel & { + readonly providerCapabilities?: { + readonly fast?: boolean | undefined; + readonly reasoning?: boolean | undefined; + }; + } + > = + inventoryModels.length > 0 && effectiveModel + ? [ + { + slug: "default", + name: displayModelName(effectiveModel), + isCustom: false, + isDefault: true, + capabilities: null, + ...(() => { + const providerCapabilities = inventoryModels.find( + (model) => model.slug === effectiveModel, + )?.providerCapabilities; + return providerCapabilities === undefined ? {} : { providerCapabilities }; + })(), + }, + ...inventoryModels.filter((model) => model.slug !== "default"), + ] + : Array.from(new Set(fallbackModels)).map((model) => ({ + slug: model, + name: model === "default" ? "Hermes configured model" : displayModelName(model), + isCustom: model !== "default", + ...(model === "default" ? { isDefault: true } : {}), + capabilities: null, + })); + const effectiveEffort = reasoning?.value || "medium"; + return models.map(({ providerCapabilities, ...model }) => { + const optionDescriptors = []; + if (providerCapabilities?.reasoning !== false) { + optionDescriptors.push({ + id: "reasoningEffort", + label: "Reasoning", + type: "select" as const, + options: HERMES_REASONING_EFFORTS.map((effort) => ({ + id: effort, + label: reasoningLabel(effort), + ...(effort === effectiveEffort ? { isDefault: true } : {}), + })), + currentValue: effectiveEffort, + }); + } + if (providerCapabilities?.fast === true) { + const effectiveFast = fast?.value ?? "normal"; + optionDescriptors.push({ + id: "fast", + label: "Processing", + type: "select" as const, + options: [ + { + id: "normal", + label: "Normal", + ...(effectiveFast === "normal" ? { isDefault: true } : {}), + }, + { id: "fast", label: "Fast", ...(effectiveFast === "fast" ? { isDefault: true } : {}) }, + ], + currentValue: effectiveFast, + }); + } + return { + ...model, + capabilities: + optionDescriptors.length === 0 ? null : createModelCapabilities({ optionDescriptors }), + }; + }); +} + +export function hermesSlashCommands( + catalog: HermesGatewayCommandsCatalogResult | undefined, +): ReadonlyArray { + const effectiveCatalog = catalog?.pairs === undefined ? HERMES_FALLBACK_COMMAND_CATALOG : catalog; + const catalogPairs = effectiveCatalog.pairs ?? HERMES_FALLBACK_COMMAND_CATALOG.pairs ?? []; + const seen = new Set(); + const commands = catalogPairs.flatMap(([wireName, rawDescription]) => { + const name = wireName.replace(/^\/+/u, "").trim(); + if (!name || seen.has(name)) return []; + seen.add(name); + const description = rawDescription.trim(); + const subcommands = effectiveCatalog.sub?.[name] ?? effectiveCatalog.sub?.[wireName] ?? []; + const usageHint = description.match(/\(usage:\s*\/\S+\s+(.+)\)$/iu)?.[1]?.trim(); + const inputHint = subcommands.length > 0 ? subcommands.join(" | ") : usageHint; + return [ + { + name, + ...(description ? { description } : {}), + ...(inputHint ? { input: { hint: inputHint } } : {}), + }, + ]; + }); + for (const [aliasWireName, canonicalWireName] of Object.entries(effectiveCatalog.canon ?? {})) { + const alias = aliasWireName.replace(/^\/+/u, "").trim(); + const canonical = canonicalWireName.replace(/^\/+/u, "").trim(); + if (!alias || alias === canonical || seen.has(alias)) continue; + seen.add(alias); + const canonicalInput = commands.find((command) => command.name === canonical)?.input; + commands.push({ + name: alias, + description: `Alias for /${canonical}`, + ...(canonicalInput ? { input: canonicalInput } : {}), + }); + } + for (const native of [ + { name: "new", description: "Start a new chat" }, + { name: "reset", description: "Start a new chat" }, + { + name: "clear", + description: "Clear the visible T3 Work timeline without resetting Hermes context", + }, + ] as const) { + if (seen.has(native.name)) continue; + seen.add(native.name); + commands.push(native); + } + return commands; +} + +function snapshot(input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly enabled: boolean; + readonly settings: HermesSettings; + readonly gatewayToken: string | undefined; + readonly remotePairingToken: string | undefined; + readonly remoteTlsCertificateSha256: string | undefined; + readonly continuationKey: string; + readonly checkedAt: string; + readonly inventory?: { + readonly commands?: HermesGatewayCommandsCatalogResult; + readonly models?: HermesGatewayModelOptionsResult; + readonly reasoning?: HermesGatewayReasoningConfigResult; + readonly fast?: HermesGatewayFastConfigResult; + }; + readonly inventoryWarning?: string; + readonly effectiveEndpoint: string; + readonly connectionOwnership?: HermesServeOwnership; +}): ServerProvider { + const models = hermesProviderModels( + input.inventory?.models, + input.inventory?.reasoning, + input.inventory?.fast, + ["default", ...input.settings.customModels], + ); + const hasProfileKey = input.settings.profileKey.trim().length > 0; + const connectionSecurity = input.effectiveEndpoint + ? assessHermesConnectionSecurity({ + endpoint: input.effectiveEndpoint, + gatewayToken: input.gatewayToken, + remoteGloballyEnabled: input.enabled, + remoteInstanceEnabled: input.settings.remoteAccessEnabled, + remotePairingToken: input.remotePairingToken, + remoteTlsCertificateSha256: input.remoteTlsCertificateSha256, + }) + : undefined; + const hasGatewayToken = input.gatewayToken !== undefined; + const isConfigured = hasProfileKey && connectionSecurity?.status === "ready"; + const isUnauthenticated = + !input.enabled || + !hasGatewayToken || + (connectionSecurity?.status !== "ready" && + connectionSecurity?.code === "remote_pairing_required") || + (!hasGatewayToken && connectionSecurity?.scope === "loopback"); + const isAuthenticated = + hasGatewayToken && + input.inventory !== undefined && + Object.values(input.inventory).some((value) => value !== undefined); + const configurationMessage = !input.enabled + ? undefined + : !hasProfileKey + ? "Configure a Hermes profile key before starting a thread." + : !hasGatewayToken + ? "Add a sensitive HERMES_GATEWAY_TOKEN. T3 will use it to attach to an existing Hermes Serve instance or securely launch its own." + : connectionSecurity?.status === "ready" + ? (input.inventoryWarning ?? + (input.connectionOwnership === "t3_owned" + ? "Running a private Hermes Serve process managed by T3 Work." + : input.connectionOwnership === "external" + ? "Attached to an existing Hermes Serve instance." + : undefined)) + : connectionSecurity?.message; + return { + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationKey }, + enabled: input.enabled, + installed: true, + version: null, + status: !input.enabled + ? "disabled" + : isConfigured && input.inventoryWarning === undefined + ? "ready" + : "warning", + auth: { + status: isUnauthenticated ? "unauthenticated" : isAuthenticated ? "authenticated" : "unknown", + }, + checkedAt: input.checkedAt, + ...(configurationMessage === undefined ? {} : { message: configurationMessage }), + models, + slashCommands: hermesSlashCommands(input.inventory?.commands), + skills: [], + }; +} + +export type HermesDriverEnv = + | HermesServeAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const gatewayToken = resolveHermesGatewayToken(environment); + const connectionRuntime = yield* makeHermesServeRuntime({ + endpoint: config.endpoint, + authToken: gatewayToken, + managedServerEnabled: config.managedServerEnabled, + processEnvironment: mergeProviderInstanceEnvironment(environment), + }); + const orchestrationAdapter = yield* makeHermesServeAdapterV2Driver( + { + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }, + { connectionRuntime }, + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Hermes orchestration adapter.", + cause, + }), + ), + ); + const remotePairingToken = resolveHermesRemotePairingToken(environment); + const remoteTlsCertificateSha256 = resolveHermesRemoteTlsCertificateSha256(environment); + let inventory: + | { + readonly commands?: HermesGatewayCommandsCatalogResult; + readonly models?: HermesGatewayModelOptionsResult; + readonly reasoning?: HermesGatewayReasoningConfigResult; + readonly fast?: HermesGatewayFastConfigResult; + } + | undefined; + let inventoryWarning: string | undefined; + let connectionOwnership: HermesServeOwnership | undefined; + const currentSnapshot = () => + snapshot({ + instanceId, + displayName, + accentColor, + enabled, + settings: config, + gatewayToken, + remotePairingToken, + remoteTlsCertificateSha256, + continuationKey: continuationIdentity.continuationKey, + checkedAt, + effectiveEndpoint: connectionRuntime.effectiveEndpoint, + ...(inventory === undefined ? {} : { inventory }), + ...(inventoryWarning === undefined ? {} : { inventoryWarning }), + ...(connectionOwnership === undefined ? {} : { connectionOwnership }), + }); + const refreshSnapshot = Effect.gen(function* () { + if (!enabled || !config.profileKey.trim()) { + return currentSnapshot(); + } + inventory = undefined; + inventoryWarning = undefined; + connectionOwnership = undefined; + const resolvedConnection = yield* Effect.result(connectionRuntime.ensureReady); + if (resolvedConnection._tag === "Failure") { + inventoryWarning = resolvedConnection.failure.message; + return currentSnapshot(); + } + connectionOwnership = resolvedConnection.success.ownership; + const security = assessHermesConnectionSecurity({ + endpoint: resolvedConnection.success.endpoint, + gatewayToken: resolvedConnection.success.authToken, + remoteGloballyEnabled: enabled, + remoteInstanceEnabled: config.remoteAccessEnabled, + remotePairingToken, + remoteTlsCertificateSha256, + }); + if (security.status !== "ready") return currentSnapshot(); + if (security.scope !== "loopback") { + inventoryWarning = + "Hermes command/model inventory probing is unavailable for remote gateways in this build."; + return currentSnapshot(); + } + const client = new HermesGatewayClient({ + endpoint: security.endpoint, + authToken: security.authToken, + reconnect: { maxAttempts: 0 }, + }); + const probeResult = yield* Effect.tryPromise({ + try: async () => { + await client.connect(); + const [commandsResult, modelsResult, reasoningResult, fastResult] = + await Promise.allSettled([ + client.readCommandsCatalog(), + client.readModelOptions({ + explicit_only: true, + include_unconfigured: false, + }), + client.readReasoningConfig(), + client.readFastConfig(), + ]); + return { commandsResult, modelsResult, reasoningResult, fastResult }; + }, + catch: (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Hermes command and model inventory probe failed.", + cause, + }), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + client.close(); + }), + ), + Effect.result, + ); + if (probeResult._tag === "Failure") { + inventoryWarning = + "Hermes Serve connected, but its command and model inventory could not be read."; + return currentSnapshot(); + } + const { commandsResult, modelsResult, reasoningResult, fastResult } = probeResult.success; + inventory = { + ...(commandsResult.status === "fulfilled" ? { commands: commandsResult.value } : {}), + ...(modelsResult.status === "fulfilled" ? { models: modelsResult.value } : {}), + ...(reasoningResult.status === "fulfilled" ? { reasoning: reasoningResult.value } : {}), + ...(fastResult.status === "fulfilled" ? { fast: fastResult.value } : {}), + }; + inventoryWarning = + commandsResult.status === "fulfilled" + ? commandsResult.value.warning?.trim() || undefined + : "Hermes command probing is unavailable; using the built-in command catalog."; + return currentSnapshot(); + }); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot: { + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSnapshot: refreshSnapshot, + refresh: refreshSnapshot, + streamChanges: Stream.empty, + }, + orchestrationAdapter, + hermesSessionCatalog: makeHermesSessionCatalog({ + providerInstanceId: instanceId, + endpoint: connectionRuntime.effectiveEndpoint, + authToken: gatewayToken, + profileKey: config.profileKey, + importEnabled: config.importEnabled, + }), + textGeneration: unsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/OpenClawDriver.test.ts b/apps/server/src/provider/Drivers/OpenClawDriver.test.ts new file mode 100644 index 00000000000..668552e14c3 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenClawDriver.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { BUILT_IN_DRIVERS } from "../builtInDrivers.ts"; +import { OpenClawDriver } from "./OpenClawDriver.ts"; + +describe("OpenClawDriver", () => { + it("is a discoverable built-in ACP provider", () => { + expect(BUILT_IN_DRIVERS).toContain(OpenClawDriver); + expect(OpenClawDriver.driverKind).toBe("openclaw"); + expect(OpenClawDriver.metadata).toEqual({ + displayName: "OpenClaw", + supportsMultipleInstances: true, + }); + }); +}); diff --git a/apps/server/src/provider/Drivers/OpenClawDriver.ts b/apps/server/src/provider/Drivers/OpenClawDriver.ts new file mode 100644 index 00000000000..3017f30f148 --- /dev/null +++ b/apps/server/src/provider/Drivers/OpenClawDriver.ts @@ -0,0 +1,139 @@ +import { + OpenClawSettings, + ProviderDriverKind, + TextGenerationError, + type ServerProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + OpenClawAdapterV2Driver, + type OpenClawAdapterV2DriverEnv, +} from "../../orchestration-v2/Adapters/OpenClawAdapterV2.ts"; +import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + buildInitialOpenClawProviderSnapshot, + checkOpenClawProviderStatus, +} from "../Layers/OpenClawProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; + +const DRIVER_KIND = ProviderDriverKind.make("openclaw"); +const decodeSettings = Schema.decodeSync(OpenClawSettings); + +const makeUnsupportedTextGeneration = (): TextGenerationShape => { + const unsupported = (operation: string) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "OpenClaw sessions do not provide application text generation.", + }), + ); + return { + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), + }; +}; + +export type OpenClawDriverEnv = + | OpenClawAdapterV2DriverEnv + | ChildProcessSpawner.ChildProcessSpawner; + +export const OpenClawDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "OpenClaw", + supportsMultipleInstances: true, + }, + configSchema: OpenClawSettings, + defaultConfig: () => decodeSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const processEnvironment = mergeProviderInstanceEnvironment(environment); + const effectiveConfig = { ...config, enabled } satisfies OpenClawSettings; + const stampIdentity = (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId, + driver: DRIVER_KIND, + ...(displayName ? { displayName } : {}), + ...(accentColor ? { accentColor } : {}), + continuation: { groupKey: continuationIdentity.continuationKey }, + }); + + const orchestrationAdapter = yield* OpenClawAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenClaw ACP orchestration adapter.", + cause, + }), + ), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities: makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }), + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.empty, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialOpenClawProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider: checkOpenClawProviderStatus(effectiveConfig, processEnvironment).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ), + refreshInterval: "5 minutes", + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build OpenClaw provider diagnostics.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + orchestrationAdapter, + textGeneration: makeUnsupportedTextGeneration(), + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HermesAcpProvider.test.ts b/apps/server/src/provider/Layers/HermesAcpProvider.test.ts new file mode 100644 index 00000000000..8fb39665d4b --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAcpProvider.test.ts @@ -0,0 +1,81 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HermesAcpSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialHermesAcpProviderSnapshot, + checkHermesAcpProviderStatus, +} from "./HermesAcpProvider.ts"; + +const decodeSettings = Schema.decodeSync(HermesAcpSettings); + +describe("buildInitialHermesAcpProviderSnapshot", () => { + it.effect("keeps Hermes in Code distinct and pending until its ACP probe completes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialHermesAcpProviderSnapshot(decodeSettings({})); + expect(snapshot.displayName).toBe("Hermes in Code"); + expect(snapshot.badgeLabel).toBe("ACP"); + expect(snapshot.message).toContain("Hermes ACP"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + }), + ); +}); + +it.layer(NodeServices.layer)("checkHermesAcpProviderStatus", (it) => { + it.effect("rejects a Hermes executable without the ACP command", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-no-acp-" }); + const executable = path.join(directory, "hermes"); + yield* fs.writeFileString(executable, "#!/bin/sh\nexit 2\n"); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkHermesAcpProviderStatus( + decodeSettings({ binaryPath: executable }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("does not expose"); + }), + ), + ); + + it.effect("reports ready only when both ACP version and dependency checks pass", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-acp-" }); + const executable = path.join(directory, "hermes"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" != "acp" ]; then exit 9; fi', + 'if [ "$2" = "--version" ]; then printf "0.19.0\\n"; exit 0; fi', + 'if [ "$2" = "--check" ]; then printf "Hermes ACP check OK\\n"; exit 0; fi', + "exit 8", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkHermesAcpProviderStatus( + decodeSettings({ + binaryPath: executable, + customModels: ["custom/model"], + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("0.19.0"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default", "custom/model"]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesAcpProvider.ts b/apps/server/src/provider/Layers/HermesAcpProvider.ts new file mode 100644 index 00000000000..c99126c6356 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAcpProvider.ts @@ -0,0 +1,196 @@ +import { + type HermesAcpSettings, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const HERMES_ACP_PRESENTATION = { + displayName: "Hermes in Code", + badgeLabel: "ACP", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const PROBE_TIMEOUT_MS = 8_000; +const DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "Hermes default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; + +function hermesAcpModels(settings: HermesAcpSettings): ReadonlyArray { + return providerModelsFromSettings( + [DEFAULT_MODEL], + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runHermesCommand = ( + settings: HermesAcpSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "hermes"; + const spawn = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + }), + ); + }); + +export function buildInitialHermesAcpProviderSnapshot( + settings: HermesAcpSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: HERMES_ACP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: hermesAcpModels(settings), + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hermes ACP availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes in Code is disabled in T3 Code settings.", + }, + }); + }); +} + +export const checkHermesAcpProviderStatus = Effect.fn("checkHermesAcpProviderStatus")(function* ( + settings: HermesAcpSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = hermesAcpModels(settings); + const build = (probe: Parameters[0]["probe"]): ServerProviderDraft => + buildServerProvider({ + presentation: HERMES_ACP_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe, + }); + + if (!settings.enabled) { + return build({ + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes in Code is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runHermesCommand(settings, ["acp", "--version"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + return build({ + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "Hermes Agent CLI (`hermes`) is not installed or not on PATH." + : "Failed to execute the Hermes ACP version probe.", + }); + } + if (Option.isNone(versionResult.success)) { + return build({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Hermes Agent CLI timed out while checking `hermes acp --version`.", + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "This Hermes Agent CLI does not expose a working `hermes acp` stdio command.", + }); + } + + const checkResult = yield* runHermesCommand(settings, ["acp", "--check"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(checkResult)) { + return build({ + installed: !isCommandMissingCause(checkResult.failure), + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes ACP dependency check could not be executed.", + }); + } + if (Option.isNone(checkResult.success)) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes ACP dependency check timed out.", + }); + } + if (checkResult.success.value.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "Hermes Agent is installed, but its ACP adapter or protocol dependencies are unavailable.", + }); + } + + return build({ + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }); +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.test.ts b/apps/server/src/provider/Layers/OpenClawProvider.test.ts new file mode 100644 index 00000000000..e386f72e3e1 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.test.ts @@ -0,0 +1,110 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { OpenClawSettings } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + buildInitialOpenClawProviderSnapshot, + checkOpenClawProviderStatus, +} from "./OpenClawProvider.ts"; + +const decodeSettings = Schema.decodeSync(OpenClawSettings); + +describe("buildInitialOpenClawProviderSnapshot", () => { + it.effect("is pending until the OpenClaw ACP probe completes", () => + Effect.gen(function* () { + const snapshot = yield* buildInitialOpenClawProviderSnapshot(decodeSettings({})); + expect(snapshot.displayName).toBe("OpenClaw"); + expect(snapshot.badgeLabel).toBe("ACP"); + expect(snapshot.message).toContain("OpenClaw ACP"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default"]); + }), + ); +}); + +it.layer(NodeServices.layer)("checkOpenClawProviderStatus", (it) => { + it.effect("gracefully reports an unavailable OpenClaw executable", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-openclaw-missing-", + }); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ binaryPath: path.join(directory, "openclaw") }), + ); + expect(snapshot.installed).toBe(false); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("not installed"); + }), + ), + ); + + it.effect("rejects an OpenClaw executable without the ACP command", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-openclaw-no-acp-", + }); + const executable = path.join(directory, "openclaw"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then printf "OpenClaw 1.2.3\\n"; exit 0; fi', + "exit 2", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ binaryPath: executable }), + ); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("does not expose"); + }), + ), + ); + + it.effect("reports ready after safe version and ACP help probes", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-openclaw-acp-" }); + const executable = path.join(directory, "openclaw"); + yield* fs.writeFileString( + executable, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then printf "OpenClaw 1.2.3\\n"; exit 0; fi', + 'if [ "$1" = "acp" ] && [ "$2" = "--help" ]; then printf "ACP over stdio\\n"; exit 0; fi', + "exit 8", + "", + ].join("\n"), + ); + yield* fs.chmod(executable, 0o755); + + const snapshot = yield* checkOpenClawProviderStatus( + decodeSettings({ + binaryPath: executable, + customModels: ["custom/model"], + }), + ); + expect(snapshot.status).toBe("ready"); + expect(snapshot.version).toBe("1.2.3"); + expect(snapshot.models.map((model) => model.slug)).toEqual(["default", "custom/model"]); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenClawProvider.ts b/apps/server/src/provider/Layers/OpenClawProvider.ts new file mode 100644 index 00000000000..6b9872dcfb1 --- /dev/null +++ b/apps/server/src/provider/Layers/OpenClawProvider.ts @@ -0,0 +1,195 @@ +import { + type ModelCapabilities, + type OpenClawSettings, + type ServerProviderModel, +} from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const OPENCLAW_PRESENTATION = { + displayName: "OpenClaw", + badgeLabel: "ACP", + showInteractionModeToggle: true, + requiresNewThreadForModelChange: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const PROBE_TIMEOUT_MS = 8_000; +const DEFAULT_MODEL: ServerProviderModel = { + slug: "default", + name: "OpenClaw default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; + +function openClawModels(settings: OpenClawSettings): ReadonlyArray { + return providerModelsFromSettings( + [DEFAULT_MODEL], + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +const runOpenClawCommand = ( + settings: OpenClawSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv, +) => + Effect.gen(function* () { + const command = settings.binaryPath || "openclaw"; + const spawn = yield* resolveSpawnCommand(command, args, { env: environment }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawn.command, spawn.args, { + env: environment, + shell: spawn.shell, + }), + ); + }); + +export function buildInitialOpenClawProviderSnapshot( + settings: OpenClawSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models: openClawModels(settings), + probe: settings.enabled + ? { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking OpenClaw ACP availability...", + } + : { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }, + }); + }); +} + +export const checkOpenClawProviderStatus = Effect.fn("checkOpenClawProviderStatus")(function* ( + settings: OpenClawSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = openClawModels(settings); + const build = (probe: Parameters[0]["probe"]): ServerProviderDraft => + buildServerProvider({ + presentation: OPENCLAW_PRESENTATION, + enabled: settings.enabled, + checkedAt, + models, + probe, + }); + + if (!settings.enabled) { + return build({ + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "OpenClaw is disabled in T3 Code settings.", + }); + } + + const versionResult = yield* runOpenClawCommand(settings, ["--version"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionResult)) { + return build({ + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "OpenClaw CLI (`openclaw`) is not installed or not on PATH." + : "Failed to execute the OpenClaw version probe.", + }); + } + if (Option.isNone(versionResult.success)) { + return build({ + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw CLI timed out while checking `openclaw --version`.", + }); + } + + const versionOutput = versionResult.success.value; + const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "The OpenClaw version probe exited unsuccessfully.", + }); + } + + const helpResult = yield* runOpenClawCommand(settings, ["acp", "--help"], environment).pipe( + Effect.timeoutOption(PROBE_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(helpResult)) { + return build({ + installed: !isCommandMissingCause(helpResult.failure), + version, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw ACP help could not be executed.", + }); + } + if (Option.isNone(helpResult.success)) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "OpenClaw ACP help timed out.", + }); + } + if (helpResult.success.value.code !== 0) { + return build({ + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "This OpenClaw CLI does not expose a working `openclaw acp` command.", + }); + } + + return build({ + installed: true, + version, + status: "ready", + auth: { status: "unknown" }, + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts new file mode 100644 index 00000000000..0eb40d71000 --- /dev/null +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings, +} from "@t3tools/contracts"; + +import { deriveProviderInstanceConfigMap } from "./ProviderInstanceRegistryHydration.ts"; + +const hermesId = ProviderInstanceId.make("hermes_local"); + +function settings(enableHermes: boolean, instanceEnabled: boolean | undefined): ServerSettings { + return { + ...DEFAULT_SERVER_SETTINGS, + enableHermes, + providerInstances: { + [hermesId]: { + driver: ProviderDriverKind.make("hermes"), + ...(instanceEnabled === undefined ? {} : { enabled: instanceEnabled }), + config: { + endpoint: "ws://127.0.0.1:9119/api/ws", + profileKey: "real-profile", + }, + }, + }, + }; +} + +describe("Hermes provider-instance hydration gate", () => { + it.each([ + { global: false, instance: true, expected: false }, + { global: true, instance: false, expected: false }, + { global: true, instance: undefined, expected: false }, + { global: true, instance: true, expected: true }, + ])( + "requires global=$global and explicit instance=$instance", + ({ global, instance, expected }) => { + expect(deriveProviderInstanceConfigMap(settings(global, instance))[hermesId]?.enabled).toBe( + expected, + ); + }, + ); + + it("applies both gates to the built-in Hermes instance", () => { + const enabled = { + ...DEFAULT_SERVER_SETTINGS, + enableHermes: true, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + hermes: { + ...DEFAULT_SERVER_SETTINGS.providers.hermes, + enabled: true, + endpoint: "ws://127.0.0.1:9119/api/ws", + }, + }, + }; + const disabled = { + ...enabled, + enableHermes: false, + }; + const defaultId = ProviderInstanceId.make("hermes"); + + expect(deriveProviderInstanceConfigMap(enabled)[defaultId]?.enabled).toBe(true); + expect(deriveProviderInstanceConfigMap(disabled)[defaultId]?.enabled).toBe(false); + }); + + it("requires an independent global and per-instance opt-in for remote Hermes", () => { + const base: ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + enableHermes: true, + providerInstances: { + [hermesId]: { + driver: ProviderDriverKind.make("hermes"), + enabled: true, + config: { + endpoint: "wss://gateway.example.com/api/ws", + profileKey: "remote-profile", + remoteAccessEnabled: true, + }, + }, + }, + }; + const remoteInstance = base.providerInstances[hermesId]!; + + expect(deriveProviderInstanceConfigMap(base)[hermesId]?.enabled).toBe(false); + expect( + deriveProviderInstanceConfigMap({ ...base, enableRemoteHermes: true })[hermesId]?.enabled, + ).toBe(true); + expect( + deriveProviderInstanceConfigMap({ + ...base, + enableRemoteHermes: true, + providerInstances: { + [hermesId]: { + ...remoteInstance, + config: { + ...(remoteInstance.config as Record), + remoteAccessEnabled: false, + }, + }, + }, + })[hermesId]?.enabled, + ).toBe(false); + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts index 0b28cd1bbe3..f3c558b1cee 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryHydration.ts @@ -49,8 +49,13 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { + HermesSessionBindingRepository, + HermesSessionBindingRepositoryError, +} from "../../hermes/HermesSessionBindingRepository.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { BUILT_IN_DRIVERS, type BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ProviderInstanceRegistry } from "../Services/ProviderInstanceRegistry.ts"; @@ -61,10 +66,70 @@ import { ProviderOrchestrationAdapterInfrastructureLive, } from "./ProviderOrchestrationAdapterInfrastructure.ts"; +// Hermes persistence is late-bound by the production server wrapper. Keeping +// it out of this base layer's public environment lets non-Hermes test/build +// compositions hydrate the other built-in drivers without constructing a +// database-backed Hermes repository. type ProviderInstanceRegistryHydrationEnv = - | Exclude + | Exclude< + BuiltInDriversEnv, + ProviderOrchestrationAdapterInfrastructure | HermesSessionBindingRepository + > | ServerSettingsService; +const unavailableHermesRepositoryOperation = ( + operation: string, +): Effect.Effect => + Effect.fail( + new HermesSessionBindingRepositoryError({ + operation, + detail: "Hermes persistence was not supplied to this runtime composition.", + }), + ); + +const UnavailableHermesSessionBindingRepository = HermesSessionBindingRepository.of({ + createBinding: () => unavailableHermesRepositoryOperation("createBinding"), + getByThreadId: () => unavailableHermesRepositoryOperation("getByThreadId"), + getByStoredIdentity: () => unavailableHermesRepositoryOperation("getByStoredIdentity"), + updateNegotiation: () => unavailableHermesRepositoryOperation("updateNegotiation"), + updateReconciliation: () => unavailableHermesRepositoryOperation("updateReconciliation"), + updateTitleState: () => unavailableHermesRepositoryOperation("updateTitleState"), + acquireOwnerLease: () => unavailableHermesRepositoryOperation("acquireOwnerLease"), + renewOwnerLease: () => unavailableHermesRepositoryOperation("renewOwnerLease"), + releaseOwnerLease: () => unavailableHermesRepositoryOperation("releaseOwnerLease"), + prepareMutationIntent: () => unavailableHermesRepositoryOperation("prepareMutationIntent"), + prepareSessionCreateIntent: () => + unavailableHermesRepositoryOperation("prepareSessionCreateIntent"), + transitionSessionCreateIntent: () => + unavailableHermesRepositoryOperation("transitionSessionCreateIntent"), + transitionMutationIntent: () => unavailableHermesRepositoryOperation("transitionMutationIntent"), + getMutationIntent: () => unavailableHermesRepositoryOperation("getMutationIntent"), + listUnsettledMutationIntents: () => + unavailableHermesRepositoryOperation("listUnsettledMutationIntents"), + prepareSessionImport: () => unavailableHermesRepositoryOperation("prepareSessionImport"), + getSessionImportByStoredIdentity: () => + unavailableHermesRepositoryOperation("getSessionImportByStoredIdentity"), + getMainSessionImport: () => unavailableHermesRepositoryOperation("getMainSessionImport"), + transitionSessionImport: () => unavailableHermesRepositoryOperation("transitionSessionImport"), + listHistoryThreadIds: () => unavailableHermesRepositoryOperation("listHistoryThreadIds"), + clearHistoryRecords: () => unavailableHermesRepositoryOperation("clearHistoryRecords"), +}); + +const remoteHermesEnabled = (settings: ServerSettings, config: unknown): boolean => { + if (typeof config !== "object" || config === null) return true; + const endpoint = Reflect.get(config, "endpoint"); + if (typeof endpoint !== "string" || endpoint.trim() === "") return true; + let remote = true; + try { + remote = !["127.0.0.1", "localhost", "::1"].includes(new URL(endpoint).hostname); + } catch { + // Invalid endpoints remain visible to the provider's configuration diagnostics. + return true; + } + if (!remote) return true; + return settings.enableRemoteHermes && Reflect.get(config, "remoteAccessEnabled") === true; +}; + /** * Synthesize a `ProviderInstanceConfigMap` from a `ServerSettings` snapshot. * @@ -83,6 +148,20 @@ export const deriveProviderInstanceConfigMap = ( ): ProviderInstanceConfigMap => { const merged: Record = { ...settings.providerInstances }; + for (const [instanceId, entry] of Object.entries(merged)) { + if (entry.driver !== "hermes") continue; + // Hermes is intentionally gated twice: the global rollout flag must be + // enabled and the explicit instance must opt in. Missing `enabled` is + // therefore false for Hermes even though other providers default it true. + merged[instanceId] = { + ...entry, + enabled: + settings.enableHermes && + entry.enabled === true && + remoteHermesEnabled(settings, entry.config), + }; + } + for (const driver of BUILT_IN_DRIVERS) { const instanceId = defaultInstanceIdForDriver(driver.driverKind); if (instanceId in merged) { @@ -104,6 +183,14 @@ export const deriveProviderInstanceConfigMap = ( merged[instanceId] = { driver: driver.driverKind, + ...(driver.driverKind === "hermes" + ? { + enabled: + settings.enableHermes && + legacyConfig.enabled === true && + remoteHermesEnabled(settings, legacyConfig), + } + : {}), config: legacyConfig, }; } @@ -164,6 +251,7 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< > = Layer.unwrap( Effect.gen(function* () { const serverSettings = yield* ServerSettingsService; + const hermesRepository = yield* Effect.serviceOption(HermesSessionBindingRepository); const initialSettings: ServerSettings | undefined = yield* serverSettings.getSettings.pipe( Effect.orElseSucceed(() => undefined), ); @@ -175,8 +263,18 @@ export const ProviderInstanceRegistryHydrationLive: Layer.Layer< const mutableLayer = ProviderInstanceRegistryMutableLayer({ drivers: BUILT_IN_DRIVERS, configMap: initialConfigMap, - }).pipe(Layer.provide(ProviderOrchestrationAdapterInfrastructureLive)); + }).pipe( + Layer.provide( + Layer.mergeAll( + ProviderOrchestrationAdapterInfrastructureLive, + Layer.succeed( + HermesSessionBindingRepository, + Option.getOrElse(hermesRepository, () => UnavailableHermesSessionBindingRepository), + ), + ), + ), + ); return SettingsWatcherLive.pipe(Layer.provideMerge(mutableLayer)); }), -) as Layer.Layer; +); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dd5155f7786..c83a66530a1 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -39,6 +39,7 @@ import * as Layer from "effect/Layer"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../../config.ts"; +import type { HermesSessionBindingRepository } from "../../hermes/HermesSessionBindingRepository.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; @@ -310,7 +311,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }, }; - const { registry } = yield* makeProviderInstanceRegistry({ + const { registry } = yield* makeProviderInstanceRegistry< + Exclude + >({ drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], configMap, }); diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index 50205103e3b..6b3339abfec 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -32,6 +32,7 @@ import type * as Scope from "effect/Scope"; import type { TextGenerationShape } from "../textGeneration/TextGeneration.ts"; import type { ProviderAdapterV2Shape } from "../orchestration-v2/ProviderAdapter.ts"; +import type { HermesSessionCatalogShape } from "../hermes/HermesSessionCatalog.ts"; import type { ProviderDriverError } from "./Errors.ts"; import type { ServerProviderShape } from "./Services/ServerProvider.ts"; @@ -71,6 +72,11 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly orchestrationAdapter: ProviderAdapterV2Shape; readonly textGeneration: TextGenerationShape; + /** + * Provider-owned, credential-encapsulated durable session inventory. + * Present only for Hermes instances. + */ + readonly hermesSessionCatalog?: HermesSessionCatalogShape; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/acp/HermesAcpSupport.test.ts b/apps/server/src/provider/acp/HermesAcpSupport.test.ts new file mode 100644 index 00000000000..dd9f6602564 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildHermesAcpSpawnInput } from "./HermesAcpSupport.ts"; + +describe("HermesAcpSupport", () => { + it("launches the genuine ACP stdio command instead of the Work gateway", () => { + const spawn = buildHermesAcpSpawnInput( + { binaryPath: "/opt/hermes/bin/hermes" }, + "/workspace/project", + { + EXISTING: "kept", + HERMES_ACP_SKIP_CONFIGURED_MCP: "0", + }, + ); + + expect(spawn).toEqual({ + command: "/opt/hermes/bin/hermes", + args: ["acp"], + cwd: "/workspace/project", + env: { + EXISTING: "kept", + HERMES_ACP_SKIP_CONFIGURED_MCP: "1", + }, + }); + expect(spawn.args).not.toContain("serve"); + }); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 00000000000..1dd21e1be2c --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,73 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { type HermesAcpSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const HERMES_SKIP_CONFIGURED_MCP_ENV = "HERMES_ACP_SKIP_CONFIGURED_MCP"; + +type HermesAcpRuntimeSettings = Pick; + +interface HermesAcpRuntimeInput extends Omit { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeSettings; + readonly environment?: NodeJS.ProcessEnv; +} + +/** + * Hermes Agent's ACP entrypoint is a JSON-RPC stdio server. It is deliberately + * separate from `hermes serve`, whose websocket protocol powers Hermes Work. + */ +export function buildHermesAcpSpawnInput( + settings: HermesAcpRuntimeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: settings.binaryPath || "hermes", + args: ["acp"], + cwd, + env: { + ...environment, + // T3 supplies its scoped MCP endpoint in session/new and session/load. + // Avoid also importing the user's global Hermes MCP configuration into + // this separately supervised provider process. + [HERMES_SKIP_CONFIGURED_MCP_ENV]: "1", + }, + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const processGroupPlatform = yield* HostProcessPlatform.pipe( + Effect.provide(NodeServices.layer), + ); + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + // Hermes tools may create child processes. Keep the ACP process and its + // process tree inside the runtime's owned teardown boundary. + ownDetachedProcessGroup: true, + ownDescendantProcessGroups: processGroupPlatform === "linux", + processGroupPlatform, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); + }); diff --git a/apps/server/src/provider/acp/OpenClawSupport.test.ts b/apps/server/src/provider/acp/OpenClawSupport.test.ts new file mode 100644 index 00000000000..774cf199b61 --- /dev/null +++ b/apps/server/src/provider/acp/OpenClawSupport.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildOpenClawSpawnInput } from "./OpenClawSupport.ts"; + +describe("OpenClawSupport", () => { + it("uses config and environment by default", () => { + expect( + buildOpenClawSpawnInput( + { + binaryPath: "openclaw", + url: "", + tokenFile: "", + passwordFile: "", + session: "", + resetSession: false, + }, + "/workspace/project", + { OPENCLAW_CONFIG_DIR: "/config/openclaw" }, + ), + ).toEqual({ + command: "openclaw", + args: ["acp"], + cwd: "/workspace/project", + env: { OPENCLAW_CONFIG_DIR: "/config/openclaw" }, + }); + }); + + it("passes only documented non-secret and credential-file overrides", () => { + const spawn = buildOpenClawSpawnInput( + { + binaryPath: "/opt/openclaw/bin/openclaw", + url: "wss://gateway.example.com", + tokenFile: "/run/secrets/openclaw-token", + passwordFile: "/run/secrets/openclaw-password", + session: "agent:main:main", + resetSession: true, + }, + "/workspace/project", + ); + + expect(spawn.args).toEqual([ + "acp", + "--url", + "wss://gateway.example.com", + "--token-file", + "/run/secrets/openclaw-token", + "--password-file", + "/run/secrets/openclaw-password", + "--session", + "agent:main:main", + "--reset-session", + ]); + expect(spawn.args).not.toContain("--token"); + expect(spawn.args).not.toContain("--password"); + }); +}); diff --git a/apps/server/src/provider/acp/OpenClawSupport.ts b/apps/server/src/provider/acp/OpenClawSupport.ts new file mode 100644 index 00000000000..523ceead501 --- /dev/null +++ b/apps/server/src/provider/acp/OpenClawSupport.ts @@ -0,0 +1,74 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { type OpenClawSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type OpenClawRuntimeSettings = Pick< + OpenClawSettings, + "binaryPath" | "passwordFile" | "resetSession" | "session" | "tokenFile" | "url" +>; + +interface OpenClawRuntimeInput extends Omit { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly openClawSettings: OpenClawRuntimeSettings; + readonly environment?: NodeJS.ProcessEnv; +} + +/** + * Builds the official OpenClaw ACP stdio entrypoint. Authentication remains in + * OpenClaw config/environment unless the user supplies a credential file path; + * credential values are never copied into argv. + */ +export function buildOpenClawSpawnInput( + settings: OpenClawRuntimeSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const args = ["acp"]; + if (settings.url) args.push("--url", settings.url); + if (settings.tokenFile) args.push("--token-file", settings.tokenFile); + if (settings.passwordFile) args.push("--password-file", settings.passwordFile); + if (settings.session) args.push("--session", settings.session); + if (settings.resetSession) args.push("--reset-session"); + + return { + command: settings.binaryPath || "openclaw", + args, + cwd, + ...(environment === undefined ? {} : { env: environment }), + }; +} + +export const makeOpenClawRuntime = ( + input: OpenClawRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const processGroupPlatform = yield* HostProcessPlatform.pipe( + Effect.provide(NodeServices.layer), + ); + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildOpenClawSpawnInput(input.openClawSettings, input.cwd, input.environment), + ownDetachedProcessGroup: true, + ownDescendantProcessGroups: processGroupPlatform === "linux", + processGroupPlatform, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); + }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index bbff99705d2..3eef461b8bd 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -25,6 +25,9 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HermesAcpDriver, type HermesAcpDriverEnv } from "./Drivers/HermesAcpDriver.ts"; +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; +import { OpenClawDriver, type OpenClawDriverEnv } from "./Drivers/OpenClawDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -39,6 +42,9 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesAcpDriverEnv + | HermesDriverEnv + | OpenClawDriverEnv | OpenCodeDriverEnv; /** @@ -53,4 +59,7 @@ export const BUILT_IN_DRIVERS: ReadonlyArray; +const ProviderInstanceRegistryHydrationWithHermesLive = ProviderInstanceRegistryHydrationLive.pipe( + Layer.provide(HermesPersistenceLayerLive), +); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), @@ -273,7 +291,8 @@ const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), - Layer.provideMerge(PersistenceLayerLive), + // The repository and every other persistence consumer share one SqlClient. + Layer.provideMerge(HermesPersistenceLayerLive), Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), // The instance registry is the new routing keystone — text generation, @@ -281,7 +300,11 @@ const RuntimeCoreDependenciesBaseLive = AgentAwarenessRelay.layer.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge(ProviderInstanceRegistryHydrationWithHermesLive), +); + +const HermesCronWithServerSettingsLayerLive = HermesCron.layer.pipe( + Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), ); const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( @@ -292,7 +315,7 @@ const RuntimeCoreDependenciesLive = RuntimeCoreDependenciesBaseLive.pipe( // no longer transitively provides it. Exposing it at the runtime level // keeps a single Live for all opencode consumers. Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), - Layer.provideMerge(ServerSettings.layer.pipe(Layer.provide(ServerSecretStore.layer))), + Layer.provideMerge(HermesCronWithServerSettingsLayerLive), Layer.provideMerge(WorkspaceLayerLive), Layer.provideMerge(ProjectEnrichmentService.layer), Layer.provideMerge(ProjectFaviconResolverLayerLive), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9d85c1a3f37..ba13d52e4a6 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -74,6 +74,8 @@ import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as ThreadManagementService from "./orchestration-v2/ThreadManagementService.ts"; import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as HermesCron from "./hermes/HermesCron.ts"; +import * as HermesSessionImport from "./hermes/HermesSessionImportService.ts"; import { archivedShellStreamItemFromSnapshot, coalesceShellApplicationEvents, @@ -141,7 +143,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun readonly threadId: ThreadId; readonly messageId: MessageId; readonly attachments: ReadonlyArray<{ - readonly type: "image"; + readonly type: "image" | "file" | "pdf" | "video"; readonly name: string; readonly mimeType: string; readonly sizeBytes: number; @@ -157,7 +159,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun const parsed = parseBase64DataUrl(attachment.dataUrl); if (parsed === null || parsed.mimeType !== attachment.mimeType.toLowerCase()) { return yield* new PersistChatAttachmentsError({ - message: `Attachment ${attachment.name} has an invalid image payload.`, + message: `Attachment ${attachment.name} has an invalid payload.`, }); } const bytes = yield* Effect.fromResult(Encoding.decodeBase64(parsed.base64)).pipe( @@ -181,7 +183,7 @@ const persistChatAttachments = Effect.fn("ws.assets.persistChatAttachments")(fun }); } const persisted = { - type: "image" as const, + type: attachment.type, id: ChatAttachmentId.make(rawId), name: attachment.name, mimeType: attachment.mimeType, @@ -340,6 +342,11 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.scheduledTasksSetEnabled, AuthOrchestrationOperateScope], [WS_METHODS.scheduledTasksDelete, AuthOrchestrationOperateScope], [WS_METHODS.scheduledTasksRunNow, AuthOrchestrationOperateScope], + [WS_METHODS.hermesCronList, AuthOrchestrationReadScope], + [WS_METHODS.hermesCronMutate, AuthOrchestrationOperateScope], + [WS_METHODS.hermesSessionsDiscover, AuthOrchestrationReadScope], + [WS_METHODS.hermesSessionsImport, AuthOrchestrationOperateScope], + [WS_METHODS.hermesHistoryReset, AuthOrchestrationOperateScope], [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], @@ -478,6 +485,8 @@ const makeWsRpcLayer = ( ); const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const hermesCron = yield* HermesCron.HermesCron; + const hermesSessions = yield* HermesSessionImport.make; const projectService = yield* ProjectService.ProjectService; const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery; const keybindings = yield* Keybindings.Keybindings; @@ -493,6 +502,7 @@ const makeWsRpcLayer = ( const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; @@ -602,6 +612,7 @@ const makeWsRpcLayer = ( environment, auth, cwd: config.cwd, + t3WorkDirectory: config.t3WorkDir ?? path.join(config.baseDir, "t3-work"), keybindingsConfigPath: config.keybindingsConfigPath, keybindings: keybindingsConfig.keybindings, issues: keybindingsConfig.issues, @@ -647,6 +658,7 @@ const makeWsRpcLayer = ( }), ), ); + yield* hermesSessions.hydrateThread(input.threadId); const eventStreamFrom = (afterSequence: number) => threadManagement @@ -1170,6 +1182,9 @@ const makeWsRpcLayer = ( runtimeMode: input.runtimeMode, interactionMode: input.interactionMode, workspaceStrategy: input.workspaceStrategy, + ...(input.prepareWorkspace === undefined + ? {} + : { prepareWorkspace: input.prepareWorkspace }), ...(input.initialMessage === undefined ? {} : { @@ -1252,6 +1267,26 @@ const makeWsRpcLayer = ( "rpc.aggregate": "scheduledTasks", "scheduled_task.id": input.id, }), + [WS_METHODS.hermesCronList]: (_input) => + observeRpcEffect(WS_METHODS.hermesCronList, hermesCron.list(), { + "rpc.aggregate": "hermesCron", + }), + [WS_METHODS.hermesCronMutate]: (input) => + observeRpcEffect(WS_METHODS.hermesCronMutate, hermesCron.mutate(input), { + "rpc.aggregate": "hermesCron", + }), + [WS_METHODS.hermesSessionsDiscover]: (input) => + observeRpcEffect(WS_METHODS.hermesSessionsDiscover, hermesSessions.discover(input), { + "rpc.aggregate": "hermesSessions", + }), + [WS_METHODS.hermesSessionsImport]: (input) => + observeRpcEffect(WS_METHODS.hermesSessionsImport, hermesSessions.importSessions(input), { + "rpc.aggregate": "hermesSessions", + }), + [WS_METHODS.hermesHistoryReset]: (input) => + observeRpcEffect(WS_METHODS.hermesHistoryReset, hermesSessions.resetHistory(input), { + "rpc.aggregate": "hermesSessions", + }), [WS_METHODS.serverProbe]: (_input) => observeRpcEffect(WS_METHODS.serverProbe, Effect.succeed({}), { "rpc.aggregate": "server", diff --git a/apps/web/public/hermes-agent-logo.png b/apps/web/public/hermes-agent-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..961df2cbeb34530643e5d9480fed93eea64dc683 GIT binary patch literal 41903 zcmX_IV{|1=*Ntu4nu(K%C$=ZHIk9cqHYc3ewlT47-RQ>p?(^gQe)Ov9?&?*myHA}y zXYYMF@|S`nG6Det7#J9`w3L`KsC@?m0|$eH0rk>j2F0F`puaaio~})=ILYA?^{+BqLaNRR zOf`f#{~Z&VYM4_M)@B8xJ{@3Q0N(hkR-b>RQjLD4!__)ns(Ro{jot(_oQ@sL?(6lU z*6tN}7Pb*~6HCD1gsbmS_;#QF)rGL9k;2t&MO0-j#11yF<8|>DaGY)L*8BD8-M5#S z34=_)jpmOHno^Cu&UhNE?W*uS8~5m=tk-bqWa1}ehR4rp<%f5<#ZpCXAH1?j^d2T; zV>JUren=y+*<-EG9uF6(5WSJ)3|3 zgc1C(JSE_;JItzCSp3_%-C?)2e9>{tsB`-E`ntXTw{eEot!MvRKi9!kUjHac;eOoe z!qL3&`a~=4Kn~!RFW;<<`*c z!B$GXpOZ@HZllGllk@SEYcwwNOaE9I>tM$bf>+Mw= z)eGi!$?}|z^4XlORexKw#ox{~wBKvn8ZHTo@7zOrH98!Y0)Ni*eGi#EFdgAa4XH6p ze-71ui9@LP*=9eUL~U1z=6QHJ2e_YoZ8sY1ai^4sb4jL^2hBgjVW-!gdQPm>Vv`9s zLiCo+?O60?{IB2+W<}Ekx%F0puu~zIuidtFZQXUJCw#-M6Hp@_eT*WqN8b4S_hvJs zcR}Wy=WbT>A@c*NTIBiigkg}c2$9=|@T94La+8qNdwELBZYo0wkX^Xb(I4FFWZ=&~ z6iduEGM&M?;Mmh*)E@+GcUYeI_%uRj-xhXv+hV)1Y}>x1USlzvXLq9K=9tUp>Q+^| zUD8#RN$&r0RMms!0HaRsvrWszQKaEuPoh2BcE4DmvQ>B8;~d;WL-jNx3dVf6Yju`3^aWO0emNrJiVt%Rl=6#f^QF32v3)kD3 z;GggN=m@&czZEds1sObdk|&PXqqC#RdZ9Nj8N(%iLQ&Y2E^)-xG#5*tWl_nww1xtMB3=@+Zd>1UF5j$t zvXb4vv!mT&`5a($Pp~6#P_j)rnU;s!!4{$gQWfkI(DP?YcJuAy|pIGS;D z7+3Fdz1g1jqgA}wt6tZ}`LS9n2}OdC`~|q6xJ5?3sW|^JH)@TTAkqU`*>B#vfv~zO zHF~k-#7<*{HL~e5HI4@(%gq578Z;@XZ&xQge9B7gxv5WM-$QDbI~9+4ji^Wk?Q#fN z3=0VF9t>Lz=O|Ws^t|1N5+&QFxpp?2u?9v(mUWpep`WzxkEgdDw>md=UQY6_X%zD= zUmve7^nD*VR%#6lCexyP&5?jd=HAx{wqzkdN?Ov@lazPfLRT~)4>0uW)^V8$TaHZGZ|E2dNtK19Fe$t`*QUmUC z9U6_(@xs>fQPY=kM1h1?N5E-yn)SnOKWi+@c1_NT)3UutbfnYC&~WdK<=wXPiu2L^ zk>{i>UEgb*|6@@F5Kih>?m84Me$M$Mj+nVtyx*9Oc z|JIew{W~c8hhtJUdGV$X{yGkmuBC3icpMzcs67SZ&oG{$Tlsan{q<&gj^71?=ETjX z8!#|T9lv1K9D8b5y_mkHIQT2lr@O3a2m!x&%-iW8>edbVU~rvk z=pU5C+V{^l0B(}vk0BJmT%UXv9-!)1MkVlP&VM1!ER@? zGXDrYpI%m0cFW-FRl{AV7IKr{eaEDTBam|#tXLw}NyEUm*?TV(cT*f!{}4rR-@*Ap zt;FLGVWVIx!mUd8B*=b#o4*&K^QQWS@=><*mWZz!0Gd4Sk27Z!x?V5W8tI#V4C%Yp)5d!rfZs!=$x9L3F89_9+JfY8sD>z?k;>z=nwKe=Ihi$a_gzh~u7KCQdXNKt0+N?`Du7B<51DkWdJ~gC0h?0wwi%@BIuPj)Blm!SSC$ zua03fC3rB((Y`{)mu0TeqcE;c)RG=JVPA+i=!o!NtT=~NwLK$Ses5O*9q5TgC?}0( zrkgWGWuy=9>+TZ)Eb<~n%@$#ZcrYKfBAWp5`aKE44q|G4p?AqRB613qPVk?5pQ1OK zby8Y{woQp2nT6~=N|#Sj?u6c6)ZA?ddnWp9$q+>Wr@n7~-^F@m#J}tu1;1mUP|)Eyn&)=^sN~r6=Y4P&r8WTMt?Y!ikuJ_<`Bw(Zka;v)NZEM zms3u{6an*j70AlwFTm$6@Rd=2QEjSe=pA`I0j|QnJCWhGE7XI9{CN8A3G);9d<1L1x`cfZ{E6P zZCikR=uI-xEqiaLd5zdBnSFo=akkz1wz88-a?7HH)t}bp`;=6?J;<84=&SaVJZJy+ z15#|)$A-)XmTQL1t~QM7;0x0pb#9+uo1i7IC` zs1n6|fj>csIgQKf)}_B0H}ItvREc?-l_`D(>(ks4(_d5L8rY<{Qyi(A2D~AwPJz|n zZEA@D&1`#CpaOx^)YM_m5l?tCQOIOYA)J(ZXxTemo{c|5_1Y8ss6{;VZEl>j;M})8 z%Q_E}WEtG};I`>n%(A~e(fFT^(hUeGY+BaL(@J3>?$ePSe5Iu$b^L=LQheA)7=BPR5ttf;<=U{(|!<9Aa5+luH54ZW*&WJiNwt{6Ky+M_RafEzbl# zKyyt2+%@{vJurlEEZeSQT(1KhCUq2#h&ZmN!+Lznc&J&>y0DZ@tEOGYQI>Cx2vSWtXxb522c1=&Q$PU+6y zAVZj+6gCtg%-|-p zYac}6lQT=+)Kk*b<-G7T}@g}DD-OEvNnr2pBe^90qrAS>wpKNAHJ13DjLYZ-mZQM`>q8j!Y4Di;+D%QFLmN}#7kFY zR==>$Mq}`;sk;)*KZ|8}6$KxfuDWq|l5HeOcVy~G|3aSCry7V+)a!OO#yH~AM;!OV zk}^!hw21V0;Bb!N#L@OezzhUGFRmfx56bk`&s&Zc(!PWUVFy^l{Rt$rBj4f3Vbdv4 z#Vu{WdBUtA^0RBPHS^;CF!*x|piA`4JKt3)CcOqnBjyX^t9(|*d2iXUYh>u0pHE^_ zBWs1C%u~?_a8(Y!+oPv^Q@$D#nIFou$U75weLg7hqz!4J!O@);wcdl3f-#1SXa|#; zCPgIW=gL{1a74@9CERq%(V?1w6QZN@0)N_(QL>WHg1TBCm;0Qfr!dc7^@cFG(*uui zq?l$6W0^{|+>yHWuI+wmuexU9nHO%I?^0%swD1kh| zTId}cvk1;$h{Bhx;oi&>VMbj7ApRQGrpOWG6cvqCy<=44GhtFI*e>&*Z? zfZ_gY(&6a&AkH21wQI!&0-^aL5M=#A;%mP8B@9G{p;yUGNTj(4Prh~4FT=WV; z4f^u{+TSHQ1rHh{cu^l>--{~iY{8%1eEThI+ilt;p5Od}diMsn@+F>MXZ%s4`AwX? z=5TmmKbze9e1qRgXx#Fcs>-K&H!kV?|Y{1PVkc%>? zxxHPrwkq%HA_h`eF@>fUwL3_Nb%LksKo2U^#y=&N`Hzm#$TDTRO9hX?g3NR4aI2M`i>9#BC+F;(YorrEPTkr zX2tydw}8kH77}`9!2l`1?dmx z&Am8Jyiw`b)*ZyOgS-rgbnhEGn+3ujGQ4CfcFM}eaHtwF5GO1D{-8NtIo6={EvvkP zOA*-&yA(a@^U2r4II=h&GdTaf`ey?BG)5WU=B{>_C=x$PzO+UNdAI@9tuHJzcxCRc)?)VIx+=QNWc z&nLRN2^H6bxx-bb);d0Tp#kq?Hv5`skpj@-*?MOe(pyK(zVbLva_KE)YEPffcYlP- z9YG@D1_*65YI_BAUdu&s1OJKI>gEKab$tcO7}2wgmPAFZ44yUhIg?Edt&7SnfQPOA z@iLIo#QVk`2tt0`*e#~r-iYgZp|^#8u$&ro9lqT&cb)pZJZNC^v=3(So$dzP5VBB- zqP8)PBF#t7+A&9tleuZ; z*#Gk9Sg2TNpDwaBdnG(Xfc?i3XVC1?OYolRc(yRh&Ci*i>)oS*K8e2mi4Y-rQzBrQ zH!2ka0zlv7^#)KJp7+8x08F2}5KjM2q^tgRnGx##`*MWcNd`A~BJl<`Wdt0ukN47z zHb+;VAFhu|E01fy_Uik+m_^gB{vOShD+_nB|kZ54frMcFm(gx zOBg$pbkdmgjx8-N+v@E;9+utnD>0asKe36e(D&%kwiMZP&-cPfIVUdS3oVQbPpA_C z2mzO;CXAtD&k4da%HrOPiyRS^B)bl$Ex&*cLU2-DC;HwU#qk2#x-~&{&u`BmvbQ!a zhb%wRYDWJc8?`Dv`#kd@ax=(;&6d8R+=)EHC4irD{VtEm{8-$RQj+EHdKS0~ERLoT zJ>ofjCu-)A%gOzx(vskJJB-U}Dm?wBDDa{=rX)-k?7ZhjPss-r?(TG1 zKdO}!YtIb8Y!1zMri|yE-~PSHF=tYpe3IwAP3&_@9+&F{-xxY+Uq$SK2ZHW(OVI8a3{|!7FvT$;={V17z-<-ONOtf zZ+veG>v!8z{*%n6wgz9WzO^ym=v*f%@2Sv&@Lm|pg` zn^LNz?Frb?lh3hU68IhEPhvn*Vg&N<)}NF}Qi#O7E_Gz_H@B)gtT_8hQm|E#mY%+Z z4@P?f;xxj)GGRH(7Ybb&9dc0$c(8jCgPX0!_a~>KQlvy z8h%qBY%8ACpy8kf!yvQSM?9IpjtWNrrNA5JNR+Xz>4@Kd9ML9+%p^pqDJv%d=1ZK> zDuoS@8sMf%FbhfZ;GnvZmx+|c+gK(L=iii&L3o3i9q+cc+5sYek%I4E>JU`sjDU%T z3q*7m1`(WBNYtNX=&tncFGm_F;$l3a^Ulr1bb;h#;8LC8n?HhKe9d?>^F6ugjfwp< zxiewk*UHA$k-0oSVpmiEX$bWNsA8p|;9`5+(QI9Y11llDlx)T!qK695QZ%OCGIklda$E+F!496~e=8}JqrSs|Jn>ygkwjCfB&!O3Ws{SKYud?aDy zVEh)mmvlT5M|@)b&N;}q(s?UXes>TrNKUc7)_woTx12szUS?ki@L9$ffW;h&-Zk5K ziTBTtf^TTV+q>kmO#Iar009Fw7A5F;mQ?y(?xmiwU=USs3i->i4ZkG_C)i(@J)lhP1CVc z^CNFT19}cUi=y%@%7pOW=a)^ZK!6XCNu(LGr5GvS^*0#wueLuWh@x=n@jkKh5B;*TGW~W4ZS^$P`;1{9DYTX5({bo zO|V5nDdz0qs*jm(s+H2020SAOwv=X*2wL9iv}c|K8a}O^r87pelPtPKwR85#XB<~M#s3h`J4&}M7&loHziP^}QJqa7)QZ`}_QB{fw_dN884$7INaE~>HCbbS29K@;$Rmm}<0l;_&AeC3hG#XE+B!e=|Qc29P*7pIb_9-?y} zr^eL@#X$=S^?F8^%&H|pfe+9e(z!2k+xc5|vGwM~HzU|)`HvbMx9p2^tDDSP&~_Aj z2)c^d7ZGxl_0g75>Df2bZlAhfR?Kxe$;f_W3g7&N`i{B!I*0}MSN4Rd9&VN$?XCW` zH963+9QYgTfEeA3jMHSu5;rW&NmK-Hu6M`xMDIeDOx)5nJm75Z0NE1LE`s0|^ljzY z%*`GrVJ*4LoRTKRXJg$oD%))*^L+wrv?~=$Y5{fM9*HM5*liH_tL`+M9)k%yfY?RC z1{37mE8P#rI9<0hRRynyBkY4%!yZ~EL%AMLxyGEUIfwhbWvDiYo4`G}ds zfc)I{zFwcHaKSAKXq!GcyX!_F;uh-14|L5n88R?%TQeK^3-^P(fC{@#bG70qmMdw} ztneC8HyAgMpN(L2u2lrbBnFhn!P4~~iOD?w`tjm6+FrjOwsWq9NkxLuB3*uAUE7Nz zYx&cCL*X0(PBEWVA!#R;wL)y{pWJMPrW-g+WApqY0y|9yN{!fI25rDYC=!TuZaN{a zQRWK}@3pI1me2)J7V?euLY4_p9;AIkk;$f+Ut`r5Q;-3H3EXa%z^ zl*7Hij=VL((}QQn1NkIKa`YwgocB<0->>j%|GHSwgqSe^_VY=xg04m`HQ`Rvz=uc-r`ifxGWZr%+CqP)bS)9xRQii=Ni!ZTo&JD&%ai z1DSp!vNIkX`cbSFS+XOVULXI@Y(bn<&Gw76#b~i2a8YZf$ru{SsJYylUdwHtfp3?Ep|P5ZT6RF+UT(%^p_4lA zE=u@a5E9ZTL>(1^W->d&0I4PgRT1#U5(I%36A0PsK-%@x6B~Q8`tXA(;nNrYu}7s~ z>h;Zo-tR`>YUKMTxY9;5y{moieG5LEKQ^s6>L_+Z25FqNp@Q%o2t#Q=*SA5WdULqU zFo{d;PZS~E_SKjzVZG$X!@E`j5>?LF%Y^f5tGRkIW15}>th!t-&H+D1NqiS=SWTxw z4><7#xS~9{FCzvSzae%?ZijdfpV7 zOOtowu5@y^)B+E7xI7$hBKnWt7SBnJH7_Z9Cn$503g6OweT6zRu=wtDskFp{h+%c( ze+`1Y03nzPKRz7xOCpa|V9qS2GmikhPkuVTK1+j#fT7~ElaGClF;>qzYtckv!F&vi z(VO?WO9t11`1E5#q*97*26%f~hZ{DXv3&bClv~1VHjIfR-Ys2d;pcpn$P7p++VU9C zv(S6R0Ulj%0RG{2f!dVisCfJ#OFAk8z1tT*18ypmV+;rm1q?W`WWh$-Q*hmyw*p{z`a{eCPB?!TULUfVSY0 zU|F?gYZLFUsX0j0&DyCxea@DzsDM81G5m->YV5H2QUt~d71beEv~n>M9eQPg7+HrQ zXumGh-8Ab{L_X$v66kM104WU+0|Nt{Cq;@uCocgm&jZA|VSHCbBcgvx?}5|8m{-H} z5{~?DJ~MlNppH>y34Plw>9J3NcYorHX7|BS?~yT z6s^AJ!_rVqM?R2B#lj+5UBRBi)82~=*a=uzckA^QpSD8 zUvt~;Cg?FKAmH1RG0O7XtVCdB33b+faUf4(Vq8zIAL@zign#Aw0N#uSF&dOKH~j40 z%Sw`lewXDYC43b6$YGRXdS5qd7}xSY$=v9TWuOhD=bz|b;bkX8GZo&LS!)mpkPrUe zFN$WC9%Cdp%f428PIf)?hXp~ckwfILq&dJiA3?Kn*`FUk6;9HrV1S%lHYZ0RH{?Ze z?~FgE%|Eij>hX}0;E*;C&z-YIwg(JYA_t)&$#{~;+k;W-DlAI0^$h?;@I6ask{dJcj?5$u~ACo zBlQyTL~Yo~6Q2i{ND-OH|9DMvP|wI*&O=pM1ED_EXMvjg$Au%RYW<;*Phzvw&QX6$ zYI!#=n z!GhjMAeIZ+PoT16zKJQ2G8)+x3gw~2g+C^|?R6qhpe(^@uOP(P7~k)C&%Yh9Nspri z+6D&v1jR)O!Zm2;=8v@ z_lE>pG;A+xgCy_G0ZQjLGlF#_t>&wWBnaNBMf|qfR+;H zJA!d@_;@m{G*q5F&)uiyGo~m74sb2Y{la=p8p0vs%0e&ztjPDfx5beOLXB|^xULJ@ zIX9Trn$+R_XMU!5KCgGkNs)K>8;N_ZAWQ=W$Cyu3dhD*zT6m8VUJZ8-XPxH4kl*WF z@7KrdV)*?0ypwl~eXybvTm<*K?qedIFr0zLO3#YsF7ib-G|(Pe?OoXQj7}Qr!OX}g zlPS??!i5?~!Ug$^EBbZSBpw?VuVD2geY1Cn8lEV#7CM$3RtHQMo^l$rG>`ui#I*k5 z#+!%eAci5hE6B!tP;Yq-O2>wZsDsm7^?;`11y2{cKAI0;fkMR5K+*M_+ZRtOgf1-V zRl-$tA^+zVjWazB2OLBdW=SNWYci~x0}sDOnPK;Thekb+{b%6M{4xBjW;9i4328bS zj0RKLgQhamwcZu3nC5Fycz=?ZI@lEYWRwIv_kerF@t?_b5i&S<9p@LrN?J!i_dAGP ziABF`AIpRR3yG3+BA!cB|@}b8`1~>W|HFZA}?fN9>Xf9)LRKa@(J0n9Y(WNj4-+Y>n zW{&J6HNkP;w8UGIdKpr--pee9@q$Tog~~7_f`C|Tu9g1s7-IT5{A3$y2rUAT9oI04 z;*C(_SnT-uVpsAs>Q3)=sQNP)$M_3?(Vj})#6%6Nw!5QAMBr>r3SkaBTTB;OmN}ie zB8DKG;Sm>7u4jIz&n&4IAZ5x(Rv zzIT@@iBIgB=O_A&)PEn|y*f&hWxQb$>UE7vl)lY-zA=eqCm|(tI-Od(>ol;WhNK|N z<|B38;}1p2w&#=H0pRo)MnnJyxLXUGO(K)yu@>1dyGR<=w0}$n4+Tk#Q*yx9<6#5k zL#urAz@^VVhdy(s)jO+epmrck%Ar7MCv_=!@B}C5cBz?cgJ%To2=^-D_qpF-OoXH% znjq7#YP=SywaYaGKIb)-P`ebK*&z4i_;F-?O_dGN^CAb%5h3`RfGku1UlbSt_3P32 z5_V?+5+Gl_bGFMN{w_>5=3Hh+o|sml8rbGW2QM*90^yf1mZy4ntbcce`TGom{-Ia;CeaNiB>c9{ zP%#A#8N}|Z{Xs4)zj{8FH%9j0(T7nS-)vi>-+VOlN=!bKV8@s5b{tQHZFcY4a#+0B zm>0}KOobJ4UmMR(zn({TY>n*ZY$yA+(MT+#uF=>p2^ikx+G*v{(-3bHvH9nnhTj^; z<8vMaZ_f@iBxPivLl*tmfrQQ$JvZ-&=DO5y;hy7AcpJ%;eH$v*;3994^FHLOLslXA z(ZbO|_;@VEwwPg4yaRt> zg3tB*$Jw>-l!!b>3KwIwGXDZ}G!6qBF*=}KXN79SEu*bgV<mf7EcbKWP!h8tiB1dmB( zc`tWhqId$vZ5Ir#3GR5%MQrfb#8mk|ZI@i>EO(J>Gk38N5Cghwn}=EUGTZbEvQC$1 ziTp*@h=NY>ipKJ??7>+tsuxr#tkUmzM*2dRG9#EL2#uh0Xdu|kr%s(Fc_qpj&2lJ{ z;&6WmF|`;P@jCD6f{blG7v6c3NlSNVO@%EewFo2 z&3<#FNbZijhMuDj&=3K5a_~owR~u^-$3I8m8Tu(=6VK7mS*@Og(ddeigjgJ)KfkXK zE+2s;yCZqwSV1Aea{eZa>M!|M_(d>o1~gwnQqPk(a4sUu+qNrP$_GB4#o*{Cw0Z}` z2X|uel3o=EWGqnNN?xyx4b?4`fW4nXL1fu^{O;}sV>CCP;iMVOd546X0e+ig?5@xR zTvj$>e4CutVFaEmWHuyHh{8uXQzzV;`nLYeWZ{x>C=(>f)>E?CKYllK?}G2A45>D% zjExY&Ixgx~ox-q(2v5V8QE5>ltu~FDecpzXVqg1Y_S)ZMr?the&9b|{K)s;(qgTAeT zC;=~t&}uw9k81a5kzw8oEcmly^>AVR8C2DV>Prj}-U^XcqWAiOq*eeR;4>OmFM>Mb-^2&B=ErsBUHK{QlC+*_f}&0QSx*s%-D)ko@Pu(qM&sCL zP7NU7;t501mJNWTh%-=RFv*9wHI+ zUxX#d(0EZ2WJaQ#XZ}HiWP{-?CrA|Nc)*}tNXk|gB9{k4NQJ6tsYd3Q-eKUM0h$K~QHVuzW_G?-fq{CEHRut=R; zKo3glC|Qkeb8o(*A$E9U@k(PLIlivr?9-E&;N1P-EpYDsT_S{((S!yOD{h*f>b_1& z7_9-Em8{TT#OFC!MFgw+2j+^MK>Tgac$UuVQlV%ART_ZDOeFec?{<{^kEM%Y0$u^PYmN@l)zK|<8ntfZWU9k`8%m}i3m4^EzBjcAk()p2lQdnQ70 z1I#%ER?Hx~QAw2YIo|G(VHe+dc`;)%_8}@HW;aN>thrV}9u;GZj+(xQ&`Pj;S!U*# zTQnT1h8@S{x;{HHj_zK(nHs7H9$j0qATb-7kN>IQkN(o7k1K%tfzk>)o_^XW<`&6q zq6o~h@f+l)9c$UeCC)$1nIfIdD#ud4Ta%1ac9eYx9+nC~=e3lFsKmtxmo)yeIk1qvu2fA5voPNqJd z7JsP=N%J9&1Yl@Dcl~s+Id8{B@PP3@b;Aq1gOBm<7;-_|gw%ByiP=#FlCM4@%*^7E zTMEMO)qjTL*7epV-HWX+N%Wm13n-bE)_=>cygeMUol z)DkZD7D`a2-zJ={`*OSl;nbWB57y%y6 zFQ~A@m~O|SdJe&7#JYI*=>pk|0*Z=ezd=@ZaYG|!4!ab8-#5fIo1|rC47rYdL+J;D zhxlLvM7d~NMq7w zSOrES-{wU~UJ?~c#3f;Is#UHrW31t($kO*XThsl0LBQrLtJC@1hs{cu&@P z5#E%mesAJaXA-*D}AtkN1`U~kVyPYB*w+ba*cV?+&!CX{B=+Do!2^80Qk z7~Jf>=q58&-LHrv#fc31e~_8%3E`o*(A85?kg3wbz=`)y^SmhIp8;!`H z9o0+w2GNMtzD#KPog=FZvLr3Y=4!)Un^OIbS~e&k&+>`<=V@yuL4DDa#tD}$Q99Ka zNcq>gpO)GF7p_w4k82PKX>*3g5-Am2Kt#8V4Vc&@hyC(qeLanC-YSBwqtW3A&4B$m zr3848uoHH_f-rpZL*IZf<02wt8vt)fB+Bb0H$Xe z@<07Tqk0kMy=t+oC8%^o4iYiN_dN$<9u^fn^@Ia5CMa6E?eQFoFeox!E3jd@V%?U? zxS(n%d|S$Fv?mLMYW~e4tWi^WW8ly*u7t<2Q%eLbV7bn3mOY`yd@AF&^@9$2s}OR< z+RKbfbw$l2-vDKgD79m;XJ1|DjA)7`mdBdj(3U^iL+rt?rWGM(ST4DG@S^YE;SeL+ zUe=*DBN>F|g{?6hw8-^-X?fVvKWBWNfeJ;`V*~xUWkgis2<-67{*>4cXO_S7pYyt44vQ_P(Q`A=McCG>^3a<8McvYb?Kvji+p8w4!PV>0%hEKb)r>i8si0_n8&EcKlDY zYW!8(;IKnSQN+IEaqy*8VM)~|s%CR2P|7a)qXC)?rA%6UQ^N7i4>nhBo$OneqCtZ&cYbF;l*Kh@^iRk;85!{M{(uc+`hd z)cilZOHDi~7BO(Z3=a+za|IqaAjA>DozkpSgLBun3a9WfpU}_~kiy(4Uf6%Re?I8@ zkt5W#mm}rX?fs-4S`^QQ(s671tSENvm+x<`=9fUi4xNyrthWi7q;hI3-yTxDNyJ6l z5=u8WKwqbcLHvdulO$88nWUx{^pnyjK~TW*U;2@AU&+2PMI$|e92?KfP+4)hcwl;P zMppC~u5Hz?-b8_2hc;sG$4jm9Q7qTa>+h4-H# z7f0U*9~nO066q<0iR*udc?toE=anAZzCR(p8H48Mm9hI*E0|DA*M0MAPCLg1FcYBv&J21Sw9ez#rQr|8eM6tdZb(-odV~^1f6l=t) zPf%fD2~ma5mqmG)P@u1|$EJN>mL67Cgg0$_^LqnWpa&1HuZTQF|9s-esY0}`tfHdl5p%h6)Khp|V8anP) z)?oG`5`jsSH12;qyi&t=@4#W6q8}LC3xA2KHVe+8?T#IffsCO(^>)l51H2mZ!zh_B z{{Z%rRO&Yl%UE_DY?1957Sbw`NTb4_!9xB06(7)Gp!#0OJVrTnbDhVgdQ@21KEly3tugpTUoNhPLmYI@~*@#NM@5Mi|gpDtTK8R zar1Bq-56N}%k5J+Mo;6`{92XSKVx8^AxQ2rWSYz-tF{j5O{}RAp5)wH@m+S|$W@-5cF$W;$h+ykjO1>2-} zVyEPfDS;85pP#MIEa3pV(mmETkDiiD=0H-Z?8L)|-1WERi+@irsOsk>0=MHTO73Q0 zZ8ze$dY0b?R&?)iqbnp^r?Gq9@95n}@GaU#Nxe7O_Q(EIMJ*I7`xe7-&(}7Bu9^ko zh+!-gK5g@(tZ5vV;${i2hfezxR|j*6>5_95kDsL(u@*IXMu%&{MH^o}S_zm{|Kg#KP(A@yg;{ zHJg)ALuAG!S>pm&`GIyBo<5SwR_od3+>`Q%(}hZ`HeWSG8SDHJAC%+m9}L6m9WGOuTDY@@8kh5(W* zUqm88TN*`23&HY(jh+4Vf6g+mAp&R?@?VbWRym$p_2smr5lKUp-oV zh;AFp}%=#i8=80+}89<^7$rPd?5N4TDZv8ozEsQ^j zxWU%t-|R2Nh>t>MLHOkM32}4b8Je)c?(k>ep5Rjwfw$VJKQIRzCMzmbQQ~wcHqQxc zVtjDNnfy6NU2!5^zSj*PBbu)p*Dj&6`V-KPJb>Li6n+g`5y=Wi`SV1&VQ&@gZ@ntG z`JS8An|B;se&t3gB(AcamddDSqKy>z37Ko-Kd{!UlJl~m7|7@H`qdS;z!;JEPR;c) zAuGa2Ak?B*BD)%?r^WrrEPGMTIY~mMF}~6PiP+yrk;pX!8)%7VkD(d^K~$PRRr^wc z(LDm23!eda+SSMYCCl%{I`z0+PoLiNl#*)?&quSLgKHehPfW!qHGQ~B1;+I)E8E(- z0)+K>MYw8bJH3-E;aF%i2<42Rq6G)jkgVwTF-CVDr{@U@{^S7Y7Ci6?*$J98`POYG z>;8`eDQqhAZcuR~CvnweY2$@mXU9Pte~aNl@J!5wwHa&#OMau`xcY-zF>Ab@H*F&Nfvg3zq^{INA5G zWtUy?M~7@86CL)=Buus9e3`CcBpwQ%BXU-FtWHh6S26J`FaV|=fm4+4Qk%ki^i9s>)A)?wMF;IKOY?~SG)ZNYXVMf;*I11np+F~Z0I1# z72aOEUn0_=gV(aU5Z3n$!;VL*1KsTuI~>Q0OHC}OF@HneEV?-RB)oN3`fm!ok`UYg8*((m|3H?Aji^e>=pV*76 zEXs4Qhv1p5?Rl+Cr3u3P-1+(d$#bXJ5!Sa1{9cyc?=H3C=WdR^{*R(_jE<`dqj2oT zZ0s~Hc`-~mQ{MRAaGVYH zOPh@wCWA5Ofj3!ay#QnpA@t>RK(brAfB@hAq-G2FcMu*4h!wVlsg5a;9$3FcG{KePto3{>u?Uisz zX*zHlWL|j9y1H{6@CM*_b*^qjh*BcuOsC+7etX;eDB5sqUb9>4xGalWCdBwipyCjd zV1#8mPd~cJTiz7jbn$=^QvfqksRi$v1Hzpv9E_$30xnI=6j9~wSDR`|nY$tQDU*%k z^zCg~?kj^;8yc-E4)D|!>vY^e1`esg`Oog*m&2l_J=G}IzHB9+>vq^Mw`=g(m z7RD?D7mM}9xCI|eW|p8r+owV!#j-L4U&OnP*XsIOJ!qRaMohPmMC@1nXbygNS?1CB z?28voCQEsh)@jhePeT!ZC3=9Au<4s!y}Jh!7z3Om9{a{oj=$Yo3JVzA_d0jZOg+Cm z6?p`M0*BC6zHk-;0e=j`j(Np0?Cp2JOLRg1yQOPG4t-kY2z2Koa&fz%&5_r3%h(&uGRibeV z@5QF+k9rnYyoNmE_%B;-tDuj5VX?cNW3OSwtXso~Xa2OcjWVHb=t6#rf%Sx45Q|mGx6wc|D ze$po$zlpTJuit|CJZ(d&t2(b1L_AV8ESRiLNUUx}zU@{%gUDXxM zAse!9<0c_El-NXW2K^Ta8N`hVzbNKgChQKe0L0WYT_g=d$q3JHB$8u7ewr>~t5ki3 zNAi~LK%pE-!Hb3R-=;ObJIoBRBMJD=*q+i{8a*f_7{3L*Sy}(HQ2ub-YwPT+i@arnN*#5D9pa1 zRZG{*%4Sto4R_*gFSX}8AQkVDIWpF@VuoMX4&5t_5q@>VC9ocu{kn%CPTlqL`DQ)# zn6hA4y=ao8s5WD_@gi0yO=znkcw$ZV!-hQs4DF7sITe@xfD+?vxj-pTHa9ybM#P_ae3yg&&97O%|4lMx!EQr1nbU%J0`7PvQEGIKc&m{VAl6 z1pe>%hx5fP<@f#CtKP=HY0d-RnY5v4Op31kcza^N?h88taxMOhYcQ7ehNd(= zvbMg}MTC4^YeX=ng|S?xCC~6e;hJ5$(?7&MnVUVA z?5fgtUl|h1FM7a{sJ%19o&16j2#EV~K3n+J+{&v8bBMeIM$zhSr(JR$FK8Tn1J~Sh zK7!M%TLQHd&Y7^F!WAh3m@aN8vsp)vsUZ(!(ipoc`rt@7I%OA{RVrCFuhWvMNfinN ze_`;q#~zPWn{=Gj_9JSVPLofoxpmhZd6fIQxnq8w0$Sb%1s2ZMPx^#15T-xz>zAvL ztjYl#fNzI^#ypzOb{a*FsEbq%<=6>uw6+7;wx#z|qHn>4UJ8ORHL5Oa9huy+i84t; zy>>JXXxX5|r}MQ*++vbkE9dTFTMmT06+PL%khn%ZB&}fAY?2yKl6`MKs*lAN_HLfY z^d&FAei~@tfbe$dwksw5VJ7ccNdLW~1zmqoS!zJNU`f~gq=vTB56|FfSbJR?jV7ms zp}-Mf>yN~L3nQFbR&~Tf99!_$i%e`f6h3HfGK0<4Uqxgc*awT2Z)MNl(Bk+FCtuGl z#MwTHCCtt$o0V42pkiP5>CiWz^74h!K6ZPdE7C|lKW#vez4w_rYpS+= zLb-iMOl98JPOJP=@bg*8UD!nd1?ZBAxu{WH@p=Sh_P(52+cj|*ffcSqL;Pf(!pDmg zD93r=KV3!l74*RoS35>E(%kd|z9lg@7kz?FkM|&V&P4Ac38d@ZR~=_aDWv?LYBtbkmF->aFGZQ_fHrIO))goqn5xX|NNrCv z&LE=pjgV<}a>-ms7B$#;)v~zt?W%o?gWK;?281kB!GT|dhS8u*@Pl7{54-RiJrMUd zPjQF+YuGOb13M7YtrK9bh8(A(HS(Tqx6#&$Yu_>BOJYoc;p>?2al|j*Ufhtoe?~HP zj`KD6mjN$5s{OJlb5iuMQr(vvO7)zKn})HBf(NYvx}eht{p`-7u$t%QxMRR|%W-(C zavg0&q!eVoH_vNfL47UnnG+nGKomNAv5)iox?eYG0k>TfHO#X$8UIsN(<-T0rm0T& z%zCaHu>8;X-rs~>0+C#%Uq)aEY!ae1N)HXY9DEO$br{>RZ<V z`_|JC+csJ4E^g*oj;ZHhIa+z&+RTkK`*!Pdd=E8Inx57VH-0uVPrOmZn`KY!x#c0S zoSQ#O7%c0Ein%!`I;@wR|8?&KUz{P<` z_5{*8cN27xtNWFw3)Y+u8^U3J*49WudNT3Qi24f-r|HuBf)Op@ouvV3S8)TNFlwzp z7KIu0BKNO|a_8-sKT5Ew=fDaXnnSHZCVUeOo$H$gFa?|~E|y6*>7@h5Fy=EEiT}fC z-12pggM}-|Cr;p5!2NO0LfaDRr|8DHaEP$;^Eq~&NqIqjNz0q=>B|3WjrZx?IaLul zh*m%Q-%Fw0D#IM@&Jhs#ZD!o0%<<_%W$U>c8?h7T%;%S1zHh{%-t8GjI(UI6UX0`Y zOe3YKsqDZdSizy0g-yM)R2q#R=%d%f9suwLMt1^DhFva~fa+`C3a4GRtJpNOqf>MU zFhCmnu>ICs8x%{tQD?1D**9F0!n0A|8v2~x_BNlpgJa=gQ5ib#m4p~rJH97%(eSW2 z0`@)fWY^@D?I8HXDa-5H?nWY1=6Lc3Z_C|(!bq}KInKM^Bg(7WsEG+}g!24^*!Ix; zKh7FqPt;BKCVeNQf741!FiVzP3+`^k@uez|L{A372<|>CZAtuZAUqZ>>vGmv=d9g$ zT%%OMp=1J#3`JuCpm?y7@DYoZS}PxQNMtJ7{5Q{gag+luHhkmsF%#ZpPgn%zBsZJw zSLDg;@lEzSe%3n_&+vrO4_RGfbeRfOLP4$ZF%(P&Jpm$Do1JKpx6&@>%S^cyeZfKX zsC9Q_h+M~i2QHeovY&U}uF#xRUky)mJAWhZNJxN<9k-Ug_B%SR4ixA3WyJ5t#|O5o zUKDy`9yO%62;C4hO*!=!t6VX)@5vO+|LDJ|<7?+3=E08;_wdIn_)SbljKb~K=ULlf zP>5}HBQGn_5!E>*{}bLJt4J0NInpJ3kaJbF;15a?0A}F4`FP@A?t^>9%pPDg3XU80 zFHQ)7$0GOpaP``K8kDv{Odd6ZZ|=tI$U~fq4eTn7_1Z1`t&fSyAbY$B@dRG?4_Aqq z!FJ6qQK1)6TZ9N%T}4mAad`JNOJbxe{(lI}?BnFLb5+i*0+|=SgMiSeD6D9k>I*HT z4Y|p_Yzc|tZk;rP^dFyDN!UVd%7KLP4)!FzO{a0X2tVI93J*DoCH154n=5zI{Pr?M zx8I|@DML7ixXRdq&9@!YsOT7&?SwSRmh}BQ=?-tuhA0cLhV_oFAPu(@A8_XxjszUA zam=YVpw&HhBZko;BiJo}jfRV7wCp)D1fK~!hrpT^!j6Im7t4yylbZxHPwl>CoGLd3 z0B9zHtavzq-@*6eXtO(L3_M>!j)FLKsK50cMS+E*CqF{3AcnpBnsll0Z^@tocCvRZ zf`HdcB_m(OR|h4QkZ701ZoOr|E<=7%B6qmpqTUu?MbG$TQ`v92HoFfd#~eSK6R?ZA zT(zB5G0SusdfDGwMZ3%MJ|v&4*rvpYd0YY;19oS{J3ALK7fY8Fakr>S!nRXWq=E>j z@HRmC$Tdp6qCp+QqK!Un@xvFnl6&Ab+^L%v)+cHYY|hwg$`2>uE>MFECLsg@#wIv*APjpX5 z1C46!!!WHwRdpLpYl9R@2kq{_EembFta@l|%suBe`a0dMIvb35Rr~nDwt3@pagM@= zKqQ{_Boqwe`RK>h`?UY(T#$!I%7AgROLhCZ9M1kXX(T`QI6F@G->yDrxTuT~={l z)lFx)Z;n=0*6i73x;LUOiN#WDpD4M{Op)c&iNF#%QuytjPRL$G8@Qai=rgwwyp3we0_+4 zC{?1zPZX2)_09@%#R;~(?M@{)Zxl$9k}?w|*}%Ku?5){%e>8fZU)UujCKhO)l~$8Y zZ4Z%>pqvEbEAw5<4JZ1IOpxN;Gol3jALzeme98tzK~o$dpxzS3^QUONHWeQIOc?YC zE7}3JXD8h@x26n;ir+$Db}O~U!=@q{@GEf+YbTm!&tnx`^*uqNIhQ}I3R}K z1qHcU3OD_ZY@gQdw^R&n-69&!#x`8rP=3v+S{R+#Ham7!U)i!9?qOCS=(}AALKB)` z*aBd}?L6jO9rJ=VAd3~MS?Q0>TGZKFF+Eh~+v zdfP|QcYZyM@X|olJgr-eJsfv!JgOByELr7(3_Sq04>HB`KM9$w3Lh}d+C;%eD1Hel zokE%;1=eF}MuTuvB-e*)_N~oZUTYgfh#UvSKQ7r`Jcit7qU!#DrHAEK8Bu4got=R$1(T#=v*y zs8U1}u&p&4{!(u}t?$X@6oKZoWh>DNBMf+%Uak)2WBNBbyGuX#M#b>wRPXGk&Rf;+ zen~YC(}bu)`Ny<>LL$c@wFWLuyz=ak~FIxUpn4E;x(PkI#%-)Wv6xs zFi;p(kZxuM;iU^O(ay`frk}9o_}tdo;!pA2q5+%s36IID-*a27*H;pyyb@%|H@H+h zbU!`SEo+#a6R|W=lfD~Qaf`yhikM`lb{&aI0j!7)cYL{ZG?_S>;7o&}cp)i~3n&^2Ot7_fvWIFHO38g-CcXVr$k6 zim5|`utV6Ci*yQzV#NO(ixMPoTy;Cg_?`I=JaRglNq<#LWFhZKY6<)f6;QBxwIbgm zQu-w`-v++JTW}i4um6(t`P@Bb&Iy(m)bWn8wIvZk@m)%(m!bRcyS1jEv=Iv9A#tz6 zxA5-QQ$fg$?ay;>%&pAhnZLTbOOEnFMCCz!DA{G%IxTm|Ton&%rFKHO&0@x%U7udz zC@TgcJ8ZfwOhs0$smPn|#bqJ*yz(TY%+ejy6QGVZ(KSTa_7VT1`YJ)+GFqP*8_pXc2RWn@HX5>`|_yo7dOY`kb%H1;~B7&^hP3PDb>XIGYSGRu9V)F8E0uC%ZTTrNZq zp_3OOWeqWB^27U9nYwvz+It|@PpykIM_j{2<9Kg9LbFJCDPkQdyB=ZW7iPW)1(|UvE6-^ zkOca?X^9WqDL&WJv087NQ_T{NU%j!rr8eIn=cv$SGPzVC<-;<+l_^Y*6l{=)KkpL< z%m@E4f`|bBseU}$T7F`hGVM)vZ=M(t!JqLZ|E2Raie`1Xk|O@g(duwQlNh7;mW^f( zrEFy?oqO1}^*{}BVb4%x%a3J#eXLRP{tUSspnC_w9;->1txIc!r`PN zfn!%w`?P5k|AzNd zjC*N;Wn>yh%b^tkwSR%;th8m@1`RYT1-60@9ztE>afnX5R@N5_KZ1va;t;ooBI&d?^WQtQ-U{9sc%s0R5h+Hk|KoKYKF-FC&%%1-U>`cdGS%Dq_A=Xx@eeNGIEP6b!4{oe?$ zd-s^3Xdo{H_J`?Ysi2Apo>v zI8YQnT}UJya2?_!b=nk#x095R5b-M7o+1*6?Ui@Fs4@c+si1TB+nN^l6o9(vbN)Ue zV4g2Y^PZ?3ZUIgS8(EG+CuRd>Jumx5qB+V-{`D^u{(~?D7}edIuWBdI{noN1Ja;!C zZe&K~;3!P1isb2RDpW*ualDub zYSzY;7@U197eodD!7`|h+lm2NRR^SnBBWGF3!QI|m!X^NEtD$Qw>Y*$IFNub_T%Df zf=m0?tZ@S_+bizJ$I^GF(cT|BtCPt=W9~3?kU|wd10s_uC?jm@5i>2jyHW2t0$^TZ zKgTHm)8;?~X75UsF87&rt{%cdMkp13SJVA-UkE0K!BAQOM>7-*;pP)=D@Z}H*EwC8 zz5EZ0J}MxF!wT=ogi6Aj@apkii2xIaqWre7GSvp@!IqLSpa)iL;;bO@@Cp|h)LvtK zb$cLP88FJw{b{sjXkDBo^>nlcVhy%HBv5rv*C)ZaS?dY2ripf?NQKSe5Ws!Wpezc4 zdx|ouWRshHvR26g6PG9|m@h9#6VNbJvL86yHq`stT> z^)hRiK*8HmOEYC?kyisfO|s<13?>#nivlOXXb?}qSAeYC_VKhOudTt0LfeF_2aPrr z_^Nu1FW2(m|q#6>Cq?L7~f5?$b4=`qluE* zS-L~%hJHk%bVLd0PQG8B>S_kV5M~ENUePAMMAiXrn=&1U@)l7RG$Q6adlk%JP*(vBI_2MW`lT5R75&ITeLoD`bv}|wnWsFs(2P&j+e2{UTYJ< z09i+wmWypduwBjO_ogp7e&uRPg+TG8)#G=3X zV0yk$9?b5#7dooE>Y?lO^Cu&rziAj6UROf2+p+`m!EHZ~P_YWYxrDPg-_iA{l9iCH zsP+o?Y|o1&^5NC4IC1&+A{A*E7RN}d-83JA&$9S4*``te$r&ZZ@mU~I%IIlFTfO9J z5Kk#HE`lNrntmE{-7o;9=cQiI<7Z|8os`s)`d`gnPjO-aMdYP&NG z7)ui03>~BU`)X)^e4d)!CMP&9xdff&b*Fk1#F7syrxnH-pC^CSr=Rl<3s_i#7}4J@ zvtu&X;+y#|;+X(`n4l4~>83WP&3xcd2qO(_R| zEvl^^-2WQiRU!NO#lp7TY`{0AzYeY2g&VI&SeqKJRkem#%`-G*7|HY81phw}#%m9p z0GRM>@l}heLmhIcFE=Y-Dd!M=l^X8PDEH&^^tb-0RDJQ(>@;2}S|l_(hl-7bfr}uL z%8p07diW2glrm}CQqt0N15N82xbBYo%T}xS9<5-$%#b!T`aVnwpoY4lI#!`iixbMs zS%J%EU^d3Pxm~3ozLU_b$@-*HGeWS;?dNsnZ(F!u_4g~q=-OX^_NUo51f16`xtl(p zQUHXoy50!@%U-CX3%r+!+dqgok$$PYW!R|~eaTvX`jJ#T5K4w+8s>bAj*!bo;){S~ zzQM}sZgY4Q-Qw4DJw<_;V^=Sa@AuW>SMXgeNB&J?XJNwocUj>?EEZ=U^jwXOcL5sX zUO%hyw{MXv-Tq$%a}I9+*GNgnyY&swI!OCB(bo*!W(y>Dp~g3Wv!w&z68m$uXzT2L zl;y6?$E&S;vw!;lF8n0ZS+prLOXc(xNMm=}P-@w%RAww(#)`9S{C(5`YhkAqNN21Z zU$3(m4LFR~_WX?hhJ4)5H8vE+e;g&)OYqRtW3ym-H&RhOX(yt|uD^lX!<mK1o#YvE;{)IbDHO?$ zeifYRJ^;UE7!p)iFCu<2Sn3G~uiy1{@9cs&nQqe*m+S^4md@Xg-KXX~GWz(uW#9G- zoiJJMwEckNqkcE@)4>goy_n0tzYv|CHr%^$I-?(N;{rq#*#BnZoRu*zo00~F4u3nc zS!;?J%nplNx_3v;W*Uh3MLX)6QKlxO1c(HXi>JRABl6{|OqOjhezh#hg69GI~onj@wWy zuCez&07VAxX1gaAb3vY92x)+ZU3OkNu8L@V)%oP6uICui{pk(5 z<7PecE<8oFp4FFg0s@u_kJ9hj`0>a)0he9%A(ZorxWEg1oD5%yG)^Q#2J|7pGhaZe ziRr;uEKX5(@S~0-D93kkI#1Bq6FNP1R9}6c#P^?U$+k%ZUpffGSta|H3xF+-jc^Ko zP|!8H$nksqF$~s&{i{Td{ynf0aXxQj)AjgYZg zxV&05tF{&S-jeQv-=ze&g`R#|*OKJPzvJs8i7|7==hJ7N+ zyg1^-xpG82czkml4iwlTAAq#J0cgcVt;WPl^|I6@R&O)ngz0vE9}k^$cu(UD(5?Bg zeAJe)&B{~n0C>hwhX8zvWt!)V?_zD~;drJ$+DigkJ|jwqVOHi9^PU({^Hh^5+2$>L82Zb%36EgIH_%O->v{VZ{s_{nI^;Qo zh~t7>7MIxtPD$Ct@e3E}ESmS9A2sRwfCPP&3iI+2B~~1iltcl5&6d)qKj>|FUP9q? z>F;4cfA9H!Or~ak0j`f$EPbC*qjEz3cSrZ7Ecdnz6^Qym7w)Pbb|vPZWV8bFNmL$U zMld=38Za^||CbQFCVJE{g^Ru=#BG6w>C0ZVIazmn*(9W@Y1q?gH;ujOVNaDP=CA)R z;1Rd^eW+e)-X`$*^3f$h89^oZST_1$@P-|cXGrjSIt_u7TO;zmjSK!-2V4p!@PWT$ zVdP!4l_+vMRPb8w=jYZ>XKxUGeMF-CbhZ)Sh~mSX`eWos%mfh+USz@(d8!}IORCx< z>k6v|z{3X;xYF6lwEbNl#8cP7P^FMLPqw6QUv&HodF^Kl9OVYifr*YYpbM1!&tE}7 zv`3IzLgL{0kB)W*;*4_|0UY?}9}0=c`9e4)k> zG5~Shtt*sB$S2yz!6_bp|JonJm&(qhI4At>eNfO<0o)Q+k)R!VB>M+e)R{P6>?q%r z8k%6-8T4=}buo!{nxrXtr;Gi-PZTi=G)ST3IJ0htE~pCSt|H%DFC51oufG5TmWcKyE#LvfGM9NqS9&m%_x^G&jdzI=B8 z+|_jHCpzP*xO}l*w_yQ3MId&#sy(paRo;d(H5D`&qEO`R*6vnl2N;#9x3qAAr;DOh z+M1kshoP_nwG>}`D;poReqs>;Hw%@O^}LHZVYEdQj5NlddvIozrl|UHB0p_33?H7) z#S$-~4|crt(5nh{KoOscz#NykX%Q53DjvcJoWd1vJo3IDp6;|qeQ`&zrF>Vxp<^bL zKbq2&`F`t8G@H3hL$9qJFolbaMDz65agQR^BL%+ySvC>$;?O9cNC6>u__)1WsEs&M z6*M@=WhH<^^@y&}Pl;^{HS5-&hip0vUCC$yyP5=a0kq{Ez~pHh9?2{zdC;Cz(R?~L z#jRJ*8FPP6H8f(7aE!l~_qp=m<_SQiJzSmNbIc9;=P2>bP<%Cl`)D}+G>OwDIUg(N zSd2mSe!a8U)+r-G<0}NUS>w)gsNk0PF#OtZ0H%fL*UXnEWX3P4Pad|rznLwF20bL< zp~vk~e=pS!FL!QK+wzZ*wu}0tc+Bk%e4oqaJntU?790F7=Ggb3_9TGrh;4Qw_RnFHSp9|T*&OkK-Y-4;giU@>YX7ayrxf!6M?8@*ok8JK zmYXH4X~p-c?sqP_YD_OoU-2MYE;nl25`;gmP(r;gFBNou4?&SdPbtdTf2{d=DJFiS zobyZkEvP%t)0A|g+w0m5ka3lf1YUbLLOb)R(1XqPewx$ZOn zyy>XEwUTz|ag1~t^!m6@vMp&ijfEceA11sAV(ZPzM_NvivDTIaIuXoV20{}caOkfD zP40KKxqV9yT&7bZpWJ88Ml4&{ludWU9?7%+eJ-fu+z5({hdqh9KpRc+6CjmcAK%El z!GZ>ILx(WdX)_}0-tKj})RfK>pKh2NftXZ#oz?Ts@-O+Dt-d{<)rmZB=e~~s*-}!OMaqd3tXlz&y_1a+(hpF(0g~P*X-#ShfaN^|uqK`%uf2;={m`;0*H%PH)nY zSz4$MA}C>eaT=|a;C8>Xp)V>(NbNjbtkgCw$f4NCEGEQ0uOS3#PIje8c!ReoA$#^C zuZd`!!=>r8d9w%T!jYAOPs3LQ2h1rF|6T9&+q$nr9-MT)Os`WiNCz``$7jRe`T*y; z%c}NECvgJT@k@vqO1TV;Nwq|KN~&GpamSfVeEGgGtG|IV370-k->hbi z;|{Wgp}q8ROKc1&4m{c|{W*Q_vhtEiJcv9km_IsQY z*>7F~A@Btpb7;ACZyg{^{t?VKrKSBv6Lo@$%FkR$7%$mfJEe@pX!%Ai`^_4(hfZBv z?e!b`de8lR32T3w2pBm}Dp*k~n61Jomv#ZPcxnnidm2?i0flVY&vl?VNq#LqCS1@X z?);PGP0=*wnkPuIW$&?jE!!4omZ;h@!ynK7BM%{&W+9cV+Z9-&e0un+vSd*>5!i%I2R+#rl&OEJ~6Yz;!dH63Kd6ADrk5bBugY0bBuPPb}5UvblHL)mbk zbPOb%6@`$yD3Y!ivqCw;?;9!uw(7(oJWy0LwW_6dL2G;VRC9-+C!akT-U^Yny1r<= zbu!{;k@+l=Z^$isZBd;0blQq~u8D#ZS(* zVOYb#wC=`?55zHRcAR3W+?E61+MwnZjlOjs)qUM@So6>jedqS(ylhn<#-eR%wH78W zEv^8K@>G4n?wJQ_@0C*o<@WtpO_ffoiIG8^B9z;$&@h{q`cCgiqEn;BAQxplk}p+1 zSNPL=aOJoexWK=s-CtcJ8~$G(c3mah`1YjiF%jG^18xtfV_bgk9^UCQaz}gY>k;v5 z|Epvc^cOmXyyMvVY)}3|JP zQ7z%G`q2K}Pww8kZ1KaDxo8ZdK>$WUS22}R@L#EwFc^%)15k3;t}M^FumLm^)%}W= zT}c1Vt*5m*6)8JZ${>L!ZDLqWPpi{1QgN};wATquu=DX@%Wj#8(kGwIyb>AbB{T%g zCYJP_CmU&Hcp61iYcbzh`g1gie6}gxfTAW@_B<3Q;eU|vGWr(^Y@em(@NQ|3)AVEk z=;)Llz;gtP4?_OLl|&NZ#JCjn%-zfu-19%MLKUS`$b_noedkK8#=3Zg7SpTErFH-* zx!tnY>rCV?!R7H9gvRA*xE`yqyNeW_jl?ani zUP(o0#lG7Y-}6qM@5V$fnCe>}B?E;B%zNa=>x1;EzOU_HEP+qtK=T_^SSDK5zgL^S z(;KUU&N`~Jdb*O?N#}1vAD;|w?JTX}Db}&~Pp3{EUJ)E|_y>Ja^z%P;+%*MbWL_1} zmKrQ(bXX*Fr*MisF;3H;pNR8TNPMJE&NQC#-*Y2GK4iAtMV}KKfz0nb*f}QUABUj! z$F{SH)S)Vw8{}>^-lm}~Wk1#y&%=oo$@Vrz*yCH!0sg-o5wu-Bid1O9i24==Ld3NH zJ6=v?kTX33{;GzRh~gf)Uk~?9{z(g7D_tj4A%0XgMl1(+$W17N3hq4IaNlI3|6Rtb z<3twLkSr1p`y{m>_j4HK$b90N&VDmoW!j9-kZ*;AXBMSOP#&y~&PPa0?KgxIB6r(of@xK3XUAvajHCMnUq>?h`J+WbFpA9^NaZ z{P|J>{zsZWg^z{U>It)*3Sy6$$j?@_v}nM?Iw&lPG~GqGg9krt*G9N8+tSDtgpDUv zxBz8rR!PZ(8vxt{wZuLpHON2jO7J?}F%s=hyig+i0p@iN(?INV(TD1azfl$*>+$%x zeZ>$y?H_+?&~TeVV9{@;B&U`%8epdH!-Y@LQVO0%((HB#4$z`VFkMQ}4`9jc${#dq zVz1fMH=Zq=5xul?FJFvKM_V%;ro9@#=*wOb%sCYb4@^CsuiSmDaU*$@MxMnXQOszN zP(VG{qZ49AY6OW1Mv_o3M_@nX5GmFezl3QRVZpEk~|9b`z6 z@Xt)x22Qkc$~UX%sIo?NhQ&<_JX9D3TI<~xvB;!wE5wFyMzYs-;<^;~t%bnkJSGR4 zFeF9GkGCtUK5nC*h&WCGYodHll+}H=73A7G zI{8LytoLdQy==%Y`GsiVK=(^?1`(2*B`^?X?zFU|se|EbNcq?l=L*05c-mYVxQW%K zmHb%;+JrE%xQxDBQ0leou)ncppJNouOPS@7hzsC#R`l4hv-=>&CK)ns%hTi62}wZ- zxSqecbm2W1+KRJk>j-3KZl9f=+T>bZE_D82d}DCrGYp9XzRyr()$EO-_FL)Wt+jx* zl?>wCAJP87eVmLAAf$O7&HpE4Y)1wXf>0Hs@;Y(>Uz$fdZvhVDn|bs>2D&}LQ%bS_ zcvzY+-U@${m{EubK1{E^LocLeF$xdpO@mSH4_>aY*(;N8knla*k zbFpvPI7jvC$<}YaKlTVZ*u9-`-F3B6roOk)e8l7<3}D0XeQsvf83))=O9qc2wmoyy z-q8~;0Z|H*s1ci&1_K0kz(=@DJ&haEpbpx5R0h_2hgN@{?|fti!}p%)P03Sw57#W< zk`Y?Nmy)^>?}Gq0Fn+|8MYE&?TiFYEv&_(jT5k4eS(Svtvti0pG@RH9_$yWcJvFNK z>cjQGo#@_!6L7#*J#U9Rdw**^0zt{UsZ(0C+MU;w@!Nwy(ngf4B~h2U{dR zyWgt3(h53{dB!jIVm`T4`66J>sp*wG*ezsR#+piL0uTk-gM)E*t8KtQA`Fac0^X=8 zc^Z&OjJ8odtyK}fsR%Z?L-gM&jpcXXh46Fg9`?aAxUg^d)UQd)DG{mE!iHlM3x_?R zhU;kLPf)kF;?(pO3w z^GvZLGw8S53xaTYMLBE5&}~aZ^JHHlqtB9(4W5e6|2>#N6lT@o$9L+WW0B$9o(l}T zVn=*-?;dM=@%6-(u8=h6bIsMTQr~YkBjbOE^Dn=BUAiOT-bXfZg4-1woD&C18l&m- zA##g|TZ>0|3e=*Ko1G3HWu~;OMqt*oEpgS(T}@@RFYjYf5mofbtJZ)G??CCMz-g#~b;fRDQB>gsmzMtDOR0kchtm7?6-qzIV^!T{0?yyapY zwjF9?Lo`k5XF=YxsyE#zxK%tc0SwmMmHNMn{XIdBHNCWW;<(zMzckWcGKE}5OgCpr z+0HqAwR9oT+*vJuffGG=U#V~XimfCqS1&wws7I1YfD2DvD^<=?d$u=mW~7hChfYh? z4yT9Xv-iIIVIw-m|;hm zvtm;YOmu#$HfYNZ7Q-%P{mFF5(EVPg$&ERmQfI|D)Zv`pWW3B|5O%F9dsf|?jX#fK zei8`DM+I{(h2kJ=7@8}~J*p*SH2np1{SE?mj->WOr=x7sOYBpaB!QNtLi^zwqyBGm zdl?~1qu%}B*jqLV&^jJht?OyIBv8H>IeEyda}4_t8zu9YD!!u)d4XL6M%f)b-5Mg+ zTv@&Ji6W_o+h=3Rqr!$M5}v)ehtiCYzVn7Zw&wCFrX~W_Did{Qhsrv-Ka=V5MLpp7uH> zUMe@2cOM}RD+z}Q6xQ5^ghl*Tby<{YXSHB}ovT~vd+=9wB6jdv156I)tmZ%httZ8b z;qA^v&=caV0TzOk}TjSY|9FJdwOG!h^hV3cGiyyD4< z)?1P3h$pJCx{=slxvW<@d$)x|w zoXdu^oVBKbIz7`=H|}=>X#CfjqQ~Q_)*~`ykHhq0QwU{W(2Yngy@8Ltnn{6ze~1LO zB5+dLG2;(tTb>Q{$w{P->)INDhw>!I+=ASB3!T@Mz%};hh*AGaF?Rz@x?8P$zFzHw zKeBNo7PKO$xPc4%gUjPlvr@}{ojJw{!vPLuBC3~=olN!)$Rdo0l&cgcEew(i)d>^9 z7)y^Es(5&0kM0|;zkSOzY%*!A!tmYVdt7)?EaX_l*7+$;eEKR1?vTC11bxaIvnDIjQ6H)I47Rd6q>|@sQFDskliDVcG2n)KECFZ z+*K0OI%)!}A|cuny)w9`DR1?O=CKjc3YN$f@L3Lx+za1l%Vp0eH-0y%`1xy6RCPf) z9+A4TSaD2D;nuf-V>Llp!)*1giHyDLG*F?9aXnH(#M%0OiXhjih+J1h;(H7*Dp*?Psy;puh2=S#NZ)=U>B-P8^HnztyA#tBJT9#>s=c|{L&{&A@|t))zfajumN{efZ`H4FyD4w zH_ki^jrF5bD(-~U2^>P{&!%88BXP%JMmg>PJ#pBw)A;2j{0dw{$o(O%eTD*6%rcM} z^)|kd=X^0yo@)>(lxwuD=4_ESeN}A9RblP*tYmuVo5+oLzP{5-WfNA?D99Ds>7{ z?_kz2jEC=}G5fuC+HoC$R&A|@`J%n0wFV&i$AT3rH2hlG8^IIhgJ^z|pq^Wt19*JzxC%ya|RO`WHCUTxg64Z}`)Xa1C@ESTTF zzk(}Y!d;f|6>z*C(jovc9WGFK=d34H0yepIUwtVr3>dg0TrfQB$NW8jwOr>Xk5hj!&Hh zUF74Xrr7gVYjPYn5TqW1Nq2QuCEtBqCCc^N?(J7@qG9+*LI8g%W|juJ#{4m|mOW~( zum|#e96)s!DHdn~TE+R68=(Y;$(@&wC2NZI2RZA%#(U6^( z5r(<}FEFTodJkJI*)u>>aoA#s3TA zARONhjosnqqKOWFWvUJY#wpShLSPYBbqdUsvZLfKIcYoi5RK^c>C;Rg7{)rkQldxa z-pl_t`kl9jkM``*%o42-;hrT#ssJy+5Va>h?2DP{rcayZr6VV6gHmoL%dARzn{9+b zH+xB&6Lsa+UFtVnL?_LN$QYh|_BsFUb=GNgS*7Y}>HpX(4nO?x?4CUbzvAO3J8OU4 zb=P?bFWUqRS{mjpRUczJd-m_Vr0|&)J|d-MA1?FT_2^Ml2E}&!g%@AU?)%eLc=aKF z4__Q%Kq`n(!5F+&@l>RNebzBJUbfyQ=!;W*4k;s6ody+tqBlU!JMX;HZ?)A{sr6I} ze1utti7;S~CQX`LlohJ9yjsYoxa_vI(s5h{tO%|VOylt8KKEaAFY81vPRUEF(j>Un z0vy_E8@hGt>gAl47+ZeG=*@%A#rnPDjyrm(%QNpqw8f7&ORskuM(`j|B(Wb?v|`E> zTj3SU+{>9md*|1!5tV#F=tz}+_`|_f5rSxo;bkmjfIuKv3|K45a|A>3Pd5Q7@C*|s z`*dK31Xp@sKV!z-wwH_YBne-(6#m8=CwrayzQV&}MH#n-v=cmKjKXBi7$SL>F;&sk zo$`P7vr&cv0VlYUDj@jMt2~A1NMiq!pCko%X`O(9 zQL+es`0(LgQB4?vM52GEo_d;TI%o_1*6E{`hjGvU0FwtE_>J$|w{J-#YR-PGEIYX^ z1|E#{J@?#e+u~@Fu}Q^1JW8ThT_O zH4b)E7JDXXgkA64xw9no9pZYz29=jq2hW&j6fW35_uO;Fk+8)m1+Ze})WUO+KrsmX z6%!^1=5I&9k9{i=jwaL}W>qz)H+7!7&Gux=C-yLY{3pvWf8>!zN@bU>*R6&Af0fz* z*M+SKDU1{LC_D$Ji@`z`q_J&4ae_~g(yK^Il%10nXC7uR2#?oae*<@e{I7p{*x_~n zWA@JyH+b@dBb-WPYw};X^x{~8l^K>vbLP&e*sHF|l{cmUff~mP>01?;P(eqe$w1@L zrE51gKyH^ZHgeq*;U|AYfF*J3F*QchA%TyXKZmo^HOZy6hVergfKwD!i<*3$RuQ*& z@#56{D<8HD1hCp8{IuvjQ(-%cJ+VYuuH3W2wh$YF@Z`!=_-Ede6e)dfhDci-lFycV znH2iLgKI~}i{P+dzkXi6;hH6v`+Di9x88bdAW^d)R^`)9JJmm?EzjdmJmFuGRzXKs zo1~W6o|Iv%q92)WgpoJje6v6L=%ekbLohwMc|~5)K7NMvKRI57D?UY`SwYq@aL_;x z{Aw9iZMAI}vdM=&D`jU~WHWCZaj8Bdokx(4KpnQAk$d>?Vj+U$BM=Q+w(LU>vb8p{ z!1iq1abrJs?p&H>Y^@GwZ1r}P8ZiE2yK&%w2WHJ8t?`*J;2qL@O$2A2d8U_tf!A39 z(IAIEb$vpUg<=L6`!{sxp?PY%;$s<#pv+TFImP5I<=VXR|LQIvsJofK!_5BaXP^4L z_uktC;H6-#K&~d_gV?J;@DP4+L<#?aFbINfzx_7bpO#s*RLke&WSFEQ{Q|`3WPKE$XY*<{Kdg`fOQBjpYniUk0$kaEe-H??i zIEF_RmsVydJ6r>zee%gajXUTkfnGJuMVoWnSM;*+XF)p^>E5HUy!P5_%F3Nk)6JPP zC-*thK!3y$Bg!_m+Jgj3#OeooN~cjDKKS5+9_euQej5YFM` z@~1y7C?HS;Lo~ytw0be5`Nc1OVT2FDAoNj)aNDZM!%#)&^;u@|cHT*NNd#1kDRxWP=9g9k^C z9&K^Kt5Qib7a|(7&_$Rn56~fw(r9FBAh0Dd z?Yc_P$0@FyOUH9yk54=S70M^(Mj7X7xCya@(32Q~+ibIyPCos6qXROQuQxK?M)36A zb#-!%VlNOjYTfUwYwcKT?e^Ahq7`qTsL7q`>fC1XL#JOvirrFyP4G*XRiz1&*KWVI z`<4W^z~_rE{@v{OS4sbh zv(7xr9ibCwv`U&Z6QX(W!3W%Ln;|BCHQS$G78(Tzw^z?z?oge|6VgVCDTp+lLE{){ z&Duujd+N+~d>mwOV+1ZoCW|h6$RYo2hlX$_poDQ2TY!^;AmW@LH;iexQd@*L@h4w? ziDu=!=&ZTi%b{tFy0%}Zz3aGfM~l3xmCPj5AUbi?m%h2wy`wYWKV0^q`}m`eo%XWs zOFLIitl|`F?*#lK{xSfezoO>sV(*-)+mTHaXHC&bnjrdu9GB?k|h~ z;^x0RU%nrIHe6(qZS8dEuwfR@o};5k6DC}#^9LSE4R8@}>&TJE*|-9ehV-J0ls=zL zD9;wAv4=Y&b;jxdZEf%e_E!!#e&q4T`}r9YQ`k`08tPJzj7GWX zHl{7reg`88y!-yV=+B``bSsb8izX7}z}`J<#wm^$?ijn8YIZOSdT+~TD^^;>q^ z(i5{l$lG9CNP|DFz}reQbM#S1`DxSc@(Lzt>@NL69`f3v*W}T8s~@JiwP3WG@ey?| zd~IR2ZL??3(iogf-XtmRH1Ff8ap}naS?r`ub3$8WXjL2k*c4t{*yd#dd=g zYisVn2trA~T1uDffGVz_*C{7P^z7H)dh2cN<^E+Biz7xHktOtiB)|`o++d{_jUD40 zl^w2DnZ9sG#&)}0Yy-+gc*umJ(@qlOh`>)?dR_;HSi5xOxV2x=Rt*We$ylT@S{)`c z988!X*8T3fUV%Ez4Vz#TOiU~Wq2J+(;SxA;zhWXN%y;OfU6=lCsn>ake#f3YTU1C1 z>~8q=4JPcSAv%7|cULS4o`-g)UDsM`O_@;7N;uaP(q}}&lv^c&50gR?VFKbLuSvwB z5g~!l2-X-pW@WkB`d?3)%aUV3T5PCGtM0iIaj9V-rkNhiFUe}&+1)=;oUKWFYEey~iZ0~dg%n`&bK<^9} zCsy6Rt<{?q{B`+?qp@Hzl-sI#Sz^hF1U$5V6%kXi_`F9r!K~ea1RpA^gz(9e%MS({tN+3ara}T? zbkuZw3{vj{(&r;9>ZeOD9j^hdJj@WYDTWKMR?RiAjV%|V+H=pnOj;fQ0pW*)%P+cn z`mpc5`U`-DdCrJs*nKHoN6GFxZ5Q#3<-w~jCFeT>g6Gfw02V$t3HGdS3zhe z1E#VSo^ecc?Mzj(iY{Cv6uK~MbOJcfZ$705>6*i z$#?~B5=Nk*(n4G|WI1a^*sEE6OON09Y zDh3*ThB$Wc4d>|k*YmXka=~#m^TqCG^RtYrF{j zORegkX5Ez|SSCxu;0B#O-i5{B2U|R9)X!~YAuF#M(*sea<0eDbLR;vt^F zi6F;f~-)<8!lg)ARj^kOm@^Ezen^dLhWktje z%t$Rr@PWW{#9BCT-i%}7;g?_~b!tZzVXf7ful)HQo%_6b&ow%H6G%*OP6(?Uz+3D8 z?%lVvfCvdVWTtplZ1$$Gplpkz%8YyVKK`540!zf6V$X}Qv23*nLW1=o zL_>P^*=H>70o#eGQ>NM$gXaOs;39M$o$cXE?bEjq%RTY>i3!%f5Jw^ekDf>U36oak z*CgiXKu*aWw#oLHtuusTA5z72tvg-8ewUfV9((DikmHOw(vWq%kFR}Gz zOcYktFVAox_%L7yRBl|Dufob=5QDqIDzg?QwV-7T+u9!<7FJ#O0#81pKJv8sSW+vT;}HiJDGahm%e^G0Wo9 zR=5_pleipW)~tstG+0xJeoJv`e-%q!Ute$BQ7O(Rz^r$l-sZv_2;3%YH9q_jnM9^eo$7TIvfo9Kd77h+YL*G24IB1Ti}A-GX7JUV zWAGiY{coJm$rMW;D&WXH_uk`WHRr$mo#N8>I@#c#e){RQPY=ihbwKRI7MOV5M7w7H z)5;#GhyC%+5`>jnwU7}IVk>YAp`6OD0e7l*1w?F9#$0q^B8)e(9b_l*$rFxpBSorh z%(ya%zzsd5i36c$C0e0_#X(`-9AE>S!+oV!`q#Xt|Aroi5aPhFi z4mVE3+N+G$OWsgQU6~J8enadt?wMf|2;0cCJn?62;m$hiEdP&SFOX?ydDs+A2=uKc z37$ToRwK@LQ`n|B;L`44C$Un)BMg=eORup1P0L?v&ya>AsLN06p950zA2JSvQ01%| zH;}LjRp=iz@#P3z1>_cPO#<`%%=>2gda)W??Nh2CoHT2`a+}deK z4Dsg5s{B6w9{2el%FKUO`ZxU`O`bfdVA0uU?|o$TxX8Gi<;%YmF`k(fj+Vm@Ch1@N z;xs>Cz^(~CkdZnDDX3<7c&CD?APwZ@FO(A1ozCNj93A8N1t$Rm$Pl@>|f!L2~( zcCVjsKnU&d!w2b%*rg%lp7ej(5}{Hnr4HTU2RryVk{f&>*4f^(dIC`fTxiZ~63=LQ z+Q&MiFNmHkU%(r#{rdH_y)-b!K4@M>T4B*(Yjqxg2sSRF=O+mW(-}TM7XP@fR%klfQoAMB8db1d>BOh=bJ{|9vzA#bD4| zhBQyh>W?-?2pGzs&T!w{*OdO1A$r_`<@K597lt~oiMxX6Im3@AN|o%zi@_#5*MXmY zisVTcN?EERTqv)m2_D2EAx*~4m>DtvrZHCpNWJA_K3l;BLITk+c)*$D8>^N#cxX($ z&H2tD867(WhY2lbjS)PfT%D}y^33vo)Y*MGVk;yD;KY^yq`&*_8CsdYi1KGvcE*VJ zVbSG2TeRMtrB!)WA)DALkl)x6UBmmiHZ(WEhdK-U8nq?)JmATAX({qGM_4`r8`^%m z>=*JmfpTLI|DR*l&eUH0Ey-C7KB~CdFrhCsPw)%^iJ7!stMIux2(gPCE#kl`caO?r ztF5=P=&hV}7g7K$>^!#S<;tnW|FQMP)}4?@l?pj^^sv7|_UP!yNjbzSM0O<82nRVR zRKYN_c>@f&BHa5?#;i9DMtwEOBDFVS=Y`B9(K^H)CX{4f(}>-k(OxCE608rIDC2z` ze)w>&7zDN=!-khK7mmLd?y)94R)>GSA{8b$PRfl(Xlr$@l)XX_d>D)X4A@Uje?3WY z^ZJOjLaNLR$qWn9V<`nU&~N8{{&;zh-7O2LcY~f7fKreWSAdKiGbX8F5ZdY$WIae$ za!Ap0Lb*6H#fTsS8&rJb$B#8jy?}_o4gz4m&sdy$?zu%-c@9i?;)%yCKUzCsvwcEY zt#{2fB{Zqzl|KJVoaTrT!xKc!mMMe|lW)D}q}OuWDDVqwPTaX))G5r(P3Qr}^dV00 z4G4gtCRaT{Z|RXbLRC?Gf_6@X?e^bqKl>2sTC0!~+wX*gPL3XqaLf|QInA~fsyX?7 z^o!a^vf6XAX3cW)D~}%3!Z%NR-Q79^VorDEsQdDan4E^x*0sgcyD0;uPj^G718Ss$APyWtKXF1WpR|;OV#tLO z6lG|dEkJQRr3S?cuv3qT{v=IC6zL^xu*)~6DmhlEN-0e)q}%``Q6|ie=~o&<{_x>< zu6zl)-0vTG#NvhVPphU(vy#1YM&vsw9-#KzPVS>IU|N7@AMzGNV7EuHF4`P4p(Z>vKvbZZ)ojP{h zSgYPxTk%$1b+t?-5PPPRDb=>#qkM=LZwa(XZer$Le96U%m$!a4v&_MrtTkO-U7eHK z&bZ9%pSqF2&n60pUBcrCX01r)6z7#@MJ~$Du^YaIU3#iwGjU@Wxp__!cPGCClM_hblx-87z*%RX?FR2Z*sZbVTG4ydpUqLrH~F|$ zqOF+@>97^I=bnRXVg^1-6p>jjw2m=gf<|^ik|5d1H^e655~a5IUo}SX@&1Rws21vY zi->yl)z{R<>K^Z^?m?6uJ+`xGwCoubc+vglH}@xUNfNB^-}|^xKl_=^oaJzs|QUf4dci*h4S4mVi?%8d-ut* z^^6HJjW7YkifPr`8YcMa!bRq^PP%3H(n~K@x8N$21|fIp(p53^&$Bq|Xb^PztHo1~ z7vF!ET^uXS_j~k6;3(*tB=q=O`S*}A;pP7Z;1k(uoyPGq;ts95{2R=gFs>qR`mwt@W}V71oMHc0R>LNI81CsRl#`*Yjxw#EP$8WmZBu`l?sSmI$ibmIBvpLYuB ztUdL@L^H{Q0EoRK+yF)8$d{~1WK6uA{SoDX*yqoG*(Q~{?wn>U{O-H&rsJZwww&>K ziIaO?UHGbPg|fxyX0y#Ub;llij2kp)PiqhLhZe;1t+@B}rg)EF?GcZ+2Z4_#b@?vbV_%Dl1-536$J;S8mA2Q@X)3ry!!SHWeyk>z?&^g82H{&J*sq!i` zf=BO;3JzhO4l+&1JPHbEg$FfYcvFpDM+llINsq9`yYk8_+%31>lK88+f1nbp6MNUY z_fBr{;(E855Q14>=Db-}ojluYyNw9>bccKaHeL=siKZc`M<}MqE4Fgv2(r}rLlK4W zVC6=Bp<|}qefQmG#~2|T?BBn?$qlSxdoA}a$1}b0<{Q~I(dI$oGSL6Cl6hsdVAk0j zZS5uyd${KMXA`gbFB5M~d|!8RNV0K6%c96O22)mi#=VwO8zJ~4NP#idY3NADj~ic( zI+8Cp@fRzgoLbzGM;+x*19B#s;=D^8_@FyV&q4xGjyd`mW2w)VKK+h6l9qp{yVzGl z?6CBpoZ*gcq8)Z25=LbgA{g(nXIF3$ckjIsv6J@)KiJ;(;H*$qepG`#Iz?^6#iQ@T}kjA0c-R z{DQB3KWWSFO*h?a&%+liVV^#|B|*PLM7D`rF5HTG8s@G*o^gT;IYKG9D=IZ~ z`!i+ndvE%XeM^sp%EG z(eNxRZ0%%hfkz&?SXOC;WEO!&5^hSwsYKCBoQ_p;n{3k2Gy|B&E*9HIQou}|{XRhZ zyr(2zxYB*OJZH5A>@n8BiVmk3lA%`&$6~kke~$PwKVuh;TgHM;ar+89xpZ!w>e@p6 zDJ+p9-g)Q$>=TM(KdQqYqci3+@1(yO-uUWWBe2vSRG{rJ&a{p&J+7+;6I0DvWJ8ju zlL4qManRy8!afy2Q*$O@g|G{Fu}4E!&3;!N=Gw1+sa5eUsR7@x^S+rF*vDhdhSZh> z>#vhcoZ`I~xR+8k&KWut)&xOet%!8o@!nEW_FsM^lI7Hwf?YU_YZR;@@jpLUsTCj3 zCa?IP0Wowqqf~MW-Lj0kEds=@-MYGsbxu7?S*Sh!?pNnNe=I`JYY!@nC-?^*fUC5; z_F5U>xAM9Y0xKa attribute !== "title"), code: [...(defaultSchema.attributes?.code ?? []), "dataCodeMeta"], + video: ["src", "controls", "muted", "loop", "playsInline", "poster", "preload"], }, protocols: { ...defaultSchema.protocols, @@ -183,8 +186,36 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ remarkPreserveCodeMeta, ] satisfies NonNullable; +interface HastNodeLike { + readonly type: string; + readonly tagName?: string; + properties?: Record; + children?: HastNodeLike[]; +} + +const WINDOWS_DRIVE_SRC_PATTERN = /^[A-Za-z]:[\\/]/; + +function rehypeEscapeWindowsDriveMediaSrc() { + const escapeNode = (node: HastNodeLike): void => { + if ( + node.type === "element" && + (node.tagName === "img" || node.tagName === "video") && + node.properties && + typeof node.properties.src === "string" && + WINDOWS_DRIVE_SRC_PATTERN.test(node.properties.src) + ) { + node.properties.src = `/${node.properties.src}`; + } + for (const child of node.children ?? []) { + escapeNode(child); + } + }; + return escapeNode; +} + const CHAT_MARKDOWN_REHYPE_PLUGINS = [ rehypeRaw, + rehypeEscapeWindowsDriveMediaSrc, [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], ] satisfies NonNullable; @@ -1490,6 +1521,24 @@ function ChatMarkdown({ /> ); }, + img({ node: _node, src, alt }) { + return ( + + ); + }, + video({ node: _node, src }) { + return ( + + ); + }, table({ node: _node, ...props }) { return ; }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 279b1381863..fcb78f52789 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, MessageId, ProjectId, + ProviderDriverKind, ProviderInstanceId, ThreadId, RunId, @@ -21,18 +22,24 @@ import { createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, getStartedThreadModelChangeBlockReason, hasServerAcknowledgedLocalDispatch, + isHermesClearChatCommand, + isHermesFreshChatCommand, isBranchMismatchDismissedForSession, + isWorkspacePreparationTurnItem, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - startNewThreadForProject, + shouldExposeWorkspaceArtifacts, shouldShowBranchMismatchBanner, shouldShowComposerContextStrip, + shouldShowWorkingTimeline, shouldWriteThreadErrorToCurrentServerThread, + startNewThreadForProject, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -40,6 +47,52 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("Hermes native fresh-chat commands", () => { + it.each(["/new", "/reset Work"])("recognizes %s only for Hermes", (text) => { + expect(isHermesFreshChatCommand({ text, isHermesConversation: true })).toBe(true); + expect(isHermesFreshChatCommand({ text, isHermesConversation: false })).toBe(false); + }); + + it.each(["/newer", "/clear", "/retry"])("does not intercept %s", (text) => { + expect(isHermesFreshChatCommand({ text, isHermesConversation: true })).toBe(false); + }); + + it("recognizes exact /clear only for Hermes", () => { + expect(isHermesClearChatCommand({ text: " /clear ", isHermesConversation: true })).toBe(true); + expect(isHermesClearChatCommand({ text: "/clear", isHermesConversation: false })).toBe(false); + expect(isHermesClearChatCommand({ text: "/clear all", isHermesConversation: true })).toBe( + false, + ); + expect(isHermesClearChatCommand({ text: "please /clear", isHermesConversation: true })).toBe( + false, + ); + }); +}); + +describe("working timeline visibility", () => { + it.each(["connecting", "running"] as const)("stays visible while a run is %s", (phase) => { + expect( + shouldShowWorkingTimeline({ + phase, + isSendBusy: false, + isConnecting: false, + isRevertingCheckpoint: false, + }), + ).toBe(true); + }); + + it("hides for an idle ready thread", () => { + expect( + shouldShowWorkingTimeline({ + phase: "ready", + isSendBusy: false, + isConnecting: false, + isRevertingCheckpoint: false, + }), + ).toBe(false); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return makeThreadFixture({ id: threadId, @@ -86,6 +139,54 @@ const readySession = { updatedAt: "2026-03-29T00:00:10.000Z", }; +describe("deriveLockedProvider", () => { + it("resolves a custom provider instance id to its owning driver", () => { + const hermesInstanceId = ProviderInstanceId.make("hermes_local"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { + instanceId: hermesInstanceId, + model: "default", + }, + latestRun: completedTurn, + runtime: { + ...readySession, + providerName: hermesInstanceId, + providerInstanceId: hermesInstanceId, + }, + }), + selectedProvider: null, + threadProvider: hermesInstanceId, + providerInstances: [ + { + instanceId: hermesInstanceId, + driver: ProviderDriverKind.make("hermes"), + }, + ], + }), + ).toBe("hermes"); + }); + + it("preserves an unknown open driver kind when no instance metadata exists", () => { + expect( + deriveLockedProvider({ + thread: makeThread({ + latestRun: completedTurn, + runtime: { + ...readySession, + providerName: "forkDriver", + providerInstanceId: ProviderInstanceId.make("forkDriver"), + }, + }), + selectedProvider: null, + threadProvider: "forkDriver", + providerInstances: [], + }), + ).toBe("forkDriver"); + }); +}); + describe("resolveThreadMetadataUpdateForNextTurn", () => { const modelSelection = { instanceId: ProviderInstanceId.make("codex"), @@ -121,16 +222,29 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: true, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(true); }); + it("hides git context for a projectless conversation backed by an internal project", () => { + expect( + shouldShowComposerContextStrip({ + routeKind: "draft", + isGitRepo: true, + hasActiveProject: true, + isProjectlessConversation: true, + }), + ).toBe(false); + }); + it("hides git context after the draft becomes a thread", () => { expect( shouldShowComposerContextStrip({ routeKind: "server", isGitRepo: true, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(false); }); @@ -141,6 +255,7 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: false, hasActiveProject: true, + isProjectlessConversation: false, }), ).toBe(false); expect( @@ -148,10 +263,51 @@ describe("shouldShowComposerContextStrip", () => { routeKind: "draft", isGitRepo: true, hasActiveProject: false, + isProjectlessConversation: false, }), ).toBe(false); }); }); + +describe("shouldExposeWorkspaceArtifacts", () => { + it("hides git and workspace artifacts for projectless conversations", () => { + expect( + shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: true, + }), + ).toBe(false); + }); + + it("keeps git and workspace artifacts for project conversations", () => { + expect( + shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: false, + }), + ).toBe(true); + }); +}); + +describe("isWorkspacePreparationTurnItem", () => { + it("matches only T3's synthetic workspace item, not provider commands", () => { + expect( + isWorkspacePreparationTurnItem({ + type: "command_execution", + input: "Preparing workspace", + providerTurnId: null, + nativeItemRef: null, + }), + ).toBe(true); + expect( + isWorkspacePreparationTurnItem({ + type: "command_execution", + input: "Preparing workspace", + providerTurnId: "provider-turn-1", + nativeItemRef: { id: "tool-1" }, + }), + ).toBe(false); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 3264951bedd..4aed64c9971 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { presentThreadShell } from "@t3tools/client-runtime/state/shell"; import { type ChatMessage, type SessionPhase, type Thread } from "../types"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadShells } from "../state/threads"; @@ -186,10 +186,9 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { return; } for (const attachment of message.attachments) { - if (attachment.type !== "image") { - continue; + if ("previewUrl" in attachment) { + revokeBlobPreviewUrl(attachment.previewUrl); } - revokeBlobPreviewUrl(attachment.previewUrl); } } @@ -199,9 +198,9 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ } const previewUrls: string[] = []; for (const attachment of message.attachments) { - if (attachment.type !== "image") continue; - if (!attachment.previewUrl || !attachment.previewUrl.startsWith("blob:")) continue; - previewUrls.push(attachment.previewUrl); + if ("previewUrl" in attachment && attachment.previewUrl?.startsWith("blob:")) { + previewUrls.push(attachment.previewUrl); + } } return previewUrls; } @@ -239,13 +238,68 @@ export function shouldShowComposerContextStrip(input: { routeKind: "draft" | "server"; isGitRepo: boolean; hasActiveProject: boolean; + isProjectlessConversation: boolean; +}): boolean { + return ( + !input.isProjectlessConversation && + input.routeKind === "draft" && + input.isGitRepo && + input.hasActiveProject + ); +} + +export function shouldExposeWorkspaceArtifacts(input: { + readonly isProjectlessConversation: boolean; +}): boolean { + return !input.isProjectlessConversation; +} + +export function isHermesFreshChatCommand(input: { + readonly text: string; + readonly isHermesConversation: boolean; +}): boolean { + if (!input.isHermesConversation) return false; + return /^\/(?:new|reset)(?:\s+.*)?$/iu.test(input.text.trim()); +} + +export function isHermesClearChatCommand(input: { + readonly text: string; + readonly isHermesConversation: boolean; +}): boolean { + if (!input.isHermesConversation) return false; + return /^\/clear$/iu.test(input.text.trim()); +} + +export function shouldShowWorkingTimeline(input: { + readonly phase: SessionPhase; + readonly isSendBusy: boolean; + readonly isConnecting: boolean; + readonly isRevertingCheckpoint: boolean; }): boolean { - return input.routeKind === "draft" && input.isGitRepo && input.hasActiveProject; + return ( + input.phase === "connecting" || + input.phase === "running" || + input.isSendBusy || + input.isConnecting || + input.isRevertingCheckpoint + ); } -export function cloneComposerImageForRetry( - image: ComposerImageAttachment, -): ComposerImageAttachment { +export function isWorkspacePreparationTurnItem(item: { + readonly type: string; + readonly input?: unknown; + readonly providerTurnId?: string | null | undefined; + readonly nativeItemRef?: unknown; +}): boolean { + return ( + item.type === "command_execution" && + item.input === "Preparing workspace" && + item.providerTurnId === null && + item.nativeItemRef === null + ); +} + +export function cloneComposerImageForRetry(image: ComposerAttachment): ComposerAttachment { if (typeof URL === "undefined" || !image.previewUrl.startsWith("blob:")) { return image; } @@ -354,39 +408,31 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { return Boolean(thread && (thread.latestRun !== null || thread.itemCount > 0 || thread.runtime)); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Session/model routing values are provider instance ids. Resolve them through +// the environment's provider inventory before falling back to the open driver +// slug shape used by rollback/fork builds. This keeps a custom instance such +// as `hermes_local` locked to its actual `hermes` driver after the first turn. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providerInstances: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; } + const resolveDriverKind = (selection: string | null): ProviderDriverKind | null => { + if (!selection) return null; + const instance = input.providerInstances.find((entry) => entry.instanceId === selection); + if (instance) return instance.driver; + return isProviderDriverKind(selection) ? selection : null; + }; const sessionProvider = input.thread?.runtime?.providerName ?? null; - if (sessionProvider && isProviderDriverKind(sessionProvider)) { - return sessionProvider; - } - const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; - const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; - return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; + return ( + resolveDriverKind(sessionProvider) ?? + resolveDriverKind(input.threadProvider) ?? + resolveDriverKind(input.selectedProvider) + ); } export function getStartedThreadModelChangeBlockReason(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 68dca3198b7..e6c75eb9f01 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -190,7 +190,7 @@ import { } from "../logicalProject"; import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { - type ComposerImageAttachment, + type ComposerAttachment, type DraftThreadEnvMode, useComposerDraftStore, type DraftId, @@ -280,6 +280,9 @@ import { dismissBranchMismatchForSession, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, + isHermesClearChatCommand, + isHermesFreshChatCommand, + isWorkspacePreparationTurnItem, shouldShowBranchMismatchBanner, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -292,6 +295,8 @@ import { reconcileMountedTerminalThreadIds, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, + shouldExposeWorkspaceArtifacts, + shouldShowWorkingTimeline, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, shouldShowComposerContextStrip, @@ -325,13 +330,15 @@ import { serverUpdateGuidance, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attachment(s).]"; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_ATTACHMENT_IDS: string[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; +const EMPTY_TURN_DIFF_SUMMARIES: ReadonlyArray = []; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -1168,6 +1175,7 @@ function ChatViewContent(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); + const handleNewThread = useNewThreadHandler(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, @@ -1282,7 +1290,7 @@ function ChatViewContent(props: ChatViewProps) { : null, ); const promptRef = useRef(""); - const composerImagesRef = useRef([]); + const composerImagesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); @@ -1925,17 +1933,19 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.modelSelection.instanceId ?? activeProject?.defaultModelSelection?.instanceId ?? null; + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const lockedProvider = deriveLockedProvider({ thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providerInstances: providerStatuses, }); const modelPickerLockedProvider = supportsProviderSwitchingViaHandoff ? null : lockedProvider; - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2056,13 +2066,13 @@ function ChatViewContent(props: ChatViewProps) { versionMismatchSelfUpdate, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider, ); const selectedProvider: ProviderDriverKind = modelPickerLockedProvider ?? unlockedSelectedProvider; + const isHermesConversation = selectedProvider === ProviderDriverKind.make("hermes"); const phase = derivePhase(activeRuntime); const pendingRequests = useMemo( () => @@ -2156,7 +2166,12 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const isWorking = shouldShowWorkingTimeline({ + phase, + isSendBusy, + isConnecting, + isRevertingCheckpoint, + }); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestRun, activeRuntime, @@ -2230,8 +2245,13 @@ function ChatViewContent(props: ChatViewProps) { attachmentIds.add(attachment.id); } } + for (const message of serverProjection?.messages ?? []) { + for (const attachment of message.attachments) { + attachmentIds.add(attachment.id); + } + } return [...attachmentIds]; - }, [serverVisibleTurnItems]); + }, [serverProjection?.messages, serverVisibleTurnItems]); const serverAttachmentIds = isServerThread ? committedServerAttachmentIds : EMPTY_ATTACHMENT_IDS; const serverAttachmentResources = useMemo( () => @@ -2277,11 +2297,9 @@ function ChatViewContent(props: ChatViewProps) { } const serverPreviewUrls = serverMessage.attachments.flatMap((attachment) => - attachment.type === "image" - ? [serverAttachmentUrlById.get(attachment.id)].filter( - (previewUrl): previewUrl is string => previewUrl !== undefined, - ) - : [], + [serverAttachmentUrlById.get(attachment.id)].filter( + (previewUrl): previewUrl is string => previewUrl !== undefined, + ), ); if ( serverPreviewUrls.length === 0 || @@ -2296,8 +2314,15 @@ function ChatViewContent(props: ChatViewProps) { let cancelled = false; const imageInstances: HTMLImageElement[] = []; + const imagePreviewUrls = serverMessage.attachments.flatMap((attachment) => + attachment.type === "image" + ? [serverAttachmentUrlById.get(attachment.id)].filter( + (previewUrl): previewUrl is string => previewUrl !== undefined, + ) + : [], + ); const preloadServerPreviews = Promise.all( - serverPreviewUrls.map( + imagePreviewUrls.map( (previewUrl) => new Promise((resolve, reject) => { const image = new Image(); @@ -2351,20 +2376,26 @@ function ChatViewContent(props: ChatViewProps) { if (row.item.type !== "user_message") continue; const handoffUrls = attachmentPreviewHandoffByMessageId[row.item.messageId]; if (handoffUrls === undefined) continue; - let imageIndex = 0; - for (const attachment of row.item.attachments) { - if (attachment.type !== "image") continue; - const handoffUrl = handoffUrls[imageIndex]; - imageIndex += 1; + for (const [attachmentIndex, attachment] of row.item.attachments.entries()) { + const handoffUrl = handoffUrls[attachmentIndex]; if (handoffUrl !== undefined) urls.set(attachment.id, handoffUrl); } } return urls; }, [attachmentPreviewHandoffByMessageId, serverAttachmentUrlById, serverVisibleTurnItems]); + const displayedServerTurnItems = useMemo( + () => + isHermesConversation + ? serverVisibleTurnItems.filter((row) => !isWorkspacePreparationTurnItem(row.item)) + : serverVisibleTurnItems, + [isHermesConversation, serverVisibleTurnItems], + ); const serverTimelineEntries = useMemo( () => deriveTimelineEntriesFromVisibleTurnItems({ - visibleTurnItems: serverVisibleTurnItems, + visibleTurnItems: displayedServerTurnItems, + projectionMessages: + isHermesConversation && serverProjection !== null ? serverProjection.messages : [], optimisticMessages: optimisticUserMessages, attachmentUrlById: timelineAttachmentUrlById, ...(serverProjection === null @@ -2374,7 +2405,13 @@ function ChatViewContent(props: ChatViewProps) { nodes: serverProjection.nodes, }), }), - [optimisticUserMessages, serverVisibleTurnItems, serverProjection, timelineAttachmentUrlById], + [ + displayedServerTurnItems, + isHermesConversation, + optimisticUserMessages, + serverProjection, + timelineAttachmentUrlById, + ], ); const draftTimelineEntries = useMemo( () => @@ -2398,30 +2435,37 @@ function ChatViewContent(props: ChatViewProps) { const draftHeroTransition = useDraftHeroLayoutTransition(isDraftHeroState); const captureDraftHeroComposerRect = draftHeroTransition.captureComposerRect; const { turnDiffSummaries } = useTurnDiffSummaries(serverProjection); + const exposeWorkspaceArtifacts = shouldExposeWorkspaceArtifacts({ + isProjectlessConversation: isHermesConversation, + }); + const visibleTurnDiffSummaries = exposeWorkspaceArtifacts + ? turnDiffSummaries + : EMPTY_TURN_DIFF_SUMMARIES; const turnDiffSummaryByAssistantMessageId = useMemo(() => { const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { + for (const summary of visibleTurnDiffSummaries) { if (!summary.assistantMessageId) continue; byMessageId.set(summary.assistantMessageId, summary); } return byMessageId; - }, [turnDiffSummaries]); + }, [visibleTurnDiffSummaries]); const revertTurnCountByUserMessageId = useMemo( () => deriveRevertTurnCountByUserMessageId({ timelineEntries, - checkpoints: turnDiffSummaries, + checkpoints: visibleTurnDiffSummaries, }), - [timelineEntries, turnDiffSummaries], + [timelineEntries, visibleTurnDiffSummaries], ); - const gitCwd = activeProject - ? projectScriptCwd({ - project: { cwd: activeProject.workspaceRoot }, - worktreePath: activeThread?.worktreePath ?? null, - }) - : null; - const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; + const gitCwd = + exposeWorkspaceArtifacts && activeProject + ? projectScriptCwd({ + project: { cwd: activeProject.workspaceRoot }, + worktreePath: activeThread?.worktreePath ?? null, + }) + : null; + const gitStatusCwd = exposeWorkspaceArtifacts ? (activeThread?.worktreePath ?? gitCwd) : null; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null ? null @@ -2484,7 +2528,9 @@ function ChatViewContent(props: ChatViewProps) { const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; - const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + const activeWorkspaceRoot = exposeWorkspaceArtifacts + ? (activeThreadWorktreePath ?? activeProjectCwd ?? undefined) + : undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Default true while loading to avoid toolbar flicker. @@ -2493,6 +2539,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind, isGitRepo, hasActiveProject: activeProject !== null, + isProjectlessConversation: isHermesConversation, }); const initialDiffPanelGitScope = gitStatusQuery.data?.hasWorkingTreeChanges === true ? "unstaged" : "branch"; @@ -4638,7 +4685,11 @@ function ChatViewContent(props: ChatViewProps) { ); const onForkFromRun = useCallback( - async (input: { readonly sourceThreadId: ThreadId; readonly runId: RunId }) => { + async (input: { + readonly sourceThreadId: ThreadId; + readonly runId: RunId; + readonly latestOnly?: boolean; + }) => { if (!activeThread || activeEnvironmentUnavailable) return; const targetThreadId = newThreadId(); const targetThreadRef = scopeThreadRef(environmentId, targetThreadId); @@ -4648,6 +4699,7 @@ function ChatViewContent(props: ChatViewProps) { sourceThreadId: input.sourceThreadId, targetThreadId, runId: input.runId, + ...(input.latestOnly ? { latestOnly: true } : {}), title: `${activeThread.title} fork`, }, }); @@ -4684,6 +4736,46 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const clearCurrentHermesTimeline = useCallback(async () => { + if (!activeThread || !isHermesConversation) { + return false; + } + if (!isServerThread) { + scheduleComposerFocus(); + return true; + } + const result = await updateThreadMetadata({ + environmentId, + input: { + threadId: activeThread.id, + clearTimeline: true, + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to clear chat", + description: chatActionErrorMessage(squashAtomCommandFailure(result)), + }), + ); + } + return false; + } + scrollToEnd(); + scheduleComposerFocus(); + return true; + }, [ + activeThread, + environmentId, + isHermesConversation, + isServerThread, + scheduleComposerFocus, + scrollToEnd, + updateThreadMetadata, + ]); + const onSend = async ( e?: { preventDefault: () => void }, dispatchMode: ComposerDispatchMode = "auto", @@ -4752,6 +4844,44 @@ function ChatViewContent(props: ChatViewProps) { composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; + const isStandaloneHermesCommand = + composerImages.length === 0 && + sendableComposerTerminalContexts.length === 0 && + composerElementContexts.length === 0 && + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0; + if ( + isStandaloneHermesCommand && + isHermesClearChatCommand({ + text: trimmed, + isHermesConversation, + }) + ) { + const cleared = await clearCurrentHermesTimeline(); + if (cleared) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + } + return; + } + if ( + isStandaloneHermesCommand && + isHermesFreshChatCommand({ + text: trimmed, + isHermesConversation, + }) && + activeProject + ) { + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + await handleNewThread(scopeProjectRef(activeProject.environmentId, activeProject.id), { + fresh: true, + modelSelection: ctxSelectedModelSelection, + }); + return; + } if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); promptRef.current = ""; @@ -4843,19 +4973,49 @@ function ChatViewContent(props: ChatViewProps) { model: ctxSelectedModel, models: ctxSelectedProviderModels, effort: ctxSelectedPromptEffort, - text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, + text: messageTextForSend || ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, }); const turnAttachmentsPromise = Promise.all( - composerImagesSnapshot.map(async (image) => ({ - type: "image" as const, - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - })), + composerImagesSnapshot.map(async (image) => { + const dataUrl = await readFileAsDataUrl(image.file); + switch (image.type) { + case "image": + return { + type: "image" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "file": + return { + type: "file" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "pdf": + return { + type: "pdf" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + case "video": + return { + type: "video" as const, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl, + }; + } + }), ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ - type: "image" as const, + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -4912,17 +5072,17 @@ function ChatViewContent(props: ChatViewProps) { clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - let firstComposerImageName: string | null = null; + let firstComposerAttachmentName: string | null = null; if (composerImagesSnapshot.length > 0) { - const firstComposerImage = composerImagesSnapshot[0]; - if (firstComposerImage) { - firstComposerImageName = firstComposerImage.name; + const firstComposerAttachment = composerImagesSnapshot[0]; + if (firstComposerAttachment) { + firstComposerAttachmentName = firstComposerAttachment.name; } } let titleSeed = trimmed; if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; + if (firstComposerAttachmentName) { + titleSeed = `Attachment: ${firstComposerAttachmentName}`; } else if (composerTerminalContextsSnapshot.length > 0) { titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); } else if (composerElementContextsSnapshot.length > 0) { @@ -4977,7 +5137,7 @@ function ChatViewContent(props: ChatViewProps) { let turnStartSucceeded = false; if (failure === null && turnAttachmentsResult._tag === "Success") { const bootstrap = - isLocalDraftThread || baseBranchForWorktree + isLocalDraftThread || (baseBranchForWorktree && !isHermesConversation) ? { ...(isLocalDraftThread ? { @@ -4987,13 +5147,14 @@ function ChatViewContent(props: ChatViewProps) { modelSelection: threadCreateModelSelection, runtimeMode, interactionMode, - branch: activeThreadBranch, - worktreePath: activeThread.worktreePath, + branch: isHermesConversation ? null : activeThreadBranch, + worktreePath: isHermesConversation ? null : activeThread.worktreePath, createdAt: activeThread.createdAt, }, + ...(isHermesConversation ? { prepareWorkspace: false } : {}), } : {}), - ...(baseBranchForWorktree + ...(baseBranchForWorktree && !isHermesConversation ? { prepareWorktree: { projectCwd: activeProject.workspaceRoot, @@ -5843,6 +6004,7 @@ function ChatViewContent(props: ChatViewProps) { environmentConnection: activeEnvironment?.connection ?? null, threadId: activeThread.id, ...(draftId ? { draftId } : {}), + isProjectlessConversation: isHermesConversation, activeProjectName: activeProject?.title, activeProjectScripts: activeProject?.scripts, preferredScriptId: activeProject @@ -5888,7 +6050,7 @@ function ChatViewContent(props: ChatViewProps) { onDeleteProjectScript: deleteProjectScript, }; const panelToggleControlProps = { - terminalAvailable: activeProject !== null, + terminalAvailable: activeProject !== null && !isHermesConversation, terminalOpen: terminalUiState.terminalOpen, terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), threadPanelOpen, @@ -5908,7 +6070,7 @@ function ChatViewContent(props: ChatViewProps) { threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), threadPanelHasAttention: activeEnvironmentUnavailableState !== null || showVersionMismatchBanner, - rightPanelAvailable: activeProject !== null, + rightPanelAvailable: activeProject !== null && !isHermesConversation, rightPanelOpen, rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), onToggleTerminal: toggleTerminalVisibility, @@ -5919,6 +6081,8 @@ function ChatViewContent(props: ChatViewProps) { ); const threadPanelHeaderControl = ( @@ -6015,6 +6179,9 @@ function ChatViewContent(props: ChatViewProps) { activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} + timelineClearedAt={ + isHermesConversation ? (activeThread.timelineClearedAt ?? null) : null + } latestRun={activeLatestRun} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} activeThreadEnvironmentId={activeThread.environmentId} @@ -6091,6 +6258,7 @@ function ChatViewContent(props: ChatViewProps) { @@ -6133,6 +6301,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread={activeThread} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} + isProjectlessConversation={isHermesConversation} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} projectSelectionRequired={isLocalDraftThread && activeProject === null} phase={phase} @@ -6176,6 +6345,29 @@ function ChatViewContent(props: ChatViewProps) { shouldAutoScrollRef={isAtEndRef} scheduleStickToBottom={scrollToEnd} onSend={onSend} + onStartFreshChat={() => { + const sendContext = composerRef.current?.getSendContext(); + if (!activeProject || !sendContext) return; + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + void handleNewThread( + scopeProjectRef(activeProject.environmentId, activeProject.id), + { + fresh: true, + modelSelection: sendContext.selectedModelSelection, + }, + ); + }} + onClearChat={() => { + void (async () => { + const cleared = await clearCurrentHermesTimeline(); + if (!cleared) return; + promptRef.current = ""; + clearComposerDraftContent(composerDraftTarget); + composerRef.current?.resetCursorState(); + })(); + }} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} onRespondToApproval={onRespondToApproval} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..7ce2b2892e2 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -27,6 +27,7 @@ import { FolderPlusIcon, LinkIcon, MessageSquareIcon, + MessagesSquareIcon, SettingsIcon, SquarePenIcon, } from "lucide-react"; @@ -109,9 +110,22 @@ import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; -import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; -import { resolveDefaultProviderModelSelection } from "../providerInstances"; +import { + environmentServerConfigsAtom, + primaryServerKeybindingsAtom, + primaryServerProvidersAtom, +} from "../state/server"; +import { + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, +} from "../providerInstances"; import { resolveShortcutCommand, threadJumpIndexFromCommand } from "../keybindings"; +import { + isT3WorkBackingProject, + T3_WORK_BACKING_PROJECT_ID, + T3_WORK_BACKING_PROJECT_TITLE, + t3WorkDirectoryForEnvironment, +} from "../t3WorkProject"; import { Command, CommandDialog, @@ -499,6 +513,18 @@ function OpenCommandPaletteDialog(props: { const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const hermesProviderEntry = useMemo( + () => + deriveProviderInstanceEntries(providers).find( + (entry) => + entry.driverKind === "hermes" && + entry.enabled && + entry.isAvailable && + entry.status === "ready", + ) ?? null, + [providers], + ); const [viewStack, setViewStack] = useState([]); const currentView = viewStack.at(-1) ?? null; const [browseGeneration, setBrowseGeneration] = useState(0); @@ -521,10 +547,14 @@ function OpenCommandPaletteDialog(props: { ), [environments], ); + const visibleProjects = useMemo( + () => projects.filter((project) => !isT3WorkBackingProject(project, serverConfigs)), + [projects, serverConfigs], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ - items: projects, + items: visibleProjects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -532,12 +562,13 @@ function OpenCommandPaletteDialog(props: { legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), - [projectOrder, projects], + [projectOrder, visibleProjects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ - projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + projects: + clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : visibleProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -548,7 +579,7 @@ function OpenCommandPaletteDialog(props: { orderedProjects, primaryEnvironmentId, projectGroupingSettings, - projects, + visibleProjects, ], ); const projectGroups = useMemo( @@ -805,6 +836,97 @@ function OpenCommandPaletteDialog(props: { [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], ); + const startFreshHermesChat = useCallback(async () => { + const t3WorkDirectory = t3WorkDirectoryForEnvironment(serverConfigs, primaryEnvironmentId); + const existingBackingProject = + projects.find( + (project) => + project.environmentId === primaryEnvironmentId && + t3WorkDirectory !== null && + project.workspaceRoot === t3WorkDirectory, + ) ?? null; + const hermesModel = + hermesProviderEntry?.models.find((model) => model.slug === "default") ?? + hermesProviderEntry?.models[0] ?? + null; + if ( + primaryEnvironmentId === null || + t3WorkDirectory === null || + !hermesProviderEntry || + !hermesModel + ) { + toastManager.add({ + type: "warning", + title: "Hermes is not ready", + description: "Enable and configure Hermes before starting a new chat.", + }); + return; + } + if (existingBackingProject === null) { + const createResult = await createProject({ + environmentId: primaryEnvironmentId, + input: { + projectId: T3_WORK_BACKING_PROJECT_ID, + title: T3_WORK_BACKING_PROJECT_TITLE, + workspaceRoot: t3WorkDirectory, + createWorkspaceRootIfMissing: true, + defaultModelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + }); + if (createResult._tag === "Failure") { + if (!isAtomCommandInterrupted(createResult)) { + const error = squashAtomCommandFailure(createResult); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not prepare T3 Work", + description: + error instanceof Error + ? error.message + : "The private T3 Work conversation directory could not be created.", + }), + ); + } + return; + } + } + await handleNewThread( + scopeProjectRef( + primaryEnvironmentId, + existingBackingProject?.id ?? T3_WORK_BACKING_PROJECT_ID, + ), + { + fresh: true, + modelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + ); + }, [ + createProject, + handleNewThread, + hermesProviderEntry, + primaryEnvironmentId, + projects, + serverConfigs, + ]); + + const newChatItem = useMemo( + () => ({ + kind: "action", + value: "new-thread:new-chat", + searchTerms: ["new chat", "hermes", "conversation", "fresh"], + title: "New chat", + icon: , + run: startFreshHermesChat, + }), + [startFreshHermesChat], + ); + const projectThreadItems = useMemo( () => enumerateCommandPaletteItems( @@ -1133,7 +1255,7 @@ function OpenCommandPaletteDialog(props: { { value: "projects", label: "Projects", - items: enumerateCommandPaletteItems(prioritized), + items: [newChatItem, ...enumerateCommandPaletteItems(prioritized)], }, ], }); @@ -1141,6 +1263,7 @@ function OpenCommandPaletteDialog(props: { clearOpenIntent, currentProjectEnvironmentId, currentProjectId, + newChatItem, openIntent, projectThreadItems, ]); @@ -1182,7 +1305,13 @@ function OpenCommandPaletteDialog(props: { title: "New thread in...", icon: , addonIcon: , - groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], + groups: [ + { + value: "projects", + label: "Projects", + items: [newChatItem, ...projectThreadItems], + }, + ], }); } diff --git a/apps/web/src/components/HermesImportOnboarding.logic.test.ts b/apps/web/src/components/HermesImportOnboarding.logic.test.ts new file mode 100644 index 00000000000..ccde2756f52 --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.logic.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; +import { ThreadId } from "@t3tools/contracts"; + +import { + describeHermesImportStatus, + hermesTransportLabel, + summarizeHermesImportSessions, +} from "./HermesImportOnboarding.logic.ts"; + +describe("Hermes import onboarding presentation", () => { + it("summarizes transport and 72-hour settlement classifications", () => { + expect( + summarizeHermesImportSessions( + [ + { + storedSessionId: "discord-new", + title: "New", + preview: "", + startedAt: 1, + settlement: "unsettled", + messageCount: 1, + source: "discord", + importedThreadId: null, + }, + { + storedSessionId: "telegram-old", + title: "Old", + preview: "", + startedAt: -2 * 24 * 60 * 60, + settlement: "settled", + messageCount: 1, + source: "telegram", + importedThreadId: null, + }, + { + storedSessionId: "discord-old", + title: "Older", + preview: "", + startedAt: 1, + settlement: "settled", + messageCount: 1, + source: "discord", + importedThreadId: ThreadId.make("thread:existing"), + }, + ], + 1, + 0, + ), + ).toEqual({ + total: 2, + ready: 1, + alreadyImported: 1, + unsettled: 1, + settled: 0, + transports: [{ source: "discord", count: 2 }], + }); + }); + + it("uses friendly built-in labels and a readable forward-compatible fallback", () => { + expect(hermesTransportLabel("telegram")).toBe("Telegram"); + expect(hermesTransportLabel("whatsapp_cloud")).toBe("WhatsApp Cloud"); + expect(hermesTransportLabel("future_transport")).toBe("future transport"); + }); + + it("describes ready conversations and their settlement behavior", () => { + expect( + describeHermesImportStatus( + { + total: 9, + ready: 7, + alreadyImported: 2, + unsettled: 3, + settled: 4, + transports: [], + }, + "30 days", + ), + ).toEqual({ + title: "7 conversations ready to import", + description: + "3 started in the last 72 hours will remain unsettled; 4 older will start settled.", + }); + }); + + it("describes empty and already-imported selections", () => { + expect( + describeHermesImportStatus( + { + total: 0, + ready: 0, + alreadyImported: 0, + unsettled: 0, + settled: 0, + transports: [], + }, + "14 days", + ), + ).toEqual({ + title: "No matching conversations", + description: "No conversations started within the last 14 days.", + }); + + expect( + describeHermesImportStatus( + { + total: 1, + ready: 0, + alreadyImported: 1, + unsettled: 0, + settled: 0, + transports: [{ source: "telegram", count: 1 }], + }, + "14 days", + ), + ).toEqual({ + title: "Hermes is up to date", + description: "All 1 matching conversation is already in T3 Work.", + }); + }); +}); diff --git a/apps/web/src/components/HermesImportOnboarding.logic.ts b/apps/web/src/components/HermesImportOnboarding.logic.ts new file mode 100644 index 00000000000..4262a28263b --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.logic.ts @@ -0,0 +1,96 @@ +import type { HermesDiscoveredSession } from "@t3tools/contracts"; + +const DAY_MS = 24 * 60 * 60 * 1_000; + +export interface HermesImportSummary { + readonly total: number; + readonly ready: number; + readonly alreadyImported: number; + readonly unsettled: number; + readonly settled: number; + readonly transports: ReadonlyArray<{ readonly source: string; readonly count: number }>; +} + +export interface HermesImportStatus { + readonly title: string; + readonly description: string; +} + +export function describeHermesImportStatus( + summary: HermesImportSummary, + ageLabel: string, +): HermesImportStatus { + if (summary.total === 0) { + return { + title: "No matching conversations", + description: `No conversations started within the last ${ageLabel}.`, + }; + } + if (summary.ready === 0) { + return { + title: "Hermes is up to date", + description: `All ${summary.total} matching conversation${summary.total === 1 ? " is" : "s are"} already in T3 Work.`, + }; + } + return { + title: `${summary.ready} conversation${summary.ready === 1 ? "" : "s"} ready to import`, + description: `${summary.unsettled} started in the last 72 hours will remain unsettled; ${summary.settled} older will start settled.`, + }; +} + +export function summarizeHermesImportSessions( + sessions: ReadonlyArray, + activeWithinDays: number, + nowMillis: number, +): HermesImportSummary { + const counts = new Map(); + let ready = 0; + let unsettled = 0; + const matchingSessions = sessions.filter( + (session) => session.startedAt * 1_000 >= nowMillis - activeWithinDays * DAY_MS, + ); + for (const session of matchingSessions) { + counts.set(session.source, (counts.get(session.source) ?? 0) + 1); + if (session.importedThreadId !== null) continue; + ready += 1; + if (session.settlement === "unsettled") unsettled += 1; + } + return { + total: matchingSessions.length, + ready, + alreadyImported: matchingSessions.length - ready, + unsettled, + settled: ready - unsettled, + transports: [...counts] + .map(([source, count]) => ({ source, count })) + .sort((left, right) => left.source.localeCompare(right.source)), + }; +} + +export function hermesTransportLabel(source: string): string { + const known: Readonly> = { + api_server: "API server", + bluebubbles: "BlueBubbles", + dingtalk: "DingTalk", + discord: "Discord", + email: "Email", + feishu: "Feishu", + homeassistant: "Home Assistant", + matrix: "Matrix", + mattermost: "Mattermost", + msgraph_webhook: "Microsoft Graph", + qqbot: "QQ Bot", + signal: "Signal", + slack: "Slack", + sms: "SMS", + telegram: "Telegram", + wecom: "WeCom", + wecom_callback: "WeCom callback", + webhook: "Webhook", + weixin: "Weixin", + whatsapp: "WhatsApp", + whatsapp_cloud: "WhatsApp Cloud", + yuanbao: "Yuanbao", + }; + return known[source] ?? source.replaceAll("_", " "); +} diff --git a/apps/web/src/components/HermesImportOnboarding.tsx b/apps/web/src/components/HermesImportOnboarding.tsx new file mode 100644 index 00000000000..217316d8f15 --- /dev/null +++ b/apps/web/src/components/HermesImportOnboarding.tsx @@ -0,0 +1,295 @@ +import type { + EnvironmentId, + HermesSessionDiscoveryResult, + ProjectId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import { + DEFAULT_HERMES_SESSION_IMPORT_AGE_DAYS, + MAX_HERMES_SESSION_IMPORT_AGE_DAYS, + MIN_HERMES_SESSION_IMPORT_AGE_DAYS, +} from "@t3tools/contracts"; +import { LoaderCircleIcon } from "lucide-react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; + +import { hermesEnvironment } from "../state/hermes"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { + describeHermesImportStatus, + hermesTransportLabel, + summarizeHermesImportSessions, +} from "./HermesImportOnboarding.logic"; + +interface HermesImportOnboardingProps { + readonly environmentId: EnvironmentId | null; + readonly providerInstanceId: ProviderInstanceId | null; + readonly backingProjectId: ProjectId | null; + readonly autoOpenEnabled: boolean; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; +} + +function failureDescription(result: { readonly cause: unknown }): string { + const error = squashAtomCommandFailure(result as Parameters[0]); + return error instanceof Error ? error.message : "Hermes conversations could not be loaded."; +} + +export function HermesImportOnboarding(props: HermesImportOnboardingProps) { + const discover = useAtomCommand(hermesEnvironment.discoverSessions, { + reportFailure: false, + }); + const importSessions = useAtomCommand(hermesEnvironment.importSessions, { + reportFailure: false, + }); + const [discovery, setDiscovery] = useState(null); + const [loading, setLoading] = useState(false); + const [importing, setImporting] = useState(false); + const [activeWithinDays, setActiveWithinDays] = useState(DEFAULT_HERMES_SESSION_IMPORT_AGE_DAYS); + const ageSliderId = useId(); + const autoCheckedKeyRef = useRef(null); + const discoveryRequestIdRef = useRef(0); + + const loadDiscovery = useCallback( + async (autoOpen: boolean) => { + if (props.environmentId === null || props.providerInstanceId === null) return; + const requestId = ++discoveryRequestIdRef.current; + setDiscovery(null); + setLoading(true); + const result = await discover({ + environmentId: props.environmentId, + input: { providerInstanceId: props.providerInstanceId, limit: 10_000 }, + }); + if (requestId !== discoveryRequestIdRef.current) return; + setLoading(false); + if (result._tag === "Success") { + setDiscovery(result.value); + if (autoOpen && result.value.mainThreadId === null) props.onOpenChange(true); + return; + } + if (!isAtomCommandInterrupted(result) && !autoOpen) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not load Hermes conversations", + description: failureDescription(result), + }), + ); + } + }, + [discover, props.environmentId, props.onOpenChange, props.providerInstanceId], + ); + + useEffect( + () => () => { + discoveryRequestIdRef.current += 1; + }, + [], + ); + + useEffect(() => { + if ( + !props.autoOpenEnabled || + props.environmentId === null || + props.providerInstanceId === null || + props.backingProjectId === null + ) { + return; + } + const key = `${props.environmentId}:${props.providerInstanceId}`; + if (autoCheckedKeyRef.current === key) return; + autoCheckedKeyRef.current = key; + void loadDiscovery(true); + }, [ + loadDiscovery, + props.autoOpenEnabled, + props.backingProjectId, + props.environmentId, + props.providerInstanceId, + ]); + + useEffect(() => { + if (props.open) void loadDiscovery(false); + }, [loadDiscovery, props.open]); + + const summary = useMemo( + () => summarizeHermesImportSessions(discovery?.sessions ?? [], activeWithinDays, Date.now()), + [activeWithinDays, discovery], + ); + const ageLabel = `${activeWithinDays} ${activeWithinDays === 1 ? "day" : "days"}`; + const importStatus = describeHermesImportStatus(summary, ageLabel); + + const runImport = useCallback(async () => { + if ( + props.environmentId === null || + props.providerInstanceId === null || + props.backingProjectId === null + ) { + return; + } + setImporting(true); + const result = await importSessions({ + environmentId: props.environmentId, + input: { + providerInstanceId: props.providerInstanceId, + backingProjectId: props.backingProjectId, + selection: { type: "all" }, + activeWithinDays, + ensureMain: true, + }, + }); + setImporting(false); + if (result._tag === "Success") { + const newlyImported = result.value.imported.filter( + (item) => item.status === "imported", + ).length; + props.onOpenChange(false); + toastManager.add({ + type: "success", + title: newlyImported === 0 ? "Hermes is up to date" : "Hermes conversations imported", + description: + newlyImported === 0 + ? "No new transport conversations were found for this Hermes profile." + : `${newlyImported} conversation${newlyImported === 1 ? "" : "s"} added to T3 Work.`, + }); + return; + } + if (!isAtomCommandInterrupted(result)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Hermes import failed", + description: failureDescription(result), + }), + ); + } + }, [ + importSessions, + activeWithinDays, + props.backingProjectId, + props.environmentId, + props.onOpenChange, + props.providerInstanceId, + ]); + + return ( + + + + Import Hermes conversations + + Choose how far back to bring this profile’s transport history into T3 Work. + + + + {loading && discovery === null ? ( +
+ + Looking for conversations… +
+ ) : ( + <> +
+
+ + + {ageLabel} + +
+ setActiveWithinDays(Number(event.currentTarget.value))} + step={1} + type="range" + value={activeWithinDays} + /> + +
+ +
+

{importStatus.title}

+

+ {importStatus.description} +

+ {summary.total === 0 ? ( +

+ Messaging transports appear after this profile has sessions. +

+ ) : null} +
+ + {summary.transports.length > 0 ? ( +
+ + Transports + +
    + {summary.transports.map((transport) => ( +
  • + {hermesTransportLabel(transport.source)}{" "} + + {transport.count} + +
  • + ))} +
+
+ ) : null} + {discovery?.capabilities.activityTimestamp.limitation ? ( +

+ {discovery.capabilities.activityTimestamp.limitation} +

+ ) : null} + + )} +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c51958..d39ad02fe3c 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -663,6 +663,66 @@ export const OpenCodeIcon: Icon = (props) => ( ); +/** OpenClaw logo from https://svgl.app/library/openclaw.svg. */ +export const OpenClawIcon: Icon = ({ className, ...props }) => ( + + + + + + + + + + + + + + + + + +); + +/** + * Hermes Agent logo from the user-selected, commit-pinned upstream asset: + * https://github.com/NousResearch/hermes-agent/blob/62e07223d630c122317d5bed3102d24bd1144976/website/static/img/logo.png + * + * The checked-in public asset is a size-optimized derivative of that exact + * PNG (matching source SHA-256: + * 2eaff911b9da9b1f1fcc81adb02f4992bb9ea6b781f4dd048cd79349927ddb7a). + */ +export const HermesIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( { + const hermesEnvironmentId = EnvironmentId.make("environment-hermes"); + const hermesInstanceId = ProviderInstanceId.make("hermes-primary"); + const driverKinds = new Map([ + [`${hermesEnvironmentId}\u0000${hermesInstanceId}`, ProviderDriverKind.make("hermes")] as const, + ]); + + it("projects durable main and needs-you roles into structured sections", () => { + expect(workInboxActiveSection(makeThreadFixture({ workInboxRole: "main" }))).toBe("main"); + expect(workInboxActiveSection(makeThreadFixture({ hasPendingApprovals: true }))).toBe( + "needs-you", + ); + expect(workInboxActiveSection(makeThreadFixture({ hasPendingUserInput: true }))).toBe( + "needs-you", + ); + expect(workInboxActiveSection(makeThreadFixture())).toBe("active"); + }); + + it("only permits unsettled ordinary Hermes sidebar threads to be pinned", () => { + const ordinaryHermes = makeThreadFixture({ + environmentId: hermesEnvironmentId, + providerInstanceId: hermesInstanceId, + }); + const canPin = (overrides: Partial[0]>) => + canPinWorkInboxThread({ + thread: ordinaryHermes, + providerDriverKindByInstance: driverKinds, + isSnoozed: false, + isSettled: false, + ...overrides, + }); + + expect(canPin({})).toBe(true); + expect(canPin({ isSettled: true })).toBe(false); + expect(canPin({ isSnoozed: true })).toBe(false); + expect(canPin({ thread: { ...ordinaryHermes, workInboxRole: "main" } })).toBe(false); + expect( + canPin({ + thread: { + ...ordinaryHermes, + lineage: { + ...ordinaryHermes.lineage, + relationshipToParent: "subagent", + }, + }, + }), + ).toBe(false); + expect( + canPin({ + thread: { + ...ordinaryHermes, + providerInstanceId: ProviderInstanceId.make("codex"), + }, + }), + ).toBe(false); + }); +}); + describe("shouldNavigateAfterProjectRemoval", () => { const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; @@ -263,6 +333,110 @@ describe("sidebar thread lineage helpers", () => { expect(isSidebarSubagentThread(makeThreadFixture())).toBe(false); }); + it("keeps subagent threads out of every sidebar lifecycle bucket", () => { + const parentId = ThreadId.make("thread-parent"); + const subagentLineage = { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent" as const, + }; + + const activeSubagent = makeThreadFixture({ lineage: subagentLineage }); + const snoozedSubagent = makeThreadFixture({ + lineage: subagentLineage, + snoozedUntil: "2026-03-10T12:00:00.000Z", + }); + const settledSubagent = makeThreadFixture({ + lineage: subagentLineage, + settledOverride: "settled", + settledAt: "2026-03-09T12:00:00.000Z", + }); + + expect( + [activeSubagent, snoozedSubagent, settledSubagent].map(isSidebarLifecycleThread), + ).toEqual([false, false, false]); + expect(isSidebarLifecycleThread(makeThreadFixture())).toBe(true); + expect( + isSidebarLifecycleThread(makeThreadFixture({ archivedAt: "2026-03-09T12:00:00.000Z" })), + ).toBe(false); + }); + + it("filters lifecycle threads by workspace using provider driver metadata", () => { + const environmentId = EnvironmentId.make("environment-workspaces"); + const codexInstanceId = ProviderInstanceId.make("codex_personal"); + const defaultHermesInstanceId = ProviderInstanceId.make("hermes"); + const customHermesInstanceId = ProviderInstanceId.make("research_assistant"); + const parentId = ThreadId.make("thread-parent"); + const providerDriverKindByInstance = new Map([ + [ + sidebarProviderInstanceKey(environmentId, codexInstanceId), + ProviderDriverKind.make("codex"), + ], + [ + sidebarProviderInstanceKey(environmentId, defaultHermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + [ + sidebarProviderInstanceKey(environmentId, customHermesInstanceId), + ProviderDriverKind.make("hermes"), + ], + ]); + const ordinaryThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-code"), + providerInstanceId: codexInstanceId, + }); + const hermesThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes"), + providerInstanceId: defaultHermesInstanceId, + }); + const customHermesThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-custom-hermes"), + providerInstanceId: customHermesInstanceId, + }); + const subagentThread = makeThreadFixture({ + environmentId, + id: ThreadId.make("thread-hermes-subagent"), + providerInstanceId: customHermesInstanceId, + lineage: { + rootThreadId: parentId, + parentThreadId: parentId, + relationshipToParent: "subagent", + }, + }); + const threads = [ordinaryThread, hermesThread, customHermesThread, subagentThread]; + + expect( + threads.filter((thread) => + isThreadVisibleInSidebarWorkspace(thread, "code", providerDriverKindByInstance), + ), + ).toEqual([ordinaryThread, hermesThread, customHermesThread]); + expect( + threads.filter((thread) => + isThreadVisibleInSidebarWorkspace(thread, "work", providerDriverKindByInstance), + ), + ).toEqual([hermesThread, customHermesThread]); + }); + + it("uses the literal Hermes instance only as a missing-metadata fallback", () => { + const environmentId = EnvironmentId.make("environment-history"); + const historicalHermesThread = makeThreadFixture({ + environmentId, + providerInstanceId: ProviderInstanceId.make("hermes"), + }); + const misleadingCustomThread = makeThreadFixture({ + environmentId, + providerInstanceId: ProviderInstanceId.make("hermes_personal"), + }); + + expect(isThreadVisibleInSidebarWorkspace(historicalHermesThread, "work", new Map())).toBe(true); + expect(isThreadVisibleInSidebarWorkspace(misleadingCustomThread, "work", new Map())).toBe( + false, + ); + }); + it("resolves the parent thread for fork sidebar affordances", () => { const parentId = ThreadId.make("thread-parent"); const fallbackParentId = ThreadId.make("thread-fallback-parent"); @@ -763,6 +937,26 @@ describe("sortThreadsForSidebarV2", () => { expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); }); + + it("keeps pinned unsettled threads first while preserving creation order within each group", () => { + const pinnedIds = new Set(["pinned-old", "pinned-new"]); + const sorted = sortThreadsForSidebarV2( + [ + sortable({ id: "regular-new", createdAt: "2026-03-09T13:00:00.000Z" }), + sortable({ id: "pinned-old", createdAt: "2026-03-09T08:00:00.000Z" }), + sortable({ id: "regular-old", createdAt: "2026-03-09T09:00:00.000Z" }), + sortable({ id: "pinned-new", createdAt: "2026-03-09T12:00:00.000Z" }), + ], + (thread) => pinnedIds.has(thread.id), + ); + + expect(sorted.map((thread) => thread.id)).toEqual([ + "pinned-new", + "pinned-old", + "regular-new", + "regular-old", + ]); + }); }); describe("sortSettledThreadsForSidebarV2", () => { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4dcd8906a20..1bdad55e569 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,5 +1,10 @@ import * as React from "react"; -import type { ContextMenuItem } from "@t3tools/contracts"; +import type { + ContextMenuItem, + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, +} from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import { getThreadSortTimestamp, @@ -111,6 +116,98 @@ export function filterSidebarV2VisibleThreads< ); } +/** + * Threads eligible for the left sidebar's lifecycle buckets. + * + * Subagent child threads are backing storage for delegated work, not + * top-level conversations. They remain discoverable through the parent + * thread's relationship/details panel, but never enter active, snoozed, or + * settled sidebar lists. + */ +export function isSidebarLifecycleThread( + thread: Pick, +): boolean { + return thread.archivedAt === null && !isSidebarSubagentThread(thread); +} + +export type SidebarWorkspace = "work" | "code"; + +export function sidebarProviderInstanceKey( + environmentId: EnvironmentId, + providerInstanceId: ProviderInstanceId, +): string { + return `${environmentId}\u0000${providerInstanceId}`; +} + +/** + * Applies the selected workspace to the sidebar lifecycle lists. + * + * Provider instance ids are user-configurable, so Hermes membership comes + * from the environment's provider metadata. The literal `hermes` fallback is + * only for cached historical shells whose server config has not loaded yet. + */ +export function isThreadVisibleInSidebarWorkspace( + thread: Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "lineage" | "providerInstanceId" + >, + workspace: SidebarWorkspace, + providerDriverKindByInstance: ReadonlyMap, +): boolean { + if (!isSidebarLifecycleThread(thread)) return false; + if (workspace === "code") return true; + + const driverKind = providerDriverKindByInstance.get( + sidebarProviderInstanceKey(thread.environmentId, thread.providerInstanceId), + ); + return ( + driverKind === "hermes" || (driverKind === undefined && thread.providerInstanceId === "hermes") + ); +} + +export function isHermesSidebarThread( + thread: Pick, + providerDriverKindByInstance: ReadonlyMap, +): boolean { + const driverKind = providerDriverKindByInstance.get( + sidebarProviderInstanceKey(thread.environmentId, thread.providerInstanceId), + ); + return ( + driverKind === "hermes" || (driverKind === undefined && thread.providerInstanceId === "hermes") + ); +} + +export type WorkInboxActiveSection = "main" | "needs-you" | "active"; + +export function workInboxActiveSection( + thread: Pick< + SidebarThreadSummary, + "hasPendingApprovals" | "hasPendingUserInput" | "workInboxRole" + >, +): WorkInboxActiveSection { + if (thread.workInboxRole === "main") return "main"; + if (thread.hasPendingApprovals || thread.hasPendingUserInput) return "needs-you"; + return "active"; +} + +export function canPinWorkInboxThread(input: { + thread: Pick< + SidebarThreadSummary, + "archivedAt" | "environmentId" | "lineage" | "providerInstanceId" | "workInboxRole" + >; + providerDriverKindByInstance: ReadonlyMap; + isSnoozed: boolean; + isSettled: boolean; +}): boolean { + return ( + input.thread.workInboxRole !== "main" && + isSidebarLifecycleThread(input.thread) && + isHermesSidebarThread(input.thread, input.providerDriverKindByInstance) && + !input.isSnoozed && + !input.isSettled + ); +} + export function getSidebarForkParentThreadId( thread: Pick, ) { @@ -494,12 +591,15 @@ export function firstValidTimestamp( // approval) is carried by each card's edge strip, not by position. export function sortThreadsForSidebarV2< T extends { readonly id: string; readonly createdAt: string }, ->(threads: readonly T[]): T[] { - return [...threads].toSorted( - (left, right) => +>(threads: readonly T[], isPinned: (thread: T) => boolean = () => false): T[] { + return [...threads].toSorted((left, right) => { + const pinOrder = Number(isPinned(right)) - Number(isPinned(left)); + return ( + pinOrder || parseTimestampMs(right.createdAt) - parseTimestampMs(left.createdAt) || - left.id.localeCompare(right.id), - ); + left.id.localeCompare(right.id) + ); + }); } type SettledTimestampInput = Pick< diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 3239d3a92fb..70cea7bacb6 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,5 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { canSnooze, effectiveSettled, @@ -12,7 +13,11 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { + ProviderDriverKind, + type ScopedThreadRef, + type SidebarProjectGroupingMode, +} from "@t3tools/contracts"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -23,11 +28,14 @@ import { CircleDashedIcon, ClockIcon, CopyIcon, + DownloadIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, EllipsisIcon, MessageSquareIcon, + PinIcon, + PinOffIcon, PlusIcon, SearchIcon, ServerIcon, @@ -98,23 +106,33 @@ import { useEnvironmentQuery } from "../state/query"; import { useAtomCommand } from "../state/use-atom-command"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; +import { + isT3WorkBackingProject, + T3_WORK_BACKING_PROJECT_ID, + T3_WORK_BACKING_PROJECT_TITLE, + t3WorkDirectoryForEnvironment, +} from "../t3WorkProject"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { formatWorkingDurationLabel, - filterSidebarV2VisibleThreads, firstValidTimestampMs, hasUnseenCompletion, + canPinWorkInboxThread, + isThreadVisibleInSidebarWorkspace, isTrailingDoubleClick, orderItemsByPreferredIds, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarV2Status, resolveWorkingStartedAt, + sidebarProviderInstanceKey, shouldNavigateAfterProjectRemoval, sortSidebarV2ProjectGroups, sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, + workInboxActiveSection, + type SidebarWorkspace, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; import { @@ -154,11 +172,37 @@ import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrom import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useLocalStorage } from "../hooks/useLocalStorage"; +import { T3Wordmark } from "./sidebar/SidebarChrome"; +import { HermesImportOnboarding } from "./HermesImportOnboarding"; // Settled-tail paging: recent history is the common lookup; the deep tail // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const SIDEBAR_WORKSPACE_STORAGE_KEY = "t3code:sidebar-workspace"; +const SIDEBAR_WORKSPACE_SCHEMA = Schema.Literals(["work", "code"]); +const SIDEBAR_WORKSPACE_ROUTES_STORAGE_KEY = "t3code:sidebar-workspace-routes:v1"; +const SIDEBAR_WORKSPACE_ROUTES_SCHEMA = Schema.Struct({ + work: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), +}); +const SIDEBAR_WORKSPACE_OPTIONS: ReadonlyArray<{ + value: SidebarWorkspace; + label: string; + description: string; +}> = [ + { + value: "work", + label: "T3 Work", + description: "Create, learn, and explore", + }, + { + value: "code", + label: "T3 Code", + description: "Build, debug, and ship", + }, +]; const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", @@ -175,6 +219,62 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } +function SidebarWorkspaceSelector(props: { + workspace: SidebarWorkspace; + onWorkspaceChange: (workspace: SidebarWorkspace) => void; +}) { + const selected = SIDEBAR_WORKSPACE_OPTIONS.find((option) => option.value === props.workspace)!; + + return ( + + + + + {selected.label.slice(3)} + + + + + { + if (value === "work" || value === "code") { + props.onWorkspaceChange(value); + } + }} + > + {SIDEBAR_WORKSPACE_OPTIONS.map((option) => ( + + + + {option.label} + + {option.description} + + + {props.workspace === option.value ? ( + + ) : null} + + + ))} + + + + ); +} + // Settled rows read "how long ago did this wrap up", matching their sort // key: both go through resolveSettledTimestamp so label and order can't // disagree. @@ -222,6 +322,7 @@ function SidebarV2ThreadTooltip({ modelInstanceId, modelLabel, branchMismatch, + showProjectContext, }: { thread: SidebarThreadSummary; projectTitle: string | null; @@ -234,6 +335,7 @@ function SidebarV2ThreadTooltip({ threadBranch: string; currentBranch: string; } | null; + showProjectContext: boolean; }) { return (
{thread.title}
- {projectTitle ? ( + {showProjectContext && projectTitle ? (
{environmentLabel}
) : null} - {thread.branch ? ( + {showProjectContext && thread.branch ? (
{thread.branch}
) : null} - {branchMismatch ? ( + {showProjectContext && branchMismatch ? (
@@ -366,6 +468,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { snoozeSupported: boolean; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; + // Pins exist only in the unsettled card list. A lifecycle transition out + // of that list removes the affordance and the pin no longer affects order. + isPinned: boolean; + canPin: boolean; // When a snooze ended (timer or early wake); drives the Woke pill until // the user visits the thread. wokeAt: string | null; @@ -389,6 +495,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; + onTogglePin: (threadRef: ScopedThreadRef) => void; onChangeRequestState: (threadKey: string, state: "open" | "closed" | "merged" | null) => void; }) { const { @@ -403,6 +510,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onStartRename, onThreadActivate, onThreadClick, + onTogglePin, onUnsettle, onUnsnooze, renamingTitle, @@ -485,9 +593,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { } : null; + const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; + const driverKind = providerEntry?.driverKind ?? null; + const isHermes = driverKind === "hermes" || (driverKind === null && modelInstanceId === "hermes"); + const selectedModel = providerEntry?.models.find( + (model) => model.slug === thread.modelSelection.model, + ); + const modelLabel = selectedModel + ? getTriggerDisplayModelLabel(selectedModel) + : thread.modelSelection.model; + const gitCwd = thread.worktreePath ?? props.projectCwd; const gitStatus = useEnvironmentQuery( - (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null + !isHermes && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null ? vcsEnvironment.status({ environmentId: thread.environmentId, input: { cwd: gitCwd }, @@ -513,29 +632,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { onChangeRequestState(threadKey, prState); }, [onChangeRequestState, prState, threadKey]); - const modelInstanceId = thread.runtime?.providerInstanceId ?? thread.modelSelection.instanceId; - const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; - const driverKind = providerEntry?.driverKind ?? null; - const selectedModel = providerEntry?.models.find( - (model) => model.slug === thread.modelSelection.model, - ); - const modelLabel = selectedModel - ? getTriggerDisplayModelLabel(selectedModel) - : thread.modelSelection.model; - const isRemote = props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; const detailsTooltip = ( ); @@ -626,6 +736,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { }, [onSnooze, threadRef], ); + const handleTogglePinClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onTogglePin(threadRef); + }, + [onTogglePin, threadRef], + ); // While the snooze popover is open the pointer leaves the row, which // would fade the hover actions out from under the open menu; pin them. const [snoozeMenuOpenRaw, setSnoozeMenuOpen] = useState(false); @@ -760,12 +878,20 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "opacity-40 grayscale group-hover/v2-row:opacity-100 group-hover/v2-row:grayscale-0", )} > - + {isHermes ? ( + + ) : ( + + )} {title} {/* The PR badge stays outside the hover-fading slot: it must @@ -862,12 +988,29 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { >
- - {props.projectTitle ? ( + {isHermes ? ( + + ) : ( + + )} + {isHermes ? ( + + Hermes + + ) : props.projectTitle ? ( - {props.settlementSupported || showSnoozeButton ? ( + {props.settlementSupported || showSnoozeButton || variant === "card" ? ( + {variant === "card" && thread.workInboxRole === "main" ? ( + + + + ) : variant === "card" && props.canPin ? ( + + ) : null} {showSnoozeButton ? (
{title}
- {thread.branch ? ( + {!isHermes && thread.branch ? ( {thread.branch} ) : ( )} {prBadge} - {diff ? ( + {!isHermes && diff ? ( +{diff.insertions}{" "} −{diff.deletions} @@ -966,7 +1131,14 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ) : null} - {driverKind ? ( + {props.isPinned ? ( + + + + ) : driverKind && !isHermes ? ( ( + SIDEBAR_WORKSPACE_STORAGE_KEY, + "code", + SIDEBAR_WORKSPACE_SCHEMA, + ); + const [workspaceRoutes, setWorkspaceRoutes] = useLocalStorage( + SIDEBAR_WORKSPACE_ROUTES_STORAGE_KEY, + {}, + SIDEBAR_WORKSPACE_ROUTES_SCHEMA, + ); const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); + const pinnedThreadKeySet = useMemo( + () => + new Set( + threads + .filter((thread) => thread.pinnedAt !== null || thread.workInboxRole === "main") + .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), + [threads], + ); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); @@ -1011,9 +1202,37 @@ export default function SidebarV2() { const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const toggleThreadPin = useCallback( + (threadRef: ScopedThreadRef) => { + void (async () => { + const threadKey = scopedThreadKey(threadRef); + const result = await updateThreadMetadata({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + pinned: !pinnedThreadKeySet.has(threadKey), + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update pin", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [pinnedThreadKeySet, updateThreadMetadata], + ); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const createProject = useAtomCommand(projectEnvironment.create, { + reportFailure: false, + }); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); @@ -1065,6 +1284,10 @@ export default function SidebarV2() { // the command was in flight, completing it must not yank them away. const routeThreadKeyRef = useRef(routeThreadKey); routeThreadKeyRef.current = routeThreadKey; + useEffect(() => { + if (routeThreadKey === null || workspaceRoutes[workspace] === routeThreadKey) return; + setWorkspaceRoutes((current) => ({ ...current, [workspace]: routeThreadKey })); + }, [routeThreadKey, setWorkspaceRoutes, workspace, workspaceRoutes]); const environmentLabelById = useMemo( () => @@ -1073,10 +1296,15 @@ export default function SidebarV2() { ), [environments], ); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const visibleProjects = useMemo( + () => projects.filter((project) => !isT3WorkBackingProject(project, serverConfigs)), + [projects, serverConfigs], + ); const orderedProjects = useMemo( () => orderItemsByPreferredIds({ - items: projects, + items: visibleProjects, preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ @@ -1084,12 +1312,12 @@ export default function SidebarV2() { legacyProjectCwdPreferenceKey(project.workspaceRoot), ], }), - [projectOrder, projects], + [projectOrder, visibleProjects], ); const unsortedProjectGroups = useMemo( () => buildSidebarProjectSnapshots({ - projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : visibleProjects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, @@ -1099,7 +1327,7 @@ export default function SidebarV2() { orderedProjects, primaryEnvironmentId, projectGroupingSettings, - projects, + visibleProjects, sidebarProjectSortOrder, ], ); @@ -1117,6 +1345,93 @@ export default function SidebarV2() { ), [serverProviders], ); + const hermesProviderEntry = useMemo( + () => + [...providerEntryByInstanceId.values()].find( + (entry) => + entry.driverKind === "hermes" && + entry.enabled && + entry.isAvailable && + entry.status === "ready", + ) ?? null, + [providerEntryByInstanceId], + ); + const t3WorkDirectory = t3WorkDirectoryForEnvironment(serverConfigs, primaryEnvironmentId); + const hermesBackingProject = + projects.find( + (project) => + project.environmentId === primaryEnvironmentId && + t3WorkDirectory !== null && + project.workspaceRoot === t3WorkDirectory, + ) ?? null; + const t3WorkProjectCreateRef = useRef(null); + useEffect(() => { + if ( + workspace !== "work" || + primaryEnvironmentId === null || + t3WorkDirectory === null || + hermesProviderEntry === null || + hermesBackingProject !== null + ) { + return; + } + const createKey = `${primaryEnvironmentId}:${t3WorkDirectory}`; + if (t3WorkProjectCreateRef.current === createKey) return; + t3WorkProjectCreateRef.current = createKey; + const hermesModel = + hermesProviderEntry.models.find((model) => model.slug === "default") ?? + hermesProviderEntry.models[0] ?? + null; + void (async () => { + const result = await createProject({ + environmentId: primaryEnvironmentId, + input: { + projectId: T3_WORK_BACKING_PROJECT_ID, + title: T3_WORK_BACKING_PROJECT_TITLE, + workspaceRoot: t3WorkDirectory, + createWorkspaceRootIfMissing: true, + defaultModelSelection: + hermesModel === null + ? null + : { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + }); + t3WorkProjectCreateRef.current = null; + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not prepare T3 Work", + description: + error instanceof Error + ? error.message + : "The private T3 Work conversation directory could not be created.", + }), + ); + } + })(); + }, [ + createProject, + hermesBackingProject, + hermesProviderEntry, + primaryEnvironmentId, + t3WorkDirectory, + workspace, + ]); + const [hermesImportOpen, setHermesImportOpen] = useState(false); + const providerDriverKindByInstance = useMemo(() => { + const result = new Map(); + for (const [environmentId, serverConfig] of serverConfigs) { + for (const provider of serverConfig.providers) { + result.set(sidebarProviderInstanceKey(environmentId, provider.instanceId), provider.driver); + } + } + return result; + }, [serverConfigs]); const projectCwdByKey = useMemo( () => new Map( @@ -1358,7 +1673,6 @@ export default function SidebarV2() { // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — // archive keeps its original "remove from sidebar" meaning. - const serverConfigs = useAtomValue(environmentServerConfigsAtom); const { activeThreads, snoozedThreads, settledThreads, snoozeNow } = useMemo(() => { const now = `${nowMinute}:00.000Z`; // Snooze classification uses a REAL clock, not the quantized minute: @@ -1367,7 +1681,13 @@ export default function SidebarV2() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys); + const visible = threads.filter( + (thread) => + isThreadVisibleInSidebarWorkspace(thread, workspace, providerDriverKindByInstance) && + (workspace === "work" || + scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -1385,9 +1705,13 @@ export default function SidebarV2() { // Snooze outranks settled classification: an explicitly snoozed thread // belongs to the shelf even if it would also auto-settle (the shelf's // wake time is a stronger statement about when it matters again). - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + const fixedInMain = thread.workInboxRole === "main"; + const durablyPinned = thread.pinnedAt !== null; + if (!fixedInMain && supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { snoozed.push(thread); } else if ( + !fixedInMain && + !durablyPinned && supportsSettlement && effectiveSettled(thread, { now, autoSettleAfterDays, changeRequestState }) ) { @@ -1397,7 +1721,9 @@ export default function SidebarV2() { } } return { - activeThreads: sortThreadsForSidebarV2(active), + activeThreads: sortThreadsForSidebarV2(active, (thread) => + pinnedThreadKeySet.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + ), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -1411,11 +1737,37 @@ export default function SidebarV2() { autoSettleAfterDays, changeRequestStateByKey, nowMinute, + pinnedThreadKeySet, + providerDriverKindByInstance, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads, + workspace, ]); + const { mainThreads, needsYouThreads, ordinaryActiveThreads } = useMemo(() => { + const main: EnvironmentThreadShell[] = []; + const needsYou: EnvironmentThreadShell[] = []; + const active: EnvironmentThreadShell[] = []; + for (const thread of activeThreads) { + switch (workInboxActiveSection(thread)) { + case "main": + main.push(thread); + break; + case "needs-you": + needsYou.push(thread); + break; + case "active": + active.push(thread); + break; + } + } + return { + mainThreads: main, + needsYouThreads: needsYou, + ordinaryActiveThreads: active, + }; + }, [activeThreads]); // Arm a timeout for the earliest upcoming wake so the shelf empties the // moment a snooze expires instead of on the next minute tick. Sorted @@ -1589,6 +1941,31 @@ export default function SidebarV2() { }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const handleWorkspaceChange = useCallback( + (nextWorkspace: SidebarWorkspace) => { + if (nextWorkspace === workspace) return; + const rememberedThreadKey = workspaceRoutes[nextWorkspace]; + setWorkspace(nextWorkspace); + if (rememberedThreadKey === undefined) return; + const rememberedThread = threads.find( + (thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === + rememberedThreadKey && + isThreadVisibleInSidebarWorkspace(thread, nextWorkspace, providerDriverKindByInstance), + ); + if (rememberedThread) { + navigateToThread(scopeThreadRef(rememberedThread.environmentId, rememberedThread.id)); + } + }, + [ + navigateToThread, + providerDriverKindByInstance, + setWorkspace, + threads, + workspace, + workspaceRoutes, + ], + ); const [renamingThreadKey, setRenamingThreadKey] = useState(null); const [renamingTitle, setRenamingTitle] = useState(""); @@ -1968,13 +2345,23 @@ export default function SidebarV2() { // clears the override, for auto-settled rows it pins the thread // active until real activity clears the pin. Environments without // the settlement capability get no lifecycle items at all. + const isWorkMain = thread.workInboxRole === "main"; const supportsSettlement = + !isWorkMain && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; + true; const supportsSnooze = + !isWorkMain && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); + const isPinned = pinnedThreadKeySet.has(threadKey); + const canPin = canPinWorkInboxThread({ + thread, + providerDriverKindByInstance, + isSnoozed, + isSettled, + }); // Presets resolve at menu-open time (same as the popover). const snoozePresets = resolveSnoozePresets(new Date()); const clicked = await settlePromise(() => @@ -2010,6 +2397,14 @@ export default function SidebarV2() { }, ] : []), + ...(canPin + ? [ + { + id: isPinned ? "unpin" : "pin", + label: isPinned ? "Unpin thread" : "Pin thread", + }, + ] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -2058,6 +2453,10 @@ export default function SidebarV2() { case "unsnooze": attemptUnsnooze(threadRef); return; + case "pin": + case "unpin": + toggleThreadPin(threadRef); + return; case "rename": startThreadRename(threadRef, thread.title); return; @@ -2077,15 +2476,17 @@ export default function SidebarV2() { if (confirmed._tag === "Failure" || !confirmed.value) return; } const result = await deleteThread(threadRef); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } return; } return; @@ -2104,8 +2505,11 @@ export default function SidebarV2() { deleteThread, handleMultiSelectContextMenu, markThreadUnread, + pinnedThreadKeySet, + providerDriverKindByInstance, serverConfigs, startThreadRename, + toggleThreadPin, ], ); @@ -2185,6 +2589,33 @@ export default function SidebarV2() { // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. const handleNewThreadClick = useCallback(() => { + if (workspace === "work") { + const hermesModel = + hermesProviderEntry?.models.find((model) => model.slug === "default") ?? + hermesProviderEntry?.models[0] ?? + null; + if (!hermesBackingProject || !hermesProviderEntry || !hermesModel) { + toastManager.add({ + type: "warning", + title: "Hermes is not ready", + description: + "Enable and configure Hermes, then wait for T3 Work to finish preparing its private conversation directory.", + }); + return; + } + if (isMobile) setOpenMobile(false); + void newThreadContext.handleNewThread( + scopeProjectRef(hermesBackingProject.environmentId, hermesBackingProject.id), + { + fresh: true, + modelSelection: { + instanceId: hermesProviderEntry.instanceId, + model: hermesModel.slug, + }, + }, + ); + return; + } // One project: nothing to pick, create immediately. if (projectGroups.length <= 1) { if (isMobile) setOpenMobile(false); @@ -2198,7 +2629,15 @@ export default function SidebarV2() { } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + }, [ + hermesBackingProject, + hermesProviderEntry, + isMobile, + newThreadContext, + projectGroups.length, + setOpenMobile, + workspace, + ]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to @@ -2208,7 +2647,15 @@ export default function SidebarV2() { shortcutLabelForCommand(keybindings, "chat.new"); return ( <> - + + } + />
@@ -2233,6 +2680,27 @@ export default function SidebarV2() { ) : null}
+
+ {workspace === "work" ? ( + + setHermesImportOpen(true)} + disabled={hermesProviderEntry === null || hermesBackingProject === null} + aria-label="Import Hermes conversations" + /> + } + > + + + Import Hermes conversations + + ) : null} +
} @@ -2260,7 +2732,7 @@ export default function SidebarV2() {
- {projectGroups.length > 0 ? ( + {workspace === "code" && projectGroups.length > 0 ? (
@@ -2397,10 +2869,12 @@ export default function SidebarV2() { : "settle" } settlementSupported={ + thread.workInboxRole !== "main" && serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } snoozeSupported={ + thread.workInboxRole !== "main" && serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } @@ -2409,6 +2883,16 @@ export default function SidebarV2() { ? snoozeWakeLabel(thread.snoozedUntil, new Date()) : null } + isPinned={section === "active" && pinnedThreadKeySet.has(threadKey)} + canPin={ + section === "active" && + canPinWorkInboxThread({ + thread, + providerDriverKindByInstance, + isSnoozed: false, + isSettled: false, + }) + } // All sections: a woken thread can classify straight // into the settled tail (PR merged while snoozed), and // the wake signal must survive the trip. Still-snoozed @@ -2440,13 +2924,56 @@ export default function SidebarV2() { onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} onUnsnooze={attemptUnsnooze} + onTogglePin={toggleThreadPin} onChangeRequestState={handleChangeRequestState} /> ); }; - const items: ReactNode[] = activeThreads.map((thread) => - renderThreadRow(thread, "active"), - ); + const items: ReactNode[] = []; + const addWorkSection = ( + key: string, + label: string, + sectionThreads: readonly EnvironmentThreadShell[], + tone: "default" | "attention" = "default", + ) => { + if (sectionThreads.length === 0) return; + items.push( +
  • +
    + + {label} + + +
    +
  • , + ); + for (const thread of sectionThreads) { + items.push(renderThreadRow(thread, "active")); + } + }; + if (workspace === "work") { + addWorkSection("main", "Main", mainThreads); + addWorkSection("needs-you", "Needs you", needsYouThreads, "attention"); + addWorkSection("active", "Active", ordinaryActiveThreads); + } else { + for (const thread of activeThreads) { + items.push(renderThreadRow(thread, "active")); + } + } // Snoozed shelf: between the inbox and Settled — out of the // way, never gone. The header always renders while anything // is snoozed (the count is the whole footprint when @@ -2721,6 +3248,14 @@ export default function SidebarV2() { + ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 596848561ea..aebeff8f244 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -17,7 +17,6 @@ import { ProviderDriverKind, ProviderInstanceId, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, - PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; @@ -49,6 +48,7 @@ import { makeComposerMentionDragHandlers, } from "./composerMentionDrag"; import { + type ComposerAttachment, type ComposerImageAttachment, type DraftId, type PersistedComposerImageAttachment, @@ -174,12 +174,18 @@ import { toastManager } from "../ui/toast"; import { BotIcon, CircleAlertIcon, + EraserIcon, + FileIcon, + FileTextIcon, ListTodoIcon, PencilRulerIcon, type LucideIcon, LockIcon, LockOpenIcon, PenLineIcon, + PlusIcon, + PaperclipIcon, + SquarePenIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -212,8 +218,11 @@ import { formatProviderSkillDisplayName } from "../../providerSkillPresentation" import { searchProviderSkills } from "../../providerSkillSearch"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; - -const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +import { + composerAttachmentAccept, + validateComposerAttachment, +} from "./composerAttachmentValidation"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; const runtimeModeConfig: Record< RuntimeMode, @@ -510,7 +519,7 @@ export interface ChatComposerHandle { /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; - images: ComposerImageAttachment[]; + images: ComposerAttachment[]; terminalContexts: TerminalContextDraft[]; elementContexts: ElementContextDraft[]; previewAnnotations: PreviewAnnotationPayload[]; @@ -542,6 +551,7 @@ export interface ChatComposerProps { activeThread: Thread | undefined; isServerThread: boolean; isLocalDraftThread: boolean; + isProjectlessConversation: boolean; forceExpandedOnMobile: boolean; projectSelectionRequired: boolean; @@ -602,7 +612,7 @@ export interface ChatComposerProps { // Refs the parent needs kept in sync promptRef: React.RefObject; - composerImagesRef: React.RefObject; + composerImagesRef: React.RefObject; composerTerminalContextsRef: React.RefObject; composerElementContextsRef: React.RefObject; composerRef: React.RefObject; @@ -613,6 +623,8 @@ export interface ChatComposerProps { // Callbacks onSend: (e?: { preventDefault: () => void }, dispatchMode?: ComposerDispatchMode) => void; + onStartFreshChat: () => void; + onClearChat: () => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -659,6 +671,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThread, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, + isProjectlessConversation, forceExpandedOnMobile, projectSelectionRequired, phase, @@ -701,6 +714,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) shouldAutoScrollRef, scheduleStickToBottom, onSend, + onStartFreshChat, + onClearChat, onInterrupt, onImplementPlanInNewThread, onRespondToApproval, @@ -1032,6 +1047,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) * thread) can still be stashed while an earlier encode is running. */ const stashInFlightRef = useRef>(new Set()); + const attachmentInputRef = useRef(null); // ------------------------------------------------------------------ // Derived: composer send state @@ -1290,14 +1306,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const addComposerImage = useCallback( - (image: ComposerImageAttachment) => { + (image: ComposerAttachment) => { addComposerDraftImage(composerDraftTarget, image); }, [composerDraftTarget, addComposerDraftImage], ); const addComposerImagesToDraft = useCallback( - (images: ComposerImageAttachment[]) => { + (images: ComposerAttachment[]) => { addComposerDraftImages(composerDraftTarget, images); }, [composerDraftTarget, addComposerDraftImages], @@ -1522,6 +1538,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) try { const dataUrl = await readFileAsDataUrl(image.file); stagedAttachmentById.set(image.id, { + type: image.type, id: image.id, name: image.name, mimeType: image.mimeType, @@ -2342,32 +2359,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } - const nextImages: ComposerImageAttachment[] = []; + const nextImages: ComposerAttachment[] = []; let nextImageCount = composerImagesRef.current.length; let error: string | null = null; for (const file of files) { - if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; - continue; - } - if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + const validation = validateComposerAttachment(file, selectedProvider); + if (!validation.accepted) { + error = validation.message; continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; + error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} files per message.`; break; } const previewUrl = URL.createObjectURL(file); nextImages.push({ - type: "image", + type: validation.type, id: randomUUID(), name: file.name || "image", - mimeType: file.type, + mimeType: validation.mimeType, sizeBytes: file.size, previewUrl, file, @@ -2392,10 +2406,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + addComposerImages(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -2922,7 +2934,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerPreviewAnnotations.length > 0 && ( + attachment.type === "image", + )} onRemove={(annotationId) => removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) } @@ -2980,7 +2995,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) key={image.id} className="relative h-16 w-16 overflow-hidden rounded-lg border border-border/80 bg-background" > - {image.previewUrl ? ( + {image.type === "image" && image.previewUrl ? ( + ) : image.type === "video" && image.previewUrl ? ( +
    - {image.name} +
    + {image.type === "pdf" ? ( +
    )} {nonPersistedComposerImageIdSet.has(image.id) && ( @@ -3071,9 +3103,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Choose a project above to start a thread" : noProviderAvailable ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : isProjectlessConversation + ? "Message Hermes" + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> @@ -3129,6 +3163,52 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) )} >
    + { + addComposerImages(Array.from(event.currentTarget.files ?? [])); + event.currentTarget.value = ""; + }} + /> + + 0 || + projectSelectionRequired + } + > + + + + attachmentInputRef.current?.click()}> + + Attach files + + {isProjectlessConversation ? ( + <> + + + New chat + + + + Clear chat + + + ) : null} + + {noProviderAvailable ? ( )}
    - +
    + + +
    {item.name} { + it("keeps browser-loadable media URLs direct", () => { + expect(resolveMarkdownMediaSource("//cdn.example.test/demo.mp4", threadRef)).toEqual({ + _tag: "direct", + url: "//cdn.example.test/demo.mp4", + }); + }); + + it("resolves workspace media after decoding and removing query strings", () => { + expect(resolveMarkdownMediaSource("./recordings/demo%20run.mp4?download=1", threadRef)).toEqual( + { + _tag: "resource", + resource: { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: "recordings/demo run.mp4", + }, + }, + ); + }); + + it("recognizes only absolute browser-artifact paths", () => { + expect( + resolveMarkdownMediaSource( + "/tmp/userdata/browser-artifacts/browser-recording-demo.webm", + threadRef, + ), + ).toEqual({ + _tag: "resource", + resource: { + _tag: "browser-artifact", + fileName: "browser-recording-demo.webm", + }, + }); + expect( + resolveMarkdownMediaSource("docs/browser-artifacts/browser-recording-demo.webm", threadRef), + ).toMatchObject({ + resource: { + _tag: "workspace-file", + path: "docs/browser-artifacts/browser-recording-demo.webm", + }, + }); + }); + + it("unescapes sanitized Windows drive paths", () => { + expect( + resolveMarkdownMediaSource( + "/C:\\Users\\me\\browser-artifacts\\browser-screenshot-demo.png", + threadRef, + ), + ).toMatchObject({ + resource: { + _tag: "browser-artifact", + fileName: "browser-screenshot-demo.png", + }, + }); + }); +}); diff --git a/apps/web/src/components/chat/MarkdownMedia.tsx b/apps/web/src/components/chat/MarkdownMedia.tsx new file mode 100644 index 00000000000..b95f41a29aa --- /dev/null +++ b/apps/web/src/components/chat/MarkdownMedia.tsx @@ -0,0 +1,185 @@ +import type { AssetResource, ScopedThreadRef } from "@t3tools/contracts"; +import { isWorkspaceVideoPreviewPath } from "@t3tools/shared/filePreview"; +import { memo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { useAssetUrlState } from "../../assets/assetUrls"; +import { ExpandedImageDialog } from "./ExpandedImageDialog"; + +const DIRECT_MEDIA_SRC_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; +const ESCAPED_WINDOWS_DRIVE_PATH_PATTERN = /^\/[A-Za-z]:[\\/]/; +const ABSOLUTE_PATH_PATTERN = /^(?:[/\\]|[A-Za-z]:[\\/])/; +const MEDIA_FRAME_CLASS_NAME = + "my-2 block max-h-96 max-w-full rounded-lg border border-border/60 bg-background object-contain"; + +interface MarkdownMediaProps { + src: string | undefined; + alt?: string | undefined; + threadRef?: ScopedThreadRef | undefined; + kind?: "image" | "video" | undefined; +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function mediaFileName(src: string): string { + const withoutQuery = src.split(/[?#]/, 1)[0] ?? src; + const basename = withoutQuery.slice(Math.max(withoutQuery.lastIndexOf("/"), -1) + 1); + return basename.length > 0 ? safeDecode(basename) : safeDecode(withoutQuery); +} + +function mediaPathFromSrc(src: string): string { + const withoutQuery = src.split(/[?#]/, 1)[0] ?? src; + const decoded = safeDecode(withoutQuery); + return ESCAPED_WINDOWS_DRIVE_PATH_PATTERN.test(decoded) ? decoded.slice(1) : decoded; +} + +function browserArtifactFileName(path: string): string | null { + if (!ABSOLUTE_PATH_PATTERN.test(path)) { + return null; + } + return /[/\\]browser-artifacts[/\\]([^/\\]+)$/.exec(path)?.[1] ?? null; +} + +export type ResolvedMarkdownMediaSource = + | { readonly _tag: "direct"; readonly url: string } + | { readonly _tag: "resource"; readonly resource: AssetResource }; + +export function resolveMarkdownMediaSource( + src: string, + threadRef: ScopedThreadRef, +): ResolvedMarkdownMediaSource { + if (DIRECT_MEDIA_SRC_PATTERN.test(src)) { + return { _tag: "direct", url: src }; + } + const path = mediaPathFromSrc(src); + const artifactFileName = browserArtifactFileName(path); + return { + _tag: "resource", + resource: artifactFileName + ? { _tag: "browser-artifact", fileName: artifactFileName } + : { + _tag: "workspace-file", + threadId: threadRef.threadId, + path: path.startsWith("./") ? path.slice(2) : path, + }, + }; +} + +function MediaUnavailable({ name }: { name: string }) { + return ( + + Media unavailable: + {name} + + ); +} + +function ResolvedMedia({ url, name, isVideo }: { url: string; name: string; isVideo: boolean }) { + const [expanded, setExpanded] = useState(false); + const [failedUrl, setFailedUrl] = useState(null); + + if (failedUrl === url) { + return ; + } + if (isVideo) { + return ( +
    +
    + Chat cleared +
    +
    + ); +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); - const userImages = row.message.attachments ?? []; + const userAttachments = row.message.attachments ?? []; const displayedUserMessage = deriveDisplayedUserMessageState(row.message.text); const terminalContexts = displayedUserMessage.contexts; const previewAnnotations: ParsedPreviewAnnotation[] = []; @@ -932,8 +953,16 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); - const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); + const messageReply = extractLeadingMessageReply(elementContextState.promptText); + const messageBodyText = messageReply?.messageText ?? elementContextState.promptText; + const previewImages = userAttachments.filter( + (attachment) => + attachment.type === "image" && attachment.name.startsWith("preview-annotation-"), + ); + const regularAttachments = userAttachments.filter( + (attachment) => + attachment.type !== "image" || !attachment.name.startsWith("preview-annotation-"), + ); const canRevertAgentWork = typeof row.revertTurnCount === "number"; return ( @@ -947,37 +976,58 @@ function UserTimelineRow({ row }: { row: Extract ) : null}
    - {regularImages.length > 0 && ( + {regularAttachments.length > 0 && (
    - {regularImages.map((image: NonNullable[number]) => ( -
    - {image.previewUrl ? ( - + ) : attachment.type === "video" && attachment.previewUrl ? ( +
    - ))} + ) : attachment.previewUrl ? ( + + {attachment.name} + + ) : ( +
    + {attachment.name} +
    + )} +
    + ), + )}
    )} {previewAnnotations.map((annotation, index) => ( @@ -997,8 +1047,9 @@ function UserTimelineRow({ row }: { row: Extract ) : null} + {messageReply ? : null} +
    +
    +

    + {text} +

    + + ); +} + function UserMessageIntentBadge({ intent, }: { @@ -1146,6 +1217,7 @@ function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const attachments = row.message.attachments ?? []; return ( <> @@ -1157,6 +1229,7 @@ function AssistantTimelineRow({ row }: { row: Extract + {attachments.length > 0 ? : null} ; +}) { + const ctx = use(TimelineRowCtx); + const previewableImages = attachments.filter( + (attachment) => attachment.type === "image" && attachment.previewUrl, + ); + + return ( +
    + {attachments.map((attachment) => { + if (attachment.type === "image" && attachment.previewUrl) { + return ( +
    + +
    + + {attachment.name} + + + + +
    +
    + ); + } + if (attachment.type === "video" && attachment.previewUrl) { + return ( +
    +
    + ); + } + if (attachment.mimeType.startsWith("audio/") && attachment.previewUrl) { + return ( +
    +
    + + {attachment.name} + + + + +
    +
    + ); + } + if (attachment.previewUrl) { + return ( + + + {attachment.name} + + ); + } + return ( +
    + Media unavailable · {attachment.name} +
    + ); + })} +
    + ); +} + function AssistantForkButton({ projectedItem, }: { readonly projectedItem: NonNullable["projectedItem"]>; }) { const ctx = use(TimelineRowCtx); + const activity = use(TimelineRowActivityCtx); const [busy, setBusy] = useState(false); const support = useV2ItemSupport({ environmentId: ctx.activeThreadEnvironmentId, @@ -1208,10 +1414,15 @@ function AssistantForkButton({ const canFork = canForkProjectedAssistantItem({ projectedItem, capabilities: support.providerSession?.capabilities, + isLatestRun: activity.latestRunId === projectedItem.item.runId, }); if (!canFork || projectedItem.item.runId === null) return null; const runId = projectedItem.item.runId; + const latestOnly = + support.providerSession?.capabilities.threads.canForkThread === true && + support.providerSession.capabilities.threads.canForkFromTurn === false; + const label = latestOnly ? "Fork latest conversation head" : "Fork from this response"; return ( @@ -1225,16 +1436,20 @@ function AssistantForkButton({ onClick={() => { setBusy(true); void ctx - .onForkFromRun({ sourceThreadId: projectedItem.sourceThreadId, runId }) + .onForkFromRun({ + sourceThreadId: projectedItem.sourceThreadId, + runId, + ...(latestOnly ? { latestOnly: true } : {}), + }) .finally(() => setBusy(false)); }} - aria-label="Fork from this response" + aria-label={label} /> } > - Fork from this response + {label} ); } @@ -1522,7 +1737,7 @@ const WorkGroupSection = memo(function WorkGroupSection({ const sectionRef = useRef(null); const anchorBottomBeforeToggleRef = useRef(null); const nonEmptyEntries = useMemo( - () => groupedEntries.filter((entry) => !workEntryIndicatesToolNeutralStatus(entry)), + () => groupedEntries.filter(workLogEntryIsVisible), [groupedEntries], ); const hasOverflow = nonEmptyEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; @@ -2389,10 +2604,16 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ? "font-medium text-destructive" : "font-medium text-foreground/82"; const turnSettled = !activity.activeTurnInProgress; - const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); + const lifecycleStatus = workEntry.toolLifecycleStatus; + const showRunningIndicator = lifecycleStatus === "inProgress"; + const showStoppedIndicator = lifecycleStatus === "stopped" || lifecycleStatus === "declined"; + const showNeutralIndicator = + lifecycleStatus === undefined && !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = workEntryIndicatesToolSuccess(workEntry) || - (turnSettled && workEntryIndicatesToolNeutralStatus(workEntry)); + (lifecycleStatus === undefined && + turnSettled && + workEntryIndicatesToolNeutralStatus(workEntry)); const rowToggleProps = canExpand ? { role: "button" as const, @@ -2416,6 +2637,7 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { "cursor-pointer hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70", )} data-tool-logo={toolPresentation?.logo} + data-tool-call-status={lifecycleStatus} data-v2-item-type={workEntry.projectedItem?.item.type} data-v2-item-visibility={workEntry.projectedItem?.visibility} {...rowToggleProps} @@ -2476,10 +2698,33 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { Failed + ) : showRunningIndicator ? ( + + + } + > + + + + + + + Running + ) : showSuccessIndicator ? ( } + render={ + + } > Completed + ) : showStoppedIndicator ? ( + + + } + > + + + Stopped + ) : showNeutralIndicator ? ( | undefined; preferredScriptId: string | null; @@ -119,104 +120,118 @@ export function ThreadDetailsPanel(props: ThreadDetailsPanelProps) { props.mode === "inline" ? "max-h-full" : "max-h-[calc(100dvh-6.5rem)]", )} > -
    -
    -

    - Workspace -

    -
    + {!props.isProjectlessConversation ? ( +
    +
    +

    + Workspace +

    +
    + + {connectionIssue ? ( +
    +
    + +
    +

    Environment unavailable

    +

    + {props.environmentConnection?.error ?? + "Reconnect this environment before sending messages or running actions."} +

    +
    + + +
    +
    +
    +
    + ) : null} - {connectionIssue ? ( -
    -
    + {props.versionMismatch ? ( +
    -

    Environment unavailable

    +

    Client and server versions differ

    - {props.environmentConnection?.error ?? - "Reconnect this environment before sending messages or running actions."} + Client {props.versionMismatch.clientVersion} ·{" "} + {props.versionMismatch.serverLabel} {props.versionMismatch.serverVersion}

    -
    - - -
    +
    -
    - ) : null} - - {props.versionMismatch ? ( -
    - -
    -

    Client and server versions differ

    -

    - Client {props.versionMismatch.clientVersion} · {props.versionMismatch.serverLabel}{" "} - {props.versionMismatch.serverVersion} -

    -
    - -
    - ) : null} - -
    - {props.availableEnvironments.length > 1 ? ( - ) : null} - +
    + {props.availableEnvironments.length > 1 ? ( + + ) : null} - {props.showOpenInPicker ? ( - - ) : null} + - {props.activeProjectScripts ? ( - - ) : null} -
    -
    + {props.showOpenInPicker ? ( + + ) : null} - {props.gitCwd ? ( + {props.activeProjectScripts ? ( + + ) : null} +
    + + ) : props.draftId ? ( +
    +

    + Delegated tasks +

    +

    + Coding tasks Hermes delegates through T3 Code will appear here. +

    +
    + ) : null} + + {!props.isProjectlessConversation && props.gitCwd ? (
    ) : null} - {!props.draftId ? ( + {!props.isProjectlessConversation && !props.draftId ? ( ) : null} diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 51bfb62667d..81deb72ed9f 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -3,6 +3,7 @@ import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; import { CircleAlertIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { presentProviderError } from "./providerErrorPresentation"; export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, @@ -12,15 +13,18 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ onDismiss?: () => void; }) { if (!error) return null; + const presentedError = presentProviderError(error); return (
    - }>{error} + }> + {presentedError} + - {error} + {presentedError} diff --git a/apps/web/src/components/chat/composerAttachmentValidation.test.ts b/apps/web/src/components/chat/composerAttachmentValidation.test.ts new file mode 100644 index 00000000000..b96308aae67 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentValidation.test.ts @@ -0,0 +1,86 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + ProviderDriverKind, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + composerAttachmentAccept, + validateComposerAttachment, +} from "./composerAttachmentValidation"; + +describe("validateComposerAttachment", () => { + it("limits the native picker to images for providers with an image-only transport", () => { + expect(composerAttachmentAccept(ProviderDriverKind.make("codex"))).toBe("image/*"); + expect(composerAttachmentAccept(ProviderDriverKind.make("hermes"))).toBeUndefined(); + }); + + it("preserves images for every provider", () => { + expect( + validateComposerAttachment( + { name: "shot.png", size: 12, type: "image/png" }, + ProviderDriverKind.make("codex"), + ), + ).toMatchObject({ accepted: true, type: "image" }); + }); + + it.each([ + ["report.pdf", "application/pdf", "pdf"], + ["clip.webm", "video/webm", "video"], + ["notes.txt", "text/plain", "file"], + ] as const)("accepts Hermes %s", (name, mimeType, type) => { + expect( + validateComposerAttachment( + { name, size: 12, type: mimeType }, + ProviderDriverKind.make("hermes"), + ), + ).toMatchObject({ accepted: true, type }); + }); + + it.each([ + ["report.pdf", "pdf"], + ["clip.webm", "video"], + ["notes.txt", "file"], + ] as const)("infers a safe MIME type for Hermes %s when the browser omits it", (name, type) => { + expect( + validateComposerAttachment({ name, size: 12, type: "" }, ProviderDriverKind.make("hermes")), + ).toMatchObject({ accepted: true, type }); + }); + + it("rejects non-image files for providers without a transport", () => { + expect( + validateComposerAttachment( + { name: "notes.txt", size: 12, type: "text/plain" }, + ProviderDriverKind.make("codex"), + ), + ).toMatchObject({ accepted: false, message: expect.stringContaining("only in Hermes") }); + }); + + it.each([ + ["image", "shot.png", "image/png", PROVIDER_SEND_TURN_MAX_IMAGE_BYTES], + ["file", "archive.zip", "application/zip", PROVIDER_SEND_TURN_MAX_FILE_BYTES], + ] as const)("enforces the exact %s byte limit", (_kind, name, type, maxBytes) => { + const provider = ProviderDriverKind.make("hermes"); + expect(validateComposerAttachment({ name, size: maxBytes, type }, provider)).toMatchObject({ + accepted: true, + }); + expect(validateComposerAttachment({ name, size: maxBytes + 1, type }, provider)).toMatchObject({ + accepted: false, + message: expect.stringContaining(`${maxBytes / (1024 * 1024)}MB`), + }); + }); + + it.each([ + ["voice.mp3", "audio/mpeg", "sound input path"], + ["../secret.txt", "text/plain", "safe, plain file names"], + ["unknown", "", "trustworthy MIME type"], + ] as const)("rejects unsafe or unsupported %s honestly", (name, type, message) => { + expect( + validateComposerAttachment({ name, size: 12, type }, ProviderDriverKind.make("hermes")), + ).toMatchObject({ + accepted: false, + message: expect.stringContaining(message), + }); + }); +}); diff --git a/apps/web/src/components/chat/composerAttachmentValidation.ts b/apps/web/src/components/chat/composerAttachmentValidation.ts new file mode 100644 index 00000000000..5b5bb0a7543 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentValidation.ts @@ -0,0 +1,103 @@ +import { + PROVIDER_SEND_TURN_MAX_FILE_BYTES, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + ProviderDriverKind, +} from "@t3tools/contracts"; + +export type ComposerAttachmentKind = "image" | "file" | "pdf" | "video"; + +export type ComposerAttachmentValidation = + | { readonly accepted: true; readonly type: ComposerAttachmentKind; readonly mimeType: string } + | { readonly accepted: false; readonly message: string }; + +const SAFE_NAME = /^[^/\\]+$/; +const MIME_TYPE = /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/i; +const MIME_TYPE_BY_EXTENSION: Readonly> = { + ".avif": "image/avif", + ".bmp": "image/bmp", + ".csv": "text/csv", + ".gif": "image/gif", + ".heic": "image/heic", + ".heif": "image/heif", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".json": "application/json", + ".md": "text/markdown", + ".mkv": "video/x-matroska", + ".mov": "video/quicktime", + ".mp4": "video/mp4", + ".pdf": "application/pdf", + ".png": "image/png", + ".svg": "image/svg+xml", + ".tiff": "image/tiff", + ".txt": "text/plain", + ".webm": "video/webm", + ".webp": "image/webp", + ".zip": "application/zip", +}; + +export function composerAttachmentAccept(provider: ProviderDriverKind): string | undefined { + return provider === ProviderDriverKind.make("hermes") ? undefined : "image/*"; +} + +function inferKnownMimeType(name: string): string | null { + const extensionIndex = name.lastIndexOf("."); + if (extensionIndex < 0) return null; + return MIME_TYPE_BY_EXTENSION[name.slice(extensionIndex).toLowerCase()] ?? null; +} + +function isSafeAttachmentName(name: string): boolean { + if (!SAFE_NAME.test(name)) return false; + for (const character of name) { + const codePoint = character.codePointAt(0); + if (codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f)) return false; + } + return true; +} + +export function validateComposerAttachment( + file: Pick, + provider: ProviderDriverKind, +): ComposerAttachmentValidation { + const name = file.name.trim(); + if (name.length === 0 || name.length > 255 || !isSafeAttachmentName(name)) { + return { accepted: false, message: "Attachment names must be safe, plain file names." }; + } + const suppliedMimeType = file.type.trim().toLowerCase(); + const mimeType = + suppliedMimeType.length > 0 ? suppliedMimeType : (inferKnownMimeType(name) ?? ""); + if (mimeType.length === 0 || mimeType.length > 100 || !MIME_TYPE.test(mimeType)) { + return { + accepted: false, + message: `'${name}' has no trustworthy MIME type and cannot be attached.`, + }; + } + if (mimeType.startsWith("audio/")) { + return { + accepted: false, + message: `Audio attachment '${name}' is unsupported because this protocol has no sound input path.`, + }; + } + const type: ComposerAttachmentKind = mimeType.startsWith("image/") + ? "image" + : mimeType === "application/pdf" + ? "pdf" + : mimeType.startsWith("video/") + ? "video" + : "file"; + if (type !== "image" && provider !== ProviderDriverKind.make("hermes")) { + return { + accepted: false, + message: `${type === "pdf" ? "PDF" : type === "video" ? "Video" : "File"} attachments are currently supported only in Hermes conversations.`, + }; + } + const maxBytes = + type === "image" ? PROVIDER_SEND_TURN_MAX_IMAGE_BYTES : PROVIDER_SEND_TURN_MAX_FILE_BYTES; + if (file.size > maxBytes) { + return { + accepted: false, + message: `'${name}' exceeds the ${Math.round(maxBytes / (1024 * 1024))}MB attachment limit.`, + }; + } + return { accepted: true, type, mimeType }; +} diff --git a/apps/web/src/components/chat/providerErrorPresentation.test.ts b/apps/web/src/components/chat/providerErrorPresentation.test.ts new file mode 100644 index 00000000000..9ada66ed50f --- /dev/null +++ b/apps/web/src/components/chat/providerErrorPresentation.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { presentProviderError } from "./providerErrorPresentation"; + +describe("presentProviderError", () => { + it("turns Hermes attachment configuration failures into a next step", () => { + expect( + presentProviderError( + "hermes provider protocol error: Attachments are disabled for this Hermes instance.", + ), + ).toBe( + "Hermes attachments are turned off. Enable Attachments in Settings → Providers, then try again.", + ); + }); + + it("explains unsupported gateway attachment capabilities without adapter jargon", () => { + expect( + presentProviderError( + "ProviderAdapterProtocolError: hermes provider protocol error: This Hermes gateway does not support PDF attachments.", + ), + ).toBe( + "This Hermes gateway does not support pdf attachments. Remove the pdf attachment or update the gateway, then try again.", + ); + }); + + it("removes internal run and provider-thread ids from turn-start failures", () => { + expect( + presentProviderError( + "Failed to start run run-secret on hermes provider thread provider-thread-secret.", + ), + ).toBe( + "Hermes couldn't start this message. Check the provider connection in Settings → Providers, then try again.", + ); + }); + + it("leaves already-polished errors unchanged", () => { + expect(presentProviderError("Could not connect to the provider after repeated attempts.")).toBe( + "Could not connect to the provider after repeated attempts.", + ); + }); +}); diff --git a/apps/web/src/components/chat/providerErrorPresentation.ts b/apps/web/src/components/chat/providerErrorPresentation.ts new file mode 100644 index 00000000000..5c36474c539 --- /dev/null +++ b/apps/web/src/components/chat/providerErrorPresentation.ts @@ -0,0 +1,48 @@ +const PROVIDER_PROTOCOL_PREFIX = + /^(?:ProviderAdapterProtocolError:\s*)?([a-z][a-z0-9_-]*) provider protocol error:\s*/iu; +const PROVIDER_TURN_START = + /^Failed to start run .+ on ([a-z][a-z0-9_-]*) provider thread .+\.?$/iu; + +function providerLabel(slug: string): string { + if (slug.toLowerCase() === "hermes") return "Hermes"; + return slug.charAt(0).toUpperCase() + slug.slice(1); +} + +function withoutTrailingPeriod(message: string): string { + return message.trim().replace(/\.+$/u, ""); +} + +/** + * Provider errors cross several Effect/RPC wrappers before reaching the + * timeline. Present known operational failures as a next step instead of + * exposing adapter names, run ids, or provider-thread ids. + */ +export function presentProviderError(message: string): string { + const trimmed = message.trim(); + const protocolMatch = PROVIDER_PROTOCOL_PREFIX.exec(trimmed); + if (protocolMatch) { + const provider = providerLabel(protocolMatch[1]!); + const detail = withoutTrailingPeriod(trimmed.slice(protocolMatch[0].length)); + if (/^Attachments are disabled for this Hermes instance$/iu.test(detail)) { + return "Hermes attachments are turned off. Enable Attachments in Settings → Providers, then try again."; + } + if (/^Hermes attachment storage is unavailable$/iu.test(detail)) { + return "T3 couldn't access the Hermes attachment storage. Remove and reattach the file, then try again."; + } + const unsupportedAttachment = + /^This Hermes gateway does not support (image|PDF|video|file) attachments$/iu.exec(detail); + if (unsupportedAttachment) { + const kind = unsupportedAttachment[1]!.toLowerCase(); + return `This Hermes gateway does not support ${kind} attachments. Remove the ${kind} attachment or update the gateway, then try again.`; + } + return `${provider} couldn't complete the request: ${detail}. Check the provider connection in Settings → Providers, then try again.`; + } + + const turnStartMatch = PROVIDER_TURN_START.exec(trimmed); + if (turnStartMatch) { + const provider = providerLabel(turnStartMatch[1]!); + return `${provider} couldn't start this message. Check the provider connection in Settings → Providers, then try again.`; + } + + return trimmed; +} diff --git a/apps/web/src/components/chat/providerIconUtils.test.ts b/apps/web/src/components/chat/providerIconUtils.test.ts new file mode 100644 index 00000000000..635fe50af86 --- /dev/null +++ b/apps/web/src/components/chat/providerIconUtils.test.ts @@ -0,0 +1,24 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { HermesIcon, OpenClawIcon } from "../Icons"; +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; + +describe("provider icon mapping", () => { + it("uses the official Hermes Agent icon for Hermes surfaces", () => { + expect(PROVIDER_ICON_BY_PROVIDER[ProviderDriverKind.make("hermes")]).toBe(HermesIcon); + expect(renderToStaticMarkup(createElement(HermesIcon))).toContain( + 'href="/hermes-agent-logo.png"', + ); + }); + + it("uses the OpenClaw icon for OpenClaw surfaces", () => { + expect(PROVIDER_ICON_BY_PROVIDER[ProviderDriverKind.make("openclaw")]).toBe(OpenClawIcon); + const markup = renderToStaticMarkup(createElement(OpenClawIcon)); + expect(markup).toContain('viewBox="0 0 120 120"'); + expect(markup).toContain("openclaw__lobster-gradient"); + expect(markup).toContain("#00e5cc"); + }); +}); diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..1a3e42f9a8d 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,14 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + HermesIcon, + Icon, + OpenAI, + OpenClawIcon, + OpenCodeIcon, +} from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +17,8 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("hermes")]: HermesIcon, + [ProviderDriverKind.make("openclaw")]: OpenClawIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index 82d90e5be7c..32291f88026 100644 --- a/apps/web/src/components/settings/AddProviderInstanceDialog.tsx +++ b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx @@ -199,7 +199,10 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns [brandedId]: nextInstance, }; try { - updateSettings({ providerInstances: nextMap }); + updateSettings({ + providerInstances: nextMap, + ...(driver === "hermes" ? { enableHermes: true } : {}), + }); toastManager.add({ type: "success", title: "Provider instance added", diff --git a/apps/web/src/components/settings/HermesCronSettings.tsx b/apps/web/src/components/settings/HermesCronSettings.tsx new file mode 100644 index 00000000000..a0a8ef4816d --- /dev/null +++ b/apps/web/src/components/settings/HermesCronSettings.tsx @@ -0,0 +1,513 @@ +import { Clock3Icon, PauseIcon, PencilIcon, PlayIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; +import type { + HermesCronJob, + HermesCronMutationInput, + HermesCronOperation, + HermesCronProviderProjection, +} from "@t3tools/contracts"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { usePrimaryEnvironment } from "../../state/environments"; +import { hermesEnvironment } from "../../state/hermes"; +import { useEnvironmentQuery } from "../../state/query"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../ui/dialog"; +import { Input } from "../ui/input"; +import { Textarea } from "../ui/textarea"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { randomUUID } from "../../lib/utils"; +import { T3_WORK_BACKING_PROJECT_ID } from "../../t3WorkProject"; + +interface Draft { + readonly providerInstanceId: string; + readonly jobIdentity: string | null; + readonly name: string; + readonly schedule: string; + readonly prompt: string; +} + +const EMPTY_DRAFT: Draft = { + providerInstanceId: "", + jobIdentity: null, + name: "", + schedule: "", + prompt: "", +}; + +function reportFailure(title: string, error: unknown) { + toastManager.add( + stackedThreadToast({ + type: "error", + title, + description: error instanceof Error ? error.message : String(error), + }), + ); +} + +function capabilityFor( + provider: HermesCronProviderProjection, + operation: HermesCronOperation, +): boolean { + switch (operation) { + case "create": + return provider.capabilities.create; + case "edit": + return provider.capabilities.edit; + case "pause": + return provider.capabilities.pause; + case "resume": + return provider.capabilities.resume; + case "delete": + return provider.capabilities.delete; + case "run_now": + return provider.capabilities.runNow; + } +} + +export function HermesCronSettings() { + const environment = usePrimaryEnvironment(); + const query = useEnvironmentQuery( + environment + ? serverEnvironment.hermesCron({ + environmentId: environment.environmentId, + input: {}, + }) + : null, + ); + const mutate = useAtomCommand(serverEnvironment.mutateHermesCron, { + label: "Hermes cron mutation", + }); + const resetHistory = useAtomCommand(hermesEnvironment.resetHistory, { + reportFailure: false, + }); + const [draft, setDraft] = useState(EMPTY_DRAFT); + const [dialogOpen, setDialogOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [resetDialogOpen, setResetDialogOpen] = useState(false); + const [resettingHistory, setResettingHistory] = useState(false); + const [historyResetProviderId, setHistoryResetProviderId] = useState(""); + const providers = query.data?.providers ?? []; + const readyProviders = useMemo( + () => providers.filter((provider) => provider.status === "ready"), + [providers], + ); + + const openCreate = useCallback(() => { + const provider = readyProviders.find((candidate) => candidate.capabilities.create); + if (!provider) return; + setDraft({ ...EMPTY_DRAFT, providerInstanceId: provider.providerInstanceId }); + setDialogOpen(true); + }, [readyProviders]); + + const openEdit = useCallback((provider: HermesCronProviderProjection, job: HermesCronJob) => { + setDraft({ + providerInstanceId: provider.providerInstanceId, + jobIdentity: job.identity, + name: job.name ?? "", + schedule: job.schedule ?? "", + prompt: job.prompt ?? "", + }); + setDialogOpen(true); + }, []); + + const runMutation = useCallback( + async (input: Omit) => { + if (!environment) return false; + const result = await mutate({ + environmentId: environment.environmentId, + input: { ...input, operationId: randomUUID() }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + reportFailure("Hermes cron operation failed", squashAtomCommandFailure(result)); + } + return false; + } + query.refresh(); + return true; + }, + [environment, mutate, query], + ); + + const submit = useCallback(async () => { + if (saving || !draft.name.trim() || !draft.schedule.trim() || !draft.prompt.trim()) return; + setSaving(true); + const succeeded = await runMutation({ + providerInstanceId: draft.providerInstanceId, + operation: draft.jobIdentity ? "edit" : "create", + ...(draft.jobIdentity ? { jobIdentity: draft.jobIdentity } : {}), + name: draft.name.trim(), + schedule: draft.schedule.trim(), + prompt: draft.prompt.trim(), + }); + setSaving(false); + if (succeeded) setDialogOpen(false); + }, [draft, runMutation, saving]); + + const mutateJob = useCallback( + async ( + provider: HermesCronProviderProjection, + job: HermesCronJob, + operation: Exclude, + ) => { + if (job.identityStrength === "missing" || !capabilityFor(provider, operation)) return; + if (operation === "delete" && !window.confirm(`Delete ${job.name ?? job.identity}?`)) return; + await runMutation({ + providerInstanceId: provider.providerInstanceId, + operation, + jobIdentity: job.identity, + }); + }, + [runMutation], + ); + + const canCreate = readyProviders.some((provider) => provider.capabilities.create); + const historyResetProvider = + readyProviders.find((provider) => provider.providerInstanceId === historyResetProviderId) ?? + readyProviders[0] ?? + null; + const runHistoryReset = useCallback(async () => { + if (!environment || historyResetProvider === null || resettingHistory) return; + setResettingHistory(true); + const result = await resetHistory({ + environmentId: environment.environmentId, + input: { + providerInstanceId: ProviderInstanceId.make(historyResetProvider.providerInstanceId), + backingProjectId: T3_WORK_BACKING_PROJECT_ID, + operationId: randomUUID(), + }, + }); + setResettingHistory(false); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not delete T3 Work history", squashAtomCommandFailure(result)); + } + return; + } + setResetDialogOpen(false); + toastManager.add({ + type: "success", + title: "T3 Work history deleted", + description: + result.value.deletedThreadCount === 0 + ? "The import ledger was reset. You can run Hermes onboarding again." + : `${result.value.deletedThreadCount} conversation${ + result.value.deletedThreadCount === 1 ? "" : "s" + } removed. You can run Hermes onboarding again.`, + }); + }, [environment, historyResetProvider, resetHistory, resettingHistory]); + + return ( + + } + headerAction={ + + } + > +
    + These jobs are read and managed directly in Hermes. They are never copied into T3 + scheduled tasks. +
    + {query.error ? ( +
    {query.error}
    + ) : query.isPending && providers.length === 0 ? ( +
    + Loading Hermes cron inventory… +
    + ) : providers.length === 0 ? ( +
    + No Hermes provider instances are configured. +
    + ) : ( +
    + {providers.map((provider) => ( +
    +
    +

    {provider.displayName}

    + + {provider.status} + + + Profile {provider.profileKey} + +
    + {provider.diagnostics.map((diagnostic) => ( +

    + {diagnostic} +

    + ))} + {provider.jobs.length === 0 && provider.status === "ready" ? ( +

    No native cron jobs.

    + ) : ( +
    + {provider.jobs.map((job) => { + const paused = job.enabled === false; + const addressable = job.identityStrength !== "missing"; + return ( +
    +
    +
    + + {job.name ?? job.id ?? "Unaddressable job"} + + + {paused ? "Paused" : job.enabled === true ? "Enabled" : "Unknown"} + +
    +

    + {job.schedule ?? "Schedule unavailable"} +

    + {job.prompt ? ( +

    + {job.prompt} +

    + ) : null} +

    + Executions: {job.executions.length} +

    +
    +
    + {provider.capabilities.edit ? ( + + ) : null} + {paused && provider.capabilities.resume ? ( + + ) : !paused && provider.capabilities.pause ? ( + + ) : null} + {provider.capabilities.runNow ? ( + + ) : null} + {provider.capabilities.delete ? ( + + ) : null} +
    +
    + ); + })} +
    + )} +
    + ))} +
    + )} +
    + + }> + setResetDialogOpen(true)} + > + + Delete history + + } + /> + + + + + + + {draft.jobIdentity ? "Edit Hermes cron job" : "New Hermes cron job"} + + Hermes owns this job and its execution schedule. + + +
    + {!draft.jobIdentity && readyProviders.length > 1 ? ( + + ) : null} + + +