diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..49c891f22767 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -38,6 +38,31 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "hermes") { + return ( + + + + + + + + + ); + } + if (props.provider === "cursor") { return ( diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 637b7b5ee188..4e3b11aca96f 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -68,7 +68,7 @@ import { * and friends) starts folded so a 300-model catalog cannot bury the list. All * provider headers remain user-collapsible. */ -const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); +const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex", "hermes"]); /** * Keep measured row changes stable, but let catalog mutations use the list's * native bounds so a filtered catalog that underflows returns to the top. diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index 8a9dabbe034f..2228bcabfe64 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -10,6 +10,31 @@ import { } from "./modelOptions"; describe("mobile model options", () => { + it("uses the Hermes product label for the built-in instance", () => { + const config = { + providers: [ + { + instanceId: "hermes", + driver: "hermes", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "default", + name: "Hermes Default", + isCustom: false, + isDefault: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect(buildModelOptions(config, null)[0]?.providerLabel).toBe("Hermes"); + }); + it("groups models by provider and flags legacy entries", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index cb7a8c4198ec..f198b5c18716 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -35,6 +35,7 @@ function providerDisplayLabel(provider: { if (provider.displayName) return provider.displayName; if (provider.driver === "codex") return "Codex"; if (provider.driver === "claudeAgent") return "Claude"; + if (provider.driver === "hermes") return "Hermes"; return provider.instanceId; } diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 000000000000..3a992b249392 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,164 @@ +import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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 { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { + buildInitialHermesProviderSnapshot, + checkHermesProviderStatus, + enrichHermesSnapshot, +} from "../Layers/HermesProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { type ProviderDriver, type ProviderInstance } from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + resolveProviderMaintenanceCapabilitiesEffect, + type ProviderMaintenanceCapabilitiesResolver, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; +import { makeHermesContinuationGroupKey, makeHermesEnvironment } from "./HermesHome.ts"; +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); + +const UPDATE: ProviderMaintenanceCapabilitiesResolver = { + resolve: (options) => + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: "hermes-agent", + updateExecutable: options?.binaryPath?.trim() || "hermes", + updateArgs: ["update"], + updateLockKey: "hermes-agent", + }), +}; + +export type HermesDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: (): HermesSettings => decodeHermesSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const eventLoggers = yield* ProviderEventLoggers; + const baseEnv = mergeProviderInstanceEnvironment(environment); + const processEnv = yield* makeHermesEnvironment(config, baseEnv); + const continuationKey = yield* makeHermesContinuationGroupKey(config, baseEnv); + const continuationIdentity = { driverKind: DRIVER_KIND, continuationKey }; + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies HermesSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeHermesAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialHermesProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichHermesSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + publishSnapshot, + httpClient, + }), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build the Hermes provider snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/HermesHome.test.ts b/apps/server/src/provider/Drivers/HermesHome.test.ts new file mode 100644 index 000000000000..ff4f9aa81172 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesHome.test.ts @@ -0,0 +1,49 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + makeHermesContinuationGroupKey, + makeHermesEnvironment, + resolveHermesHomePath, +} from "./HermesHome.ts"; + +it.layer(NodeServices.layer)("HermesHome", (it) => { + describe("Hermes home resolution", () => { + it.effect("honors inherited HERMES_HOME", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = { ...process.env, HERMES_HOME: "/tmp/hermes-shared" }; + const resolved = path.resolve("/tmp/hermes-shared"); + expect(yield* resolveHermesHomePath({ homePath: "" }, environment)).toBe(resolved); + expect(yield* makeHermesContinuationGroupKey({ homePath: "" }, environment)).toBe( + `hermes:home:${resolved}`, + ); + }), + ); + + it.effect("lets explicit settings override the inherited home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.homedir(), ".hermes-work"); + const environment = { ...process.env, HERMES_HOME: "/tmp/hermes-shared" }; + expect( + (yield* makeHermesEnvironment({ homePath: "~/.hermes-work" }, environment)).HERMES_HOME, + ).toBe(resolved); + }), + ); + + it.effect("uses the instance HOME for Hermes' default directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = { ...process.env, HOME: "/tmp/remote-home", HERMES_HOME: "" }; + expect(yield* resolveHermesHomePath({ homePath: "" }, environment)).toBe( + path.resolve("/tmp/remote-home/.hermes"), + ); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/HermesHome.ts b/apps/server/src/provider/Drivers/HermesHome.ts new file mode 100644 index 000000000000..3fe5f44f3672 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesHome.ts @@ -0,0 +1,44 @@ +import * as NodeOS from "node:os"; + +import type { HermesSettings } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { expandHomePath } from "../../pathExpansion.ts"; + +export const resolveHermesHomePath = Effect.fn("resolveHermesHomePath")(function* ( + config: Pick, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const path = yield* Path.Path; + const configured = config.homePath.trim(); + const inherited = environment.HERMES_HOME?.trim(); + const environmentHome = environment.HOME?.trim() || environment.USERPROFILE?.trim(); + const platform = yield* HostProcessPlatform; + const platformDefault = + platform === "win32" && environment.LOCALAPPDATA?.trim() + ? path.join(environment.LOCALAPPDATA.trim(), "hermes") + : path.join(environmentHome || NodeOS.homedir(), ".hermes"); + return path.resolve(expandHomePath(configured || inherited || platformDefault)); +}); + +export const makeHermesEnvironment = Effect.fn("makeHermesEnvironment")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + if (!config.homePath.trim()) return baseEnv; + return { + ...baseEnv, + HERMES_HOME: yield* resolveHermesHomePath(config, baseEnv), + }; +}); + +export const makeHermesContinuationGroupKey = Effect.fn("makeHermesContinuationGroupKey")( + function* ( + config: Pick, + environment?: NodeJS.ProcessEnv, + ): Effect.fn.Return { + return `hermes:home:${yield* resolveHermesHomePath(config, environment)}`; + }, +); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 30c173d8fae8..d9effbc32690 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -863,10 +863,14 @@ export function makeCursorAdapter( turnId: ctx.activeTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, + streamKind: event.streamKind, rawPayload: event.rawPayload, }), ); return; + case "AvailableCommandsUpdated": + case "UsageUpdated": + return; } }), ), diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 858d862e6d5f..62c9b0bb8b33 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -865,10 +865,14 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte turnId: notificationTurnId, ...(event.itemId ? { itemId: event.itemId } : {}), text: event.text, + streamKind: event.streamKind, rawPayload: event.rawPayload, }), ); return; + case "AvailableCommandsUpdated": + case "UsageUpdated": + return; } }), ), diff --git a/apps/server/src/provider/Layers/HermesAdapter.test.ts b/apps/server/src/provider/Layers/HermesAdapter.test.ts new file mode 100644 index 000000000000..3f6fc31834e3 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vite-plus/test"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + resolveHermesRuntimeMode, + selectHermesAutoApprovedPermissionOption, + selectHermesPermissionOptionId, +} from "./HermesAdapter.ts"; + +const permissionRequest = { + sessionId: "hermes-session", + toolCall: { toolCallId: "tool-1", title: "Run command" }, + options: [ + { optionId: "allow_once", kind: "allow_once", name: "Allow once" }, + { optionId: "allow_session", kind: "allow_always", name: "Allow for session" }, + { optionId: "allow_always", kind: "allow_always", name: "Always allow" }, + { optionId: "deny", kind: "reject_once", name: "Deny" }, + ], +} satisfies EffectAcpSchema.RequestPermissionRequest; + +describe("Hermes adapter policy mapping", () => { + it("maps T3 runtime modes to Hermes ACP modes", () => { + expect(resolveHermesRuntimeMode("approval-required")).toBe("default"); + expect(resolveHermesRuntimeMode("auto")).toBe("default"); + expect(resolveHermesRuntimeMode("auto-accept-edits")).toBe("accept_edits"); + expect(resolveHermesRuntimeMode("full-access")).toBe("dont_ask"); + }); + + it("uses exact Hermes option ids and avoids permanent auto-approval", () => { + expect(selectHermesPermissionOptionId(permissionRequest, "accept")).toBe("allow_once"); + expect(selectHermesPermissionOptionId(permissionRequest, "acceptForSession")).toBe( + "allow_session", + ); + expect(selectHermesPermissionOptionId(permissionRequest, "decline")).toBe("deny"); + expect(selectHermesAutoApprovedPermissionOption(permissionRequest)).toBe("allow_session"); + }); + + it("falls back to ACP option kinds and only auto-approves once", () => { + const genericRequest = { + ...permissionRequest, + options: [ + { optionId: "temporary", kind: "allow_once", name: "Temporarily allow" }, + { optionId: "permanent", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ], + } satisfies EffectAcpSchema.RequestPermissionRequest; + + expect(selectHermesPermissionOptionId(genericRequest, "accept")).toBe("temporary"); + expect(selectHermesPermissionOptionId(genericRequest, "decline")).toBe("reject"); + expect(selectHermesAutoApprovedPermissionOption(genericRequest)).toBe("temporary"); + }); +}); diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 000000000000..ecb819f7c350 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,1184 @@ +/** + * HermesAdapterLive — Hermes CLI (`hermes acp`) via ACP. + * + * @module HermesAdapterLive + */ + +import { + ApprovalRequestId, + type HermesSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { + applyHermesAcpModelSelection, + makeHermesAcpRuntime, + resolveHermesModelId, +} from "../acp/HermesAcpSupport.ts"; +import { type HermesAdapterShape } from "../Services/HermesAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface HermesAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`hermes`). + */ + readonly instanceId?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `hermesSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface HermesSessionContext { + readonly threadId: ThreadId; + readonly acpSessionId: string; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Turns already interrupted; a sendTurn preparing or awaiting settlement after a stop must not resurrect them. */ + readonly interruptedTurnIds: Set; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + readonly promptSettlements: Set>; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseHermesResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== HERMES_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +export function resolveHermesRuntimeMode(runtimeMode: RuntimeMode): string { + switch (runtimeMode) { + case "full-access": + return "dont_ask"; + case "auto-accept-edits": + return "accept_edits"; + case "approval-required": + case "auto": + return "default"; + } +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly modelSelection: + | { + readonly model: string; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyHermesAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + mapError: (cause) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + const requestedModeId = resolveHermesRuntimeMode(input.runtimeMode); + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +export function selectHermesAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowSessionOption = request.options.find((option) => option.optionId === "allow_session"); + if (allowSessionOption?.optionId.trim()) { + return allowSessionOption.optionId.trim(); + } + + const allowOnceOption = + request.options.find((option) => option.optionId === "allow_once") ?? + request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function selectHermesPermissionOptionId( + request: EffectAcpSchema.RequestPermissionRequest, + decision: Exclude, +): string | undefined { + const preferredIds = + decision === "acceptForSession" + ? ["allow_session", "allow_always"] + : decision === "accept" + ? ["allow_once"] + : ["deny"]; + for (const id of preferredIds) { + const exact = request.options.find((option) => option.optionId === id); + if (exact?.optionId.trim()) return exact.optionId.trim(); + } + const fallbackKind = + decision === "acceptForSession" + ? "allow_always" + : decision === "accept" + ? "allow_once" + : "reject_once"; + return ( + request.options.find((option) => option.kind === fallbackKind)?.optionId.trim() || undefined + ); +} + +function permissionOutcome( + request: EffectAcpSchema.RequestPermissionRequest, + decision: ProviderApprovalDecision, +): EffectAcpSchema.RequestPermissionResponse { + if (decision === "cancel") return { outcome: { outcome: "cancelled" } }; + const optionId = selectHermesPermissionOptionId(request, decision); + return optionId + ? { outcome: { outcome: "selected", optionId } } + : { outcome: { outcome: "cancelled" } }; +} + +export function makeHermesAdapter( + hermesSettings: HermesSettings, + options?: HermesAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("hermes"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Hermes runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Hermes ACP extension event.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.hermes.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: HermesSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.hermes.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: HermesSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + // Release the per-thread semaphore so starting and stopping sessions + // does not grow threadLocksRef for the adapter's lifetime. + yield* SynchronizedRef.update(threadLocksRef, (current) => { + const next = new Map(current); + next.delete(ctx.threadId); + return next; + }); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: HermesAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const hermesModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: HermesSessionContext; + + const resumeSessionId = parseHermesResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the HermesSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { hermes: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveHermesSettings = options?.resolveSettings + ? yield* options.resolveSettings + : hermesSettings; + const interactiveEnvironment = { + ...(options?.environment ?? process.env), + // Probe and metadata jobs opt out explicitly; interactive Hermes + // sessions retain its configured MCP servers alongside T3's bridge. + HERMES_ACP_SKIP_CONFIGURED_MCP: "0", + }; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeHermesAcpRuntime({ + hermesSettings: effectiveHermesSettings, + environment: interactiveEnvironment, + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to start the Hermes ACP session process.", + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectHermesAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return permissionOutcome(params, resolved); + }), + ), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + modelSelection: hermesModelSelection, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const configuredModel = resolveHermesModelId(hermesModelSelection?.model); + const currentModel = + started.sessionSetupResult.models?.currentModelId.trim() || undefined; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: configuredModel ?? currentModel ?? "default", + threadId: input.threadId, + resumeCursor: { + schemaVersion: HERMES_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + acpSessionId: started.sessionId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + interruptedTurnIds: new Set(), + promptsInFlight: 0, + promptSettlements: new Set(), + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + streamKind: event.streamKind, + rawPayload: event.rawPayload, + }), + ); + return; + case "AvailableCommandsUpdated": + return; + case "UsageUpdated": + yield* offerRuntimeEvent({ + type: "thread.token-usage.updated", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + usage: { + usedTokens: event.used, + ...(event.size > 0 ? { maxTokens: event.size } : {}), + compactsAutomatically: true, + }, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: event.rawPayload, + }, + }); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Hermes runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Hermes ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: HermesAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const promptSettlement = yield* Deferred.make(); + const run = Effect.gen(function* () { + // Accounting is serialized under the thread lock: a concurrent + // sendTurn must observe this prompt's turn id (and this prompt's + // in-flight count) atomically, or two prompts can both treat + // themselves as a fresh turn and emit duplicate `turn.started`. + const prepared = yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + const earlierPromptSettlements = Array.from(ctx.promptSettlements); + ctx.promptSettlements.add(promptSettlement); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + // Bind the turn id before any cooperative yield so interruptTurn + // can mark this prompt interrupted even while it is being prepared. + ctx.activeTurnId = turnId; + return { ctx, steeringTurnId, turnId, earlierPromptSettlements }; + }), + ); + const { ctx, steeringTurnId, turnId, earlierPromptSettlements } = prepared; + const clearPhantomActiveTurn = () => { + if (steeringTurnId === undefined) { + ctx.activeTurnId = undefined; + } + }; + + if ( + steeringTurnId !== undefined && + (input.attachments?.length ?? 0) > 0 && + earlierPromptSettlements.length > 0 + ) { + // Hermes can redirect text while active, but queues rich media as + // a text placeholder. Stop the active request and wait for its RPC + // to settle before submitting the intact image blocks. This wait is + // deliberately outside the thread lock so interruptTurn can still + // cancel while it is in progress. + yield* ctx.acp + .notify("session/cancel", { sessionId: ctx.acpSessionId }) + .pipe(Effect.ignore); + yield* Effect.forEach(earlierPromptSettlements, Deferred.await, { + discard: true, + }); + // A stop that arrived while the previous prompt was settling must + // not resurrect the turn with a fresh image prompt. + if (ctx.interruptedTurnIds.has(turnId)) { + // The superseded prompt already skipped its completion (another + // prompt was in flight), so close the turn here or the thread + // would stay running forever. + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "cancelled", stopReason: "cancelled" }, + }); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Hermes image steer was interrupted.", + }); + } + } + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + clearPhantomActiveTurn(); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.tapErrorCause(() => Effect.sync(clearPhantomActiveTurn)), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Failed to read attachment file.", + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + // A rejected turn must not leave a phantom active turn behind. + clearPhantomActiveTurn(); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + // A stop that arrived while the prompt was being prepared (attachment + // I/O or config) must not publish a turn.started afterwards. + if (ctx.interruptedTurnIds.has(turnId)) { + clearPhantomActiveTurn(); + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Hermes prompt was interrupted during preparation.", + }); + } + + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveHermesModelId(model) ?? ctx.session.model ?? "default"; + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + modelSelection: + model === undefined + ? undefined + : { + model, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + return yield* withThreadLock( + input.threadId, + Effect.gen(function* () { + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }), + ); + }); + return yield* run.pipe( + Effect.ensuring( + withThreadLock( + input.threadId, + Effect.sync(() => { + const ctx = sessions.get(input.threadId); + if (!ctx) { + return; + } + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + ctx.promptSettlements.delete(promptSettlement); + }).pipe(Effect.andThen(Deferred.succeed(promptSettlement, undefined)), Effect.asVoid), + ), + ), + ); + }); + + const interruptTurn: HermesAdapterShape["interruptTurn"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const interruptedTurnId = ctx.activeTurnId; + if (interruptedTurnId !== undefined) { + ctx.interruptedTurnIds.add(interruptedTurnId); + } + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }), + ); + + const respondToRequest: HermesAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: HermesAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "hermes/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: HermesAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: HermesAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: HermesAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: HermesAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: HermesAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: HermesAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Hermes session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies HermesAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/HermesProvider.test.ts b/apps/server/src/provider/Layers/HermesProvider.test.ts new file mode 100644 index 000000000000..ff82b5836b50 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + buildHermesDiscoveredModelsFromSessionModelState, + hermesModelsFromSettings, + hermesSlashCommands, + parseHermesVersion, +} from "./HermesProvider.ts"; + +describe("Hermes provider metadata", () => { + it("reads the product version instead of Hermes' release date", () => { + expect(parseHermesVersion("Hermes Agent v0.20.0 (2026.8.3)")).toBe("0.20.0"); + }); + + it("marks Hermes' configured model as the live default", () => { + const models = buildHermesDiscoveredModelsFromSessionModelState({ + currentModelId: "openrouter:anthropic/claude-sonnet-4.6", + availableModels: [ + { + modelId: "openrouter:anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + }, + { modelId: "openai:gpt-5.4", name: "GPT-5.4" }, + ], + } satisfies EffectAcpSchema.SessionModelState); + + expect(models.map(({ slug, isDefault }) => ({ slug, isDefault }))).toEqual([ + { slug: "openrouter:anthropic/claude-sonnet-4.6", isDefault: true }, + { slug: "openai:gpt-5.4", isDefault: undefined }, + ]); + }); + + it("keeps the built-in default entry when Hermes routes through its default model", () => { + const models = buildHermesDiscoveredModelsFromSessionModelState({ + currentModelId: "default", + availableModels: [ + { + modelId: "openrouter:anthropic/claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + }, + { modelId: "openai:gpt-5.4", name: "GPT-5.4" }, + ], + } satisfies EffectAcpSchema.SessionModelState); + + expect(models.map(({ slug, isDefault }) => ({ slug, isDefault }))).toEqual([ + { slug: "default", isDefault: true }, + { slug: "openrouter:anthropic/claude-sonnet-4.6", isDefault: undefined }, + { slug: "openai:gpt-5.4", isDefault: undefined }, + ]); + }); + + it("merges exact provider:model custom ids without duplicates", () => { + const models = hermesModelsFromSettings([ + " openrouter:anthropic/claude-sonnet-4.6 ", + "openrouter:anthropic/claude-sonnet-4.6", + ]); + expect(models.map((model) => model.slug)).toEqual([ + "default", + "openrouter:anthropic/claude-sonnet-4.6", + ]); + }); + + it("normalizes advertised slash command names and hints", () => { + expect( + hermesSlashCommands([ + { name: "/model", description: "Switch model", input: { hint: "provider:model" } }, + ]), + ).toEqual([{ name: "model", description: "Switch model", input: { hint: "provider:model" } }]); + }); +}); diff --git a/apps/server/src/provider/Layers/HermesProvider.ts b/apps/server/src/provider/Layers/HermesProvider.ts new file mode 100644 index 000000000000..9ddd2fe40ae6 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -0,0 +1,446 @@ +import { + type HermesSettings, + type ModelCapabilities, + type ServerProvider, + type ServerProviderModel, + type ServerProviderSlashCommand, +} from "@t3tools/contracts"; +import type * as EffectAcpSchema from "effect-acp/schema"; +import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { compareSemverVersions } from "@t3tools/shared/semver"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + spawnAndCollect, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + deleteHermesSession, + makeHermesAcpRuntime, + resolveHermesModelId, +} from "../acp/HermesAcpSupport.ts"; + +const HERMES_PRESENTATION = { + displayName: "Hermes", + badgeLabel: "Early Access", + showInteractionModeToggle: false, + requiresNewThreadForModelChange: false, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const VERSION_PROBE_TIMEOUT_MS = 4_000; +const HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; +const MINIMUM_HERMES_VERSION = "0.20.0"; + +const HERMES_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "default", + name: "Hermes Default", + isCustom: false, + isDefault: true, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +export function parseHermesVersion(output: string): string | null { + const productVersion = output.match(/Hermes Agent\s+v?(\d+\.\d+\.\d+)/i)?.[1]; + return productVersion ?? parseGenericCliVersion(output); +} + +export function buildInitialHermesProviderSnapshot( + hermesSettings: HermesSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = hermesModelsFromSettings(hermesSettings.customModels); + + if (!hermesSettings.enabled) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hermes CLI availability...", + }, + }); + }); +} + +export function hermesModelsFromSettings( + customModels: ReadonlyArray | undefined, + builtInModels: ReadonlyArray = HERMES_BUILT_IN_MODELS, +): ReadonlyArray { + return providerModelsFromSettings(builtInModels, customModels ?? [], EMPTY_CAPABILITIES); +} + +export function buildHermesDiscoveredModelsFromSessionModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) { + return []; + } + const seen = new Set(); + const discoveredModels = modelState.availableModels + .map((model): ServerProviderModel | undefined => { + const slug = resolveHermesModelId(model.modelId); + if (!slug || seen.has(slug)) { + return undefined; + } + seen.add(slug); + return { + slug, + name: model.name.trim() || slug, + isCustom: false, + ...(model.modelId === modelState.currentModelId ? { isDefault: true } : {}), + capabilities: EMPTY_CAPABILITIES, + }; + }) + .filter((model): model is ServerProviderModel => model !== undefined); + // When Hermes routes through its "default" entry, keep that routing selectable + // instead of forcing clients onto the first concrete discovered model. + return modelState.currentModelId === "default" + ? [...HERMES_BUILT_IN_MODELS, ...discoveredModels] + : discoveredModels; +} + +export function hermesSlashCommands( + commands: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return commands.flatMap((command) => { + const name = command.name.trim().replace(/^\//, ""); + if (!name || seen.has(name)) return []; + seen.add(name); + const description = command.description.trim(); + const hint = command.input?.hint.trim(); + return [ + { + name, + ...(description ? { description } : {}), + ...(hint ? { input: { hint } } : {}), + } satisfies ServerProviderSlashCommand, + ]; + }); +} + +const discoverHermesModelsViaAcp = ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) => { + const probeEnvironment = { + ...environment, + HERMES_ACP_SKIP_CONFIGURED_MCP: "1", + }; + return Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const discovered = yield* Effect.gen(function* () { + const sessionIdRef = yield* Ref.make(undefined); + yield* Effect.addFinalizer(() => + Ref.get(sessionIdRef).pipe( + Effect.flatMap((sessionId) => + sessionId + ? deleteHermesSession({ + settings: hermesSettings, + sessionId, + environment: probeEnvironment, + }).pipe(Effect.ignore) + : Effect.void, + ), + ), + ); + const acp = yield* makeHermesAcpRuntime({ + hermesSettings, + environment: probeEnvironment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* acp.start(); + yield* Ref.set(sessionIdRef, started.sessionId); + const commands = yield* acp.getEvents().pipe( + Stream.filterMap((event) => + event._tag === "AvailableCommandsUpdated" ? Result.succeed(event) : Result.failVoid, + ), + Stream.runHead, + Effect.timeoutOption("1 second"), + Effect.map(Option.flatten), + ); + return { + models: buildHermesDiscoveredModelsFromSessionModelState(started.sessionSetupResult.models), + slashCommands: Option.match(commands, { + onNone: () => [], + onSome: (event) => hermesSlashCommands(event.commands), + }), + }; + }).pipe(Effect.scoped); + + return discovered; + }); +}; + +const runHermesVersionCommand = ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const command = hermesSettings.binaryPath || "hermes"; + const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { + env: environment, + }); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env: environment, + shell: spawnCommand.shell, + }), + ); + }); + +export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = hermesModelsFromSettings(hermesSettings.customModels); + + if (!hermesSettings.enabled) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runHermesVersionCommand(hermesSettings, environment).pipe( + Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionResult)) { + const error = versionResult.failure; + yield* Effect.logWarning("Hermes CLI health check failed.", { + errorTag: error._tag, + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Hermes CLI (`hermes`) is not installed or not on PATH. Install it from https://hermes-agent.nousresearch.com." + : "Failed to execute Hermes CLI health check.", + }, + }); + } + + if (Option.isNone(versionResult.success)) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but timed out while running `hermes --version`.", + }, + }); + } + + const versionOutput = versionResult.success.value; + const version = parseHermesVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); + if (versionOutput.code !== 0) { + yield* Effect.logWarning("Hermes CLI version probe exited with a non-zero status.", { + exitCode: versionOutput.code, + stdoutLength: versionOutput.stdout.length, + stderrLength: versionOutput.stderr.length, + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but failed to run.", + }, + }); + } + if (!version) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `Unable to determine the Hermes Agent version. T3 Code requires v${MINIMUM_HERMES_VERSION} or newer.`, + }, + }); + } + if (compareSemverVersions(version, MINIMUM_HERMES_VERSION) < 0) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Hermes Agent v${version} is incompatible. Upgrade to v${MINIMUM_HERMES_VERSION} or newer with \`hermes update\`.`, + }, + }); + } + + const discoveryExit = yield* discoverHermesModelsViaAcp(hermesSettings, environment).pipe( + Effect.timeoutOption(HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + Effect.exit, + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Hermes ACP model discovery failed", { + errorTag: causeErrorTag(discoveryExit.cause), + }); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: + "Hermes CLI is not configured or ACP startup failed. Run `hermes setup --quick`, then refresh provider status. See https://hermes-agent.nousresearch.com.", + }, + }); + } + if (Option.isNone(discoveryExit.value)) { + yield* Effect.logWarning( + `Hermes ACP model discovery timed out after ${HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + ); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version, + status: "error", + auth: { status: "unknown" }, + message: `Hermes CLI is installed but ACP startup timed out after ${HERMES_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`, + }, + }); + } + const discovery = discoveryExit.value.value; + const models = + discovery.models.length > 0 + ? hermesModelsFromSettings(hermesSettings.customModels, discovery.models) + : fallbackModels; + + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + probe: { + installed: true, + version, + status: "ready", + auth: { status: "authenticated" }, + }, + }); +}); + +export const enrichHermesSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly enableProviderUpdateChecks?: boolean; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const { snapshot, publishSnapshot } = input; + + return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { + enableProviderUpdateChecks: input.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + Effect.catchCause((cause) => + Effect.logWarning("Hermes version advisory enrichment failed", { + errorTag: causeErrorTag(cause), + }), + ), + Effect.asVoid, + ); +}; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index dcc3ac0b5db7..41125cf9c3f3 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `grok`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `grok`, `hermes`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,7 +18,7 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `opencode` + * without trying to spawn real `codex` / `claude` / `agent` / `grok` / `hermes` / `opencode` * binaries. That keeps the assertions focused on registry routing * behaviour rather than the runtime details of each provider. */ @@ -29,6 +29,7 @@ import { type CodexSettings, type CursorSettings, type GrokSettings, + type HermesSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -47,6 +48,7 @@ import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; import { GrokDriver } from "../Drivers/GrokDriver.ts"; +import { HermesDriver } from "../Drivers/HermesDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -124,6 +126,14 @@ const makeGrokConfig = (overrides: Partial): GrokSettings => ({ ...overrides, }); +const makeHermesConfig = (overrides: Partial): HermesSettings => ({ + enabled: false, + binaryPath: "hermes", + homePath: "", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -294,12 +304,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); const grokId = ProviderInstanceId.make("grok_default"); + const hermesId = ProviderInstanceId.make("hermes_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); const grokDriverKind = ProviderDriverKind.make("grok"); + const hermesDriverKind = ProviderDriverKind.make("hermes"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -330,6 +342,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeGrokConfig({}), }, + [hermesId]: { + driver: hermesDriverKind, + displayName: "Hermes", + enabled: false, + config: makeHermesConfig({ homePath: "/home/julius/.hermes-work" }), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -339,7 +357,14 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, GrokDriver, OpenCodeDriver], + drivers: [ + CodexDriver, + ClaudeDriver, + CursorDriver, + GrokDriver, + HermesDriver, + OpenCodeDriver, + ], configMap, }); @@ -349,9 +374,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(5); + expect(instances).toHaveLength(6); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, grokId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, grokId, hermesId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -361,16 +386,19 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); const grok = yield* registry.getInstance(grokId); + const hermes = yield* registry.getInstance(hermesId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); expect(grok?.driverKind).toBe(grokDriverKind); + expect(hermes?.driverKind).toBe(hermesDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); expect(grok?.displayName).toBe("Grok"); + expect(hermes?.displayName).toBe("Hermes"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across diff --git a/apps/server/src/provider/Services/HermesAdapter.ts b/apps/server/src/provider/Services/HermesAdapter.ts new file mode 100644 index 000000000000..75cf71483937 --- /dev/null +++ b/apps/server/src/provider/Services/HermesAdapter.ts @@ -0,0 +1,4 @@ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface HermesAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts index bd25e9815aef..028cac82369f 100644 --- a/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts +++ b/apps/server/src/provider/acp/AcpCoreRuntimeEvents.ts @@ -1,5 +1,6 @@ import { type RuntimeEventRawSource, + type RuntimeContentStreamKind, RuntimeItemId, type CanonicalRequestType, type EventId, @@ -220,6 +221,7 @@ export function makeAcpContentDeltaEvent(input: { readonly turnId: TurnId | undefined; readonly itemId?: string; readonly text: string; + readonly streamKind?: Extract; readonly rawPayload: unknown; }): ProviderRuntimeEvent { return { @@ -230,7 +232,7 @@ export function makeAcpContentDeltaEvent(input: { turnId: input.turnId, ...(input.itemId ? { itemId: RuntimeItemId.make(input.itemId) } : {}), payload: { - streamKind: "assistant_text", + streamKind: input.streamKind ?? "assistant_text", delta: input.text, }, raw: { diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts index 7682c5f5f9cb..379b26417bd3 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.test.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.test.ts @@ -13,6 +13,45 @@ import { } from "./AcpRuntimeModel.ts"; describe("AcpRuntimeModel", () => { + it("parses Hermes reasoning, usage, and command updates", () => { + const thought = parseSessionUpdateEvent({ + sessionId: "hermes-session", + update: { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "checking the workspace" }, + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(thought.events[0]).toMatchObject({ + _tag: "ContentDelta", + streamKind: "reasoning_text", + text: "checking the workspace", + }); + + const usage = parseSessionUpdateEvent({ + sessionId: "hermes-session", + update: { sessionUpdate: "usage_update", used: 12_345, size: 131_072 }, + } satisfies EffectAcpSchema.SessionNotification); + expect(usage.events[0]).toMatchObject({ + _tag: "UsageUpdated", + used: 12_345, + size: 131_072, + }); + + const commands = parseSessionUpdateEvent({ + sessionId: "hermes-session", + update: { + sessionUpdate: "available_commands_update", + availableCommands: [ + { name: "steer", description: "Redirect active work", input: { hint: "guidance" } }, + ], + }, + } satisfies EffectAcpSchema.SessionNotification); + expect(commands.events[0]).toMatchObject({ + _tag: "AvailableCommandsUpdated", + commands: [{ name: "steer" }], + }); + }); + it("parses session mode state from typed ACP session setup responses", () => { const modeState = parseSessionModeState({ sessionId: "session-1", @@ -321,6 +360,7 @@ describe("AcpRuntimeModel", () => { expect(contentResult.events).toEqual([ { _tag: "ContentDelta", + streamKind: "assistant_text", text: "hello from acp", rawPayload: { sessionId: "session-1", diff --git a/apps/server/src/provider/acp/AcpRuntimeModel.ts b/apps/server/src/provider/acp/AcpRuntimeModel.ts index e6bfc127e6e9..93e84a162261 100644 --- a/apps/server/src/provider/acp/AcpRuntimeModel.ts +++ b/apps/server/src/provider/acp/AcpRuntimeModel.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import type * as EffectAcpSchema from "effect-acp/schema"; import { deriveToolActivityPresentation } from "@t3tools/shared/toolActivity"; -import type { ToolLifecycleItemType } from "@t3tools/contracts"; +import type { RuntimeContentStreamKind, ToolLifecycleItemType } from "@t3tools/contracts"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -107,6 +107,18 @@ export type AcpParsedSessionEvent = readonly _tag: "ContentDelta"; readonly itemId?: string; readonly text: string; + readonly streamKind: Extract; + readonly rawPayload: unknown; + } + | { + readonly _tag: "AvailableCommandsUpdated"; + readonly commands: ReadonlyArray; + readonly rawPayload: unknown; + } + | { + readonly _tag: "UsageUpdated"; + readonly used: number; + readonly size: number; readonly rawPayload: unknown; }; @@ -569,6 +581,37 @@ export function parseSessionUpdateEvent(params: EffectAcpSchema.SessionNotificat events.push({ _tag: "ContentDelta", text: upd.content.text, + streamKind: "assistant_text", + rawPayload: params, + }); + } + break; + } + case "agent_thought_chunk": { + if (upd.content.type === "text" && upd.content.text.length > 0) { + events.push({ + _tag: "ContentDelta", + text: upd.content.text, + streamKind: "reasoning_text", + rawPayload: params, + }); + } + break; + } + case "available_commands_update": { + events.push({ + _tag: "AvailableCommandsUpdated", + commands: upd.availableCommands, + rawPayload: params, + }); + break; + } + case "usage_update": { + if (Number.isFinite(upd.used) && Number.isFinite(upd.size)) { + events.push({ + _tag: "UsageUpdated", + used: Math.max(0, Math.trunc(upd.used)), + size: Math.max(0, Math.trunc(upd.size)), rawPayload: params, }); } diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..232ca0ed094a 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -69,6 +69,8 @@ export interface AcpSessionRuntimeOptions { readonly version: string; }; readonly authMethodId: string; + /** Allow multiple session/prompt RPCs to reach agents that support active-turn redirects. */ + readonly concurrentPrompts?: boolean; readonly mcpServers?: ReadonlyArray; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { @@ -293,9 +295,9 @@ export const make = ( const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); const promptSerializationSemaphore = yield* Semaphore.make(1); - const activePromptFiberRef = yield* Ref.make< - Option.Option> - >(Option.none()); + const activePromptFibersRef = yield* Ref.make< + ReadonlyArray> + >([]); const sessionLoadGateRef = yield* Ref.make>(Option.none()); const logRequest = (event: AcpSessionRequestLogEvent) => @@ -716,58 +718,60 @@ export const make = ( }), getModeState: Ref.get(modeStateRef), getConfigOptions: Ref.get(configOptionsRef), - prompt: (payload) => - promptSerializationSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const cancelledResponse = { - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse; - const promptRpcFiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - yield* Ref.set(activePromptFiberRef, Option.some(promptRpcFiber)); - return yield* Fiber.join(promptRpcFiber).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.succeed(cancelledResponse) - : Effect.failCause(cause), - ), - Effect.ensuring( - Effect.gen(function* () { - yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); - yield* Ref.set(activePromptFiberRef, Option.none()); - }), - ), - Effect.tap(() => - closeActiveAssistantSegment({ - queue: eventQueue, - assistantSegmentRef, - }), - ), - ); - }), - ), + prompt: (payload) => { + const runPrompt = Effect.gen(function* () { + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const cancelledResponse = { + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse; + const promptRpcFiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + yield* Ref.update(activePromptFibersRef, (current) => [...current, promptRpcFiber]); + return yield* Fiber.join(promptRpcFiber).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.succeed(cancelledResponse) + : Effect.failCause(cause), + ), + Effect.ensuring( + Effect.gen(function* () { + yield* Fiber.interrupt(promptRpcFiber).pipe(Effect.ignore); + yield* Ref.update(activePromptFibersRef, (current) => + current.filter((fiber) => fiber !== promptRpcFiber), + ); + }), + ), + Effect.tap(() => + closeActiveAssistantSegment({ + queue: eventQueue, + assistantSegmentRef, + }), + ), + ); + }); + return options.concurrentPrompts + ? runPrompt + : promptSerializationSemaphore.withPermit(runPrompt); + }, cancel: getStartedState.pipe( Effect.flatMap((started) => Effect.gen(function* () { - const activePromptFiber = yield* Ref.get(activePromptFiberRef); - if (Option.isSome(activePromptFiber)) { - yield* Fiber.interrupt(activePromptFiber.value).pipe(Effect.ignore); - } - yield* acp.agent - .cancel({ sessionId: started.sessionId }) - .pipe(Effect.ignore, Effect.forkIn(runtimeScope)); + const activePromptFibers = yield* Ref.get(activePromptFibersRef); + yield* Effect.forEach(activePromptFibers, Fiber.interrupt, { discard: true }).pipe( + Effect.ignore, + ); + yield* acp.agent.cancel({ sessionId: started.sessionId }).pipe(Effect.ignore); }), ), ), 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 000000000000..ed220bd43e8b --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { buildHermesAcpSpawnInput, resolveHermesModelId } from "./HermesAcpSupport.ts"; + +describe("Hermes ACP support", () => { + it("starts the official Hermes ACP server with the instance environment", () => { + expect( + buildHermesAcpSpawnInput({ binaryPath: "/opt/bin/hermes" }, "/workspace", { + HERMES_HOME: "/tmp/hermes", + }), + ).toEqual({ + command: "/opt/bin/hermes", + args: ["acp"], + cwd: "/workspace", + env: { HERMES_HOME: "/tmp/hermes" }, + }); + }); + + it("keeps Hermes' configured model for the default sentinel", () => { + expect(resolveHermesModelId("default")).toBeUndefined(); + expect(resolveHermesModelId(" openrouter:anthropic/claude-sonnet-4.6 ")).toBe( + "openrouter:anthropic/claude-sonnet-4.6", + ); + }); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 000000000000..e981787e9634 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,98 @@ +import { type HermesSettings } from "@t3tools/contracts"; +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 ChildProcess from "effect/unstable/process/ChildProcess"; +import * as EffectAcpErrors from "effect-acp/errors"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { spawnAndCollect } from "../providerSnapshot.ts"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +const HERMES_AUTH_METHOD = "hermes-setup"; + +type HermesAcpSettings = Pick; + +interface HermesAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "concurrentPrompts" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpSettings; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildHermesAcpSpawnInput( + settings: HermesAcpSettings, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + return { + command: settings.binaryPath || "hermes", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + authMethodId: HERMES_AUTH_METHOD, + concurrentPrompts: true, + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe(Effect.provide(context)); + }); + +export function resolveHermesModelId(model: string | null | undefined): string | undefined { + const value = model?.trim(); + return value && value !== "default" ? value : undefined; +} + +export function applyHermesAcpModelSelection(input: { + readonly runtime: Pick; + readonly model: string | null | undefined; + readonly mapError: (cause: EffectAcpErrors.AcpError) => E; +}): Effect.Effect { + const modelId = resolveHermesModelId(input.model); + return modelId + ? input.runtime.setSessionModel(modelId).pipe(Effect.mapError(input.mapError)) + : Effect.void; +} + +export const deleteHermesSession = Effect.fn("deleteHermesSession")(function* (input: { + readonly settings: HermesAcpSettings; + readonly sessionId: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const command = input.settings.binaryPath || "hermes"; + const spawnCommand = yield* resolveSpawnCommand( + command, + ["sessions", "delete", input.sessionId, "--yes"], + input.environment ? { env: input.environment } : {}, + ); + return yield* spawnAndCollect( + command, + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + ...(input.environment ? { env: input.environment } : {}), + shell: spawnCommand.shell, + }), + ); +}); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..3d727b97423f 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ 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 { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const resolvedModel = resolveHermesModelId(modelSelection.model); + const outputRef = yield* Ref.make(""); + const sessionIdRef = yield* Ref.make(undefined); + const probeEnvironment = { + ...environment, + HERMES_ACP_SKIP_CONFIGURED_MCP: "1", + }; + yield* Effect.addFinalizer(() => + Ref.get(sessionIdRef).pipe( + Effect.flatMap((sessionId) => + sessionId + ? deleteHermesSession({ + settings: hermesSettings, + sessionId, + environment: probeEnvironment, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), + Effect.ignore, + ) + : Effect.void, + ), + ), + ); + const runtime = yield* makeHermesAcpRuntime({ + hermesSettings, + environment: probeEnvironment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + const started = yield* runtime.start(); + yield* Ref.set(sessionIdRef, started.sessionId); + yield* applyHermesAcpModelSelection({ + runtime, + model: resolvedModel, + mapError: (cause) => + new TextGenerationError({ + operation, + detail: "Failed to set Hermes ACP base model for text generation.", + cause, + }), + }); + yield* runtime.setMode("default").pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: "Failed to configure Hermes approval mode for text generation.", + cause, + }), + ), + ); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(HERMES_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Hermes ACP request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause: EffectAcpErrors.AcpError | TextGenerationError) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Hermes ACP request failed.", + cause, + }), + ), + ); + + const trimmed = (yield* Ref.get(outputRef)).trim(); + if (!trimmed) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Hermes ACP request was cancelled." + : "Hermes Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(trimmed)).pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes Agent returned invalid structured output.", + cause, + }), + ), + }), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Hermes ACP text generation failed.", + cause, + }), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("HermesTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + policy: input.policy, + }); + + const generated = yield* runHermesJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("HermesTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + policy: input.policy, + changeRequestTemplate: input.changeRequestTemplate, + }); + + const generated = yield* runHermesJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("HermesTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("HermesTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + previousTitle: input.previousTitle, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies TextGeneration.ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGeneration.TextGeneration["Service"]; +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..0071f214076c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "hermes" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..7c924022a8d2 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -211,6 +211,25 @@ export const GrokIcon: Icon = ({ className, ...props }) => ( ); +export const HermesIcon: Icon = ({ className, ...props }) => ( + + {/* Snakes drawn under the staff so the staff reads as the front of the wrap */} + + + {/* Wings */} + + + {/* Staff */} + + {/* Knob */} + + +); + export const TraeIcon: Icon = (props) => ( {/* Back rectangle: left strip + bottom strip drawn separately — empty bottom-left corner is the gap between them */} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..58b420202d48 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, GrokIcon, HermesIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("hermes")]: HermesIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index a472c6a8d3d7..5a6f1f27288f 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -299,7 +299,7 @@ function formatProcessName(command: string): string { function formatProcessType(process: ServerProcessDiagnosticsEntry): string { if (process.depth > 0) return "Subprocess"; - if (/\b(codex|claude|opencode|cursor)\b/i.test(process.command)) return "Agent"; + if (/\b(codex|claude|opencode|cursor|grok|hermes)\b/i.test(process.command)) return "Agent"; return "Process"; } diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 9a42961d13ee..10d321a89cc2 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -34,6 +34,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial { + it("exposes Hermes as an early-access provider with binary and home settings", () => { + const hermes = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("hermes")]; + expect(hermes?.label).toBe("Hermes"); + expect(hermes?.badgeLabel).toBe("Early Access"); + expect(deriveProviderSettingsFields(hermes!).map((field) => field.key)).toEqual([ + "binaryPath", + "homePath", + ]); + }); + it("derives visible provider config fields from the client definition schema", () => { const codex = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("codex")]; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index bfee6a8d6807..900f27e68d52 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,20 @@ import { CodexSettings, CursorSettings, GrokSettings, + HermesSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { + ClaudeAI, + CursorIcon, + GrokIcon, + HermesIcon, + type Icon, + OpenAI, + OpenCodeIcon, +} from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + icon: HermesIcon, + badgeLabel: "Early Access", + settingsSchema: HermesSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..0727356648b5 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -36,6 +36,10 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Codex"; case "cursor": return "Cursor"; + case "grok": + return "Grok"; + case "hermes": + return "Hermes"; case "opencode": return "OpenCode"; default: { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index cd5e2aa11d74..06e0cfa207cf 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..e2027ff57599 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Hermes](./user/providers-hermes.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/internals/glossary.md b/docs/internals/glossary.md index da16f74d339f..e20a50f8e784 100644 --- a/docs/internals/glossary.md +++ b/docs/internals/glossary.md @@ -94,7 +94,7 @@ The live backend agent implementation and its event stream. The main service is #### Provider -The backend agent runtime that actually performs work. Five drivers ship built in: Codex, Claude, Cursor, Grok, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. +The backend agent runtime that actually performs work. Six drivers ship built in: Codex, Claude, Cursor, Grok, Hermes, and OpenCode. See [ProviderService.ts][14], [ProviderAdapter.ts][15], and [CodexAdapter.ts][17] as a representative adapter. #### Session diff --git a/docs/internals/overview.md b/docs/internals/overview.md index b9454f7b58d0..e064141ad3ab 100644 --- a/docs/internals/overview.md +++ b/docs/internals/overview.md @@ -106,8 +106,8 @@ build production behavior on receipts. ## Provider drivers -Five drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: -Codex, Claude, Cursor, Grok, and OpenCode. A driver declares its kind and config schema and creates a +Six drivers ship built in, registered in [`builtInDrivers.ts`][drivers] as `BUILT_IN_DRIVERS`: +Codex, Claude, Cursor, Grok, Hermes, and OpenCode. A driver declares its kind and config schema and creates a scoped adapter; `ProviderInstanceRegistry` owns live instances and `ProviderAdapterRegistry` resolves an instance to its adapter, so `ProviderService` routes session and turn operations without knowing which agent is behind them. See [providers.md](./providers.md). diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..8909713d66a5 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread. | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `hermes` | [`Drivers/HermesDriver.ts`][hermes] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an @@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[hermes]: ../../apps/server/src/provider/Drivers/HermesDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts diff --git a/docs/user/install.md b/docs/user/install.md index 96776c7ea1f1..cdc412fde807 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -54,13 +54,14 @@ yay -S t3code-nightly-bin T3 Code drives provider CLIs; it does not ship them. Install the CLI for each provider you want to use, then authenticate it. -| Provider | CLI | Default binary | Log in with | -| ---------- | ----------------------------------------------------- | -------------- | --------------------- | -| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | -| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | -| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | -| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | -| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | +| Provider | CLI | Default binary | Log in with | +| ---------- | ----------------------------------------------------- | -------------- | ---------------------- | +| Codex | [Codex CLI](https://developers.openai.com/codex/cli) | `codex` | `codex login` | +| Claude | [Claude Code](https://claude.com/product/claude-code) | `claude` | `claude auth login` | +| Cursor | [Cursor CLI](https://cursor.com/cli) | `cursor-agent` | `agent login` | +| Grok Build | [Grok Build CLI](https://x.ai/cli) | `grok` | `grok login` | +| Hermes | [Hermes Agent](https://hermes-agent.nousresearch.com) | `hermes` | `hermes setup --quick` | +| OpenCode | [OpenCode](https://opencode.ai) | `opencode` | `opencode auth login` | Cursor is the one to watch: install Cursor CLI, which provides the `cursor-agent` binary that T3 Code looks for, but authenticate with `agent login`, not `cursor-agent login`. @@ -82,7 +83,8 @@ T3 Code. You can install T3 Code, open it, and add providers afterwards. A provi authenticated shows its status in **Settings** and fails at session start with the login command to run. -For multi-account setups, see [Codex](./providers-codex.md) and [Claude](./providers-claude.md). +For multi-account setups, see [Codex](./providers-codex.md), [Claude](./providers-claude.md), and +[Hermes](./providers-hermes.md). ## Next Steps diff --git a/docs/user/providers-hermes.md b/docs/user/providers-hermes.md new file mode 100644 index 000000000000..a4800f74dcb1 --- /dev/null +++ b/docs/user/providers-hermes.md @@ -0,0 +1,56 @@ +# Hermes Agent + +Hermes support is available as an Early Access provider. T3 Code connects to the official Hermes +Agent Protocol server and uses Hermes' existing models, credentials, tools, skills, rules, and +session history. + +## Set up Hermes + +Install Hermes Agent using the instructions at +[hermes-agent.nousresearch.com](https://hermes-agent.nousresearch.com), then configure a model: + +```sh +hermes setup --quick +``` + +Restart or refresh T3 Code's provider status after setup. The Hermes card reports the detected CLI +version and offers the normal provider update action, which runs `hermes update`. Hermes Agent +0.20.0 or newer is required for the supported ACP behavior. + +## Multiple Hermes instances + +The built-in instance uses Hermes' normal home directory. Additional provider instances can set a +different **HERMES_HOME path** or supply `HERMES_HOME` in their environment. Instances that use the +same Hermes home can continue each other's sessions; different homes keep configuration, +credentials, and history isolated. + +Hermes reports its configured models directly to T3 Code. Custom entries must use the exact model +identifier expected by Hermes, normally `provider:model`. The `default` entry keeps the model +selected in Hermes. + +## Permissions and active turns + +T3 Code maps permission modes as follows: + +| T3 Code mode | Hermes mode | +| ----------------- | -------------- | +| Approval required | `default` | +| Auto | `default` | +| Auto-accept edits | `accept_edits` | +| Full access | `dont_ask` | + +Approval buttons use the choices returned by Hermes. Automatic approval prefers the session-scoped +choice and never silently creates a permanent grant. + +Plain-text messages sent while Hermes is working redirect the active turn. Images are sent through +Hermes' ACP image support. Hermes slash commands advertised by the running CLI appear in the +composer. Interactive sessions load both Hermes' configured MCP servers and T3 Code's bridge; +short-lived provider checks and source-control text generation skip configured MCP startup. + +T3 Code persists Hermes' ACP session ID for continuation. On resume, Hermes restores its own +history while T3 Code suppresses that replay because the conversation is already stored in the +thread. + +Hermes manages skill discovery itself. T3 Code does not duplicate Hermes skills in its own skill +picker, and Hermes ACP does not currently expose T3 Code's separate Plan interaction toggle or +structured question forms. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..7a1bfb04e7ea 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,7 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); +const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -152,6 +153,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", + [HERMES_DRIVER_KIND]: "Hermes", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..aebe3b43ff92 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; -import { ProviderInstanceId } from "./providerInstance.ts"; +import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER } from "./model.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, ClientSettingsPatch, @@ -16,6 +17,41 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("Hermes provider settings", () => { + it("auto-enables the built-in Hermes CLI with an isolated optional home", () => { + expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({ + enabled: true, + binaryPath: "hermes", + homePath: "", + customModels: [], + }); + }); + + it("uses the Hermes-configured model for interactive and metadata jobs", () => { + const hermes = ProviderDriverKind.make("hermes"); + expect(DEFAULT_MODEL_BY_PROVIDER[hermes]).toBe("default"); + expect(DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[hermes]).toBe("default"); + }); + + it("decodes Hermes legacy patches without disturbing other providers", () => { + expect( + decodeServerSettingsPatch({ + providers: { + hermes: { + binaryPath: " /opt/bin/hermes ", + homePath: " ~/.hermes-work ", + customModels: ["openrouter:anthropic/claude-sonnet-4.6"], + }, + }, + }).providers?.hermes, + ).toEqual({ + binaryPath: "/opt/bin/hermes", + homePath: "~/.hermes-work", + customModels: ["openrouter:anthropic/claude-sonnet-4.6"], + }); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..ea15383ff3b8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -425,6 +425,38 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const HermesSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("hermes").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Hermes Agent CLI binary.", + providerSettingsForm: { placeholder: "hermes", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "HERMES_HOME path", + description: "Custom Hermes configuration and session directory.", + providerSettingsForm: { placeholder: "~/.hermes", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "homePath"], + }, +); +export type HermesSettings = typeof HermesSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -604,6 +636,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -700,6 +733,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -747,6 +787,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ),