From 9ff2d8e8f6ae2d698d75707e04e46f809f4ba400 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:00:10 -0400 Subject: [PATCH 01/23] feat: add Hermes agent provider --- .../src/provider/Drivers/HermesDriver.ts | 175 ++++ .../src/provider/Layers/HermesAdapter.ts | 952 ++++++++++++++++++ .../src/provider/Layers/HermesProvider.ts | 387 +++++++ .../src/provider/Services/HermesAdapter.ts | 19 + .../src/provider/acp/HermesAcpSupport.ts | 54 + apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/HermesTextGeneration.ts | 256 +++++ packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.ts | 35 + 9 files changed, 1886 insertions(+) create mode 100644 apps/server/src/provider/Drivers/HermesDriver.ts create mode 100644 apps/server/src/provider/Layers/HermesAdapter.ts create mode 100644 apps/server/src/provider/Layers/HermesProvider.ts create mode 100644 apps/server/src/provider/Services/HermesAdapter.ts create mode 100644 apps/server/src/provider/acp/HermesAcpSupport.ts create mode 100644 apps/server/src/textGeneration/HermesTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..46bbca42da5 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,175 @@ +/** + * HermesDriver — `ProviderDriver` for the Hermes Agent (`hermes`) runtime. + * + * Hermes exposes an ACP-based CLI. The driver is still a plain value, but + * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` + * hook to run the slow ACP model-capability probe in the background without + * blocking the initial `ready`-state publish. + * + * Text generation is supported via the ACP runtime — `makeHermesTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/HermesDriver + */ +import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +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 * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.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 { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "hermes", + updateArgs: ["update"], + updateLockKey: "hermes-agent", + }), +); + +export type HermesDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +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 spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + 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(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialHermesProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + // Keep version-advisory enrichment off the initial provider snapshot + // so Hermes never blocks server startup or settings rendering. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichHermesSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + publishSnapshot, + stampIdentity, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Hermes snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 00000000000..82966dcb106 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,952 @@ +/** + * HermesAdapterLive — Hermes CLI (`hermes acp`) via ACP. + * + * @module HermesAdapterLive + */ + +import { + ApprovalRequestId, + type HermesSettings, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + 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 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 Random from "effect/Random"; +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 { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { makeHermesAcpRuntime } from "../acp/HermesAcpSupport.ts"; +import { type HermesAdapterShape } from "../Services/HermesAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +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?: typeof ProviderInstanceId.Type; + /** + * 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; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + 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() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntimeShape; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +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 nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + 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 nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + 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* Random.nextUUIDv4, + 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); + 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 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 acp = yield* makeHermesAcpRuntime({ + hermesSettings: effectiveHermesSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + 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 { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + resumeCursor: { + schemaVersion: HERMES_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + 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, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); + + 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 ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model ?? "hermes-default" }, + }); + + 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) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + 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, + }; + }); + + const interruptTurn: HermesAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + 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.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.ts b/apps/server/src/provider/Layers/HermesProvider.ts new file mode 100644 index 00000000000..9aa9772cd0c --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -0,0 +1,387 @@ +import type { + HermesSettings, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_PRESENTATION = { + displayName: "Hermes", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const HERMES_FALLBACK_MODEL: ServerProviderModel = { + slug: "hermes-default", + name: "Hermes Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; +const HERMES_DEFAULT_MODELS: ReadonlyArray = [HERMES_FALLBACK_MODEL]; +const ABOUT_TIMEOUT_MS = 4_000; + +export interface HermesConfigModelDefaults { + readonly defaultModel: string | null; +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +export function parseHermesConfigModelDefaults(raw: string): HermesConfigModelDefaults { + const lines = raw.replace(/\r\n?/g, "\n").split("\n"); + let inModelBlock = false; + let modelIndent = 0; + + for (const line of lines) { + const withoutComment = line.replace(/\s+#.*$/, ""); + if (withoutComment.trim().length === 0) continue; + + const indent = withoutComment.length - withoutComment.trimStart().length; + const trimmed = withoutComment.trim(); + const topLevelModelMatch = /^model\s*:\s*$/.exec(trimmed); + if (topLevelModelMatch && indent === 0) { + inModelBlock = true; + modelIndent = indent; + continue; + } + + if (inModelBlock && indent <= modelIndent) { + inModelBlock = false; + } + + if (!inModelBlock) continue; + const defaultMatch = /^default\s*:\s*(.+?)\s*$/.exec(trimmed); + if (!defaultMatch?.[1]) continue; + + const value = stripQuotes(defaultMatch[1]); + return { defaultModel: value.length > 0 ? value : null }; + } + + return { defaultModel: null }; +} + +function formatHermesModelName(slug: string): string { + return slug + .split(/[/-]/g) + .filter((segment) => segment.length > 0) + .map((segment) => { + if (/^gpt$/i.test(segment)) return "GPT"; + if (/^\d/.test(segment)) return segment; + return segment[0]!.toUpperCase() + segment.slice(1); + }) + .join(" "); +} + +function modelFromHermesDefault(defaultModel: string | null): ServerProviderModel { + const slug = defaultModel?.trim(); + if (!slug) return HERMES_FALLBACK_MODEL; + return { + slug, + name: formatHermesModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; +} + +function readHermesConfigModelDefaults( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.HERMES_HOME?.trim() || path.join(environment.HOME || NodeOS.homedir(), ".hermes"); + const raw = yield* fs + .readFileString(path.join(home, "config.yaml")) + .pipe(Effect.catch(() => Effect.succeed(null))); + return raw ? parseHermesConfigModelDefaults(raw) : { defaultModel: null }; + }); +} + +function getHermesModels( + hermesSettings: Pick, + defaults: HermesConfigModelDefaults, +): ReadonlyArray { + return providerModelsFromSettings( + [modelFromHermesDefault(defaults.defaultModel)], + PROVIDER, + hermesSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function getHermesFallbackModels( + hermesSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings( + HERMES_DEFAULT_MODELS, + PROVIDER, + hermesSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function buildInitialHermesProviderSnapshot( + hermesSettings: HermesSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getHermesFallbackModels(hermesSettings); + + 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 Agent availability...", + }, + }); + }); +} + +interface HermesAboutResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +function extractVersion(raw: string): string | null { + const plain = stripAnsi(raw); + const aboutMatch = /^CLI Version\s{2,}(.+)$/im.exec(plain); + if (aboutMatch?.[1]) return aboutMatch[1].trim(); + const versionMatch = /\b(?:hermes(?:-agent)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i.exec(plain); + return versionMatch?.[1]?.trim() ?? null; +} + +function parseHermesAboutOutput(result: CommandResult): HermesAboutResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lower = combined.toLowerCase(); + const version = extractVersion(combined); + + if (lower.includes("not logged in") || lower.includes("authentication required")) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Hermes Agent is not authenticated. Run `hermes setup` or `hermes model`.", + }; + } + + if (result.code === 0) { + return { + version, + status: "ready", + auth: { status: "unknown" }, + }; + } + + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes Agent is installed, but T3 Code could not verify its auth status.", + }; +} + +const runHermesCommand = ( + hermesSettings: HermesSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(hermesSettings.binaryPath, [...args], { + env: environment, + shell: process.platform === "win32", + }); + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +const runHermesAboutCommand = ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + runHermesCommand(hermesSettings, ["--version"], environment).pipe( + Effect.catch(() => runHermesCommand(hermesSettings, ["about"], environment)), + ); + +export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getHermesModels( + hermesSettings, + yield* readHermesConfigModelDefaults(environment), + ); + + 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 probe = yield* runHermesAboutCommand(hermesSettings, environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(probe)) { + const error = + probe.failure instanceof Error + ? probe.failure + : new Error(typeof probe.failure === "string" ? probe.failure : String(probe.failure)); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Hermes Agent CLI (`hermes`) is not installed or not on PATH." + : `Failed to execute Hermes Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(probe.success)) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes Agent CLI is installed but timed out during the health check.", + }, + }); + } + + const parsed = parseHermesAboutOutput(probe.success.value); + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: parsed.version, + status: parsed.status, + auth: parsed.auth, + ...(parsed.message ? { message: parsed.message } : {}), + }, + }); +}); + +export const enrichHermesSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const stampIdentity = input.stampIdentity ?? ((value) => value); + return enrichProviderSnapshotWithVersionAdvisory( + input.snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((snapshot) => input.publishSnapshot(stampIdentity(snapshot))), + Effect.catchCause((cause) => + Effect.logWarning("Hermes version advisory enrichment failed", { + cause: Cause.pretty(cause), + }), + ), + ); +}; diff --git a/apps/server/src/provider/Services/HermesAdapter.ts b/apps/server/src/provider/Services/HermesAdapter.ts new file mode 100644 index 00000000000..99003afa746 --- /dev/null +++ b/apps/server/src/provider/Services/HermesAdapter.ts @@ -0,0 +1,19 @@ +/** + * HermesAdapter — shape type for the Hermes provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/HermesDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module HermesAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * HermesAdapterShape — per-instance Hermes adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface HermesAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 00000000000..8c97360075e --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,54 @@ +import { type HermesSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +type HermesAcpRuntimeHermesSettings = Pick; + +export interface HermesAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildHermesAcpSpawnInput( + hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + return { + command: hermesSettings?.binaryPath || "hermes", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + authMethodId: "terminal_setup", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..902dbed5239 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,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 { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | HermesDriverEnv | OpenCodeDriverEnv; /** @@ -47,4 +49,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeHermesAcpRuntime({ + hermesSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + 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* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + + 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 Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapHermesAcpError(operation, "Hermes ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + 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(rawResult)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapHermesAcpError(operation, "Hermes ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "HermesTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runHermesJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["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, + }); + + const generated = yield* runHermesJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["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, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "HermesTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..0a581c1a63b 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 OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); export const DEFAULT_MODEL = "gpt-5.4"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; @@ -140,6 +141,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [OPENCODE_DRIVER_KIND]: "OpenCode", + [HERMES_DRIVER_KIND]: "Hermes", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..9224b703d4e 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,33 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const HermesSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("hermes").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Hermes Agent binary.", + providerSettingsForm: { + placeholder: "hermes", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type HermesSettings = typeof HermesSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -370,6 +397,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -445,6 +473,12 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -464,6 +498,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual From 82f37bdeff3c35e54f53803a96d47a5d37daa2a4 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:00:14 -0400 Subject: [PATCH 02/23] feat: surface Hermes in web provider UI --- .../components/KeybindingsToast.browser.tsx | 5 +++ apps/web/src/components/chat/ChatComposer.tsx | 7 ++-- .../components/chat/ModelPickerContent.tsx | 29 ++++++++------- .../settings/ProviderInstanceCard.tsx | 35 +++++++++++++++++-- .../components/settings/providerDriverMeta.ts | 8 +++++ apps/web/src/providerInstances.ts | 12 ++++--- 6 files changed, 73 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 611eaf572d0..a873d8d757b 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -130,6 +130,11 @@ function createBaseServerConfig(): ServerConfig { serverPassword: "", customModels: [], }, + hermes: { + enabled: false, + binaryPath: "", + customModels: [], + }, }, }, }; diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 96fc34c1013..09f0a74c247 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -98,6 +98,7 @@ import { proposedPlanTitle } from "../../proposedPlan"; import { getProviderInteractionModeToggle } from "../../providerModels"; import { deriveProviderInstanceEntries, + isSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, @@ -649,7 +650,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) for (const candidate of candidates) { if (!candidate) continue; const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && isSelectableProviderInstance(entry), ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -669,12 +670,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const byKind = providerInstanceEntries.find( (entry) => - entry.enabled && + isSelectableProviderInstance(entry) && entry.driverKind === selectedProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const anyEnabled = providerInstanceEntries.find(isSelectableProviderInstance); return ( anyEnabled?.instanceId ?? providerInstanceEntries[0]?.instanceId ?? diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c65..4482328756f 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -21,7 +21,7 @@ import { import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { isSelectableProviderInstance, type ProviderInstanceEntry } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { @@ -166,15 +166,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [props.lockedContinuationGroupKey, props.lockedProvider], ); - const readyInstanceSet = useMemo(() => { - const ready = new Set(); + const selectableInstanceSet = useMemo(() => { + const selectable = new Set(); for (const entry of instanceEntries) { - if (entry.status === "ready") { - ready.add(entry.instanceId); + const models = modelOptionsByInstance.get(entry.instanceId) ?? []; + if (isSelectableProviderInstance(entry) && models.length > 0) { + selectable.add(entry.instanceId); } } - return ready; - }, [instanceEntries]); + return selectable; + }, [instanceEntries, modelOptionsByInstance]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -189,7 +190,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // its models — stale options shouldn't appear in the picker. continue; } - if (!readyInstanceSet.has(instanceId)) { + if (!selectableInstanceSet.has(instanceId)) { continue; } for (const model of models) { @@ -209,20 +210,24 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); + }, [modelOptionsByInstance, entryByInstanceId, selectableInstanceSet]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; const lockedInstanceEntries = useMemo( () => - props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], - [instanceEntries, matchesLockedProvider, props.lockedProvider], + props.lockedProvider + ? instanceEntries.filter( + (entry) => selectableInstanceSet.has(entry.instanceId) && matchesLockedProvider(entry), + ) + : [], + [instanceEntries, matchesLockedProvider, props.lockedProvider, selectableInstanceSet], ); const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); const sidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries - : instanceEntries; + : instanceEntries.filter((entry) => selectableInstanceSet.has(entry.instanceId)); const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 430ec3637e0..af40c711181 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -10,7 +10,7 @@ import { Trash2Icon, XIcon, } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -60,6 +60,13 @@ const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; +function isInteractiveEventTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest("a,button,input,select,textarea,[role='switch']") !== null + ); +} + type EnvironmentDraftRow = { readonly id: string; readonly name: string; @@ -573,6 +580,20 @@ export function ProviderInstanceCard({ ); }; + const toggleExpanded = () => onExpandedChange(!isExpanded); + + const handleHeaderClick = (event: MouseEvent) => { + if (isInteractiveEventTarget(event.target)) return; + toggleExpanded(); + }; + + const handleHeaderKeyDown = (event: KeyboardEvent) => { + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleExpanded(); + }; + const titleIconNode = driverKind ? ( -
+
@@ -782,7 +811,7 @@ export function ProviderInstanceCard({ size="sm" variant="ghost" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" - onClick={() => onExpandedChange(!isExpanded)} + onClick={toggleExpanded} aria-label={`Toggle ${displayName} details`} > 0; +} + /** * Resolve an entry's displayName with a tiered priority: * @@ -217,16 +221,14 @@ export function resolveSelectableProviderInstance( instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { if (instanceId === undefined) { - return deriveProviderInstanceEntries(providers).find( - (entry) => entry.enabled && entry.isAvailable, - )?.instanceId; + return deriveProviderInstanceEntries(providers).find(isSelectableProviderInstance)?.instanceId; } const entries = deriveProviderInstanceEntries(providers); const requested = entries.find((entry) => entry.instanceId === instanceId); - if (requested && requested.enabled && requested.isAvailable) { + if (requested && isSelectableProviderInstance(requested)) { return instanceId; } - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return entries.find(isSelectableProviderInstance)?.instanceId; } /** From fbc1dd66c61ea24cf9a92cf817f5c95d7a7dce74 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:00:18 -0400 Subject: [PATCH 03/23] fix: stabilize local dev server restarts --- apps/server/package.json | 2 +- apps/server/scripts/dev-watch.mjs | 94 +++++++++++++++++++++++++++++++ apps/web/vite.config.ts | 9 +-- 3 files changed, 100 insertions(+), 5 deletions(-) create mode 100644 apps/server/scripts/dev-watch.mjs diff --git a/apps/server/package.json b/apps/server/package.json index 387c880a9ab..2d82f8ef75f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,7 +15,7 @@ ], "type": "module", "scripts": { - "dev": "node --watch src/bin.ts", + "dev": "node scripts/dev-watch.mjs", "build": "node scripts/cli.ts build", "build:bundle": "tsdown", "start": "node dist/bin.mjs", diff --git a/apps/server/scripts/dev-watch.mjs b/apps/server/scripts/dev-watch.mjs new file mode 100644 index 00000000000..afd28bb3cb7 --- /dev/null +++ b/apps/server/scripts/dev-watch.mjs @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { watch } from "node:fs"; +import { stat } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const serverDir = resolve(scriptsDir, ".."); +const srcDir = resolve(serverDir, "src"); +const entry = resolve(srcDir, "bin.ts"); +const sourceExtensions = new Set([".cjs", ".cts", ".js", ".json", ".mjs", ".mts", ".ts"]); + +let child; +let restartTimer; +let stopping = false; + +function start() { + child = spawn(process.execPath, [entry], { + stdio: "inherit", + env: process.env, + cwd: serverDir, + }); + + child.on("exit", (code, signal) => { + if (stopping || restartTimer) { + return; + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +async function shouldRestart(filename) { + if (!filename || typeof filename !== "string") { + return false; + } + + const changedPath = resolve(srcDir, filename); + if (!changedPath.startsWith(`${srcDir}/`)) { + return false; + } + + try { + const changedStat = await stat(changedPath); + if (changedStat.isDirectory()) { + return false; + } + } catch { + // Deleted source files still need a restart. + } + + return sourceExtensions.has(extname(changedPath)); +} + +function scheduleRestart() { + clearTimeout(restartTimer); + restartTimer = setTimeout(() => { + restartTimer = undefined; + console.log("Restarting server..."); + + const previous = child; + previous.once("exit", () => { + if (!stopping) { + start(); + } + }); + previous.kill("SIGTERM"); + }, 100); +} + +const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => { + if (await shouldRestart(filename)) { + scheduleRestart(); + } +}); + +function shutdown(signal) { + stopping = true; + clearTimeout(restartTimer); + watcher.close(); + if (!child || child.killed) { + process.exit(0); + } + child.once("exit", () => process.exit(0)); + child.kill(signal); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +start(); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 38819e28d73..b3bed23bfa5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; @@ -32,13 +33,13 @@ const buildSourcemap = ? "hidden" : true; -function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { - if (!wsUrl) { +function resolveDevProxyTarget(inputUrl: string | undefined): string | undefined { + if (!inputUrl) { return undefined; } try { - const url = new URL(wsUrl); + const url = new URL(inputUrl); if (url.protocol === "ws:") { url.protocol = "http:"; } else if (url.protocol === "wss:") { @@ -53,7 +54,7 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { } } -const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget(configuredHttpUrl) ?? resolveDevProxyTarget(configuredWsUrl); export default defineConfig({ plugins: [ From 4fb4c166fd1595b516734c8a0bbde133ead020f1 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:00:23 -0400 Subject: [PATCH 04/23] test: cover Hermes provider integration --- .../provider/Layers/HermesProvider.test.ts | 125 ++++++++++++++++++ .../src/provider/acp/HermesAcpSupport.test.ts | 32 +++++ .../chat/ProviderModelPicker.browser.tsx | 75 +++++++++++ .../settings/SettingsPanels.browser.tsx | 51 +++++++ docs/providers/hermes.md | 78 +++++++++++ 5 files changed, 361 insertions(+) create mode 100644 apps/server/src/provider/Layers/HermesProvider.test.ts create mode 100644 apps/server/src/provider/acp/HermesAcpSupport.test.ts create mode 100644 docs/providers/hermes.md 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 00000000000..199a09580ef --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.test.ts @@ -0,0 +1,125 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { HermesSettings } from "@t3tools/contracts"; +import { + checkHermesProviderStatus, + getHermesFallbackModels, + parseHermesConfigModelDefaults, +} from "./HermesProvider.ts"; + +const encoder = new TextEncoder(); +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout?: string; stderr?: string; code?: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); + }), + ); +} + +const makeHermesSettings = (overrides?: Partial): HermesSettings => + decodeHermesSettings({ + enabled: true, + binaryPath: "hermes", + customModels: [], + ...overrides, + }); + +describe("parseHermesConfigModelDefaults", () => { + it("reads the model.default value from Hermes config.yaml", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +model: + provider: openai-codex + base_url: https://chatgpt.com/backend-api/codex + default: gpt-5.5 +`), + { defaultModel: "gpt-5.5" }, + ); + }); + + it("ignores unrelated default keys outside the model block", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +default: wrong +model: + provider: openai-codex +tools: + default: also-wrong +`), + { defaultModel: null }, + ); + }); +}); + +describe("getHermesFallbackModels", () => { + it("keeps a built-in fallback model plus configured custom models", () => { + const models = getHermesFallbackModels( + makeHermesSettings({ customModels: ["nous/hermes-4", "hermes-default"] }), + ); + + assert.deepEqual( + models.map((model) => [model.slug, model.name, model.isCustom]), + [ + ["hermes-default", "Hermes Default", false], + ["nous/hermes-4", "nous/hermes-4", true], + ], + ); + }); +}); + +describe("checkHermesProviderStatus", () => { + it.effect("reports ready status and parses --version output", () => + Effect.gen(function* () { + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((_command, args) => { + assert.deepEqual(args, ["--version"]); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }), + ); + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: "/tmp/no-hermes-config", + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "0.11.0"); + assert.equal(snapshot.models[0]?.slug, "hermes-default"); + }), + ); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.test.ts b/apps/server/src/provider/acp/HermesAcpSupport.test.ts new file mode 100644 index 00000000000..0d9ad370705 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { buildHermesAcpSpawnInput } from "./HermesAcpSupport.ts"; + +describe("buildHermesAcpSpawnInput", () => { + it("builds the default Hermes ACP command", () => { + expect(buildHermesAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "hermes", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("uses the configured Hermes binary path and environment", () => { + const env = { HOME: "/tmp/hermes-home", PATH: "/usr/bin" }; + + expect( + buildHermesAcpSpawnInput( + { + binaryPath: "/Users/me/.local/bin/hermes", + }, + "/tmp/project", + env, + ), + ).toEqual({ + command: "/Users/me/.local/bin/hermes", + args: ["acp"], + cwd: "/tmp/project", + env, + }); + }); +}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index d3b168876a6..84b031cba17 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -247,6 +247,31 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider }; } +function buildHermesProvider(overrides: Partial = {}): ServerProvider { + return { + driver: ProviderDriverKind.make("hermes"), + instanceId: ProviderInstanceId.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: new Date().toISOString(), + models: [ + { + slug: "gpt-5.5", + name: "GPT 5.5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + ...overrides, + }; +} + async function mountPicker(props: { activeInstanceId?: ProviderInstanceId; model: string; @@ -1205,6 +1230,56 @@ describe("ProviderModelPicker", () => { } }); + it("does not offer a disabled Hermes provider as an empty model source", async () => { + const providers = [ + ...TEST_PROVIDERS, + buildHermesProvider({ + enabled: false, + status: "disabled", + models: [], + }), + ]; + const mounted = await mountPicker({ + model: "gpt-5-codex", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("GPT-5 Codex"); + }); + + expect(document.querySelector('[data-model-picker-provider="hermes"]')).toBeNull(); + expect(document.body.textContent ?? "").not.toContain("No models found"); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the configured Hermes model when Hermes is ready", async () => { + const mounted = await mountPicker({ + activeInstanceId: ProviderInstanceId.make("hermes"), + model: "gpt-5.5", + lockedProvider: null, + providers: [...TEST_PROVIDERS, buildHermesProvider()], + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("button", { name: "Hermes", exact: true }).click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("GPT 5.5"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ model: "gpt-5-codex", diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 34f24006edc..811e330eee2 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1142,6 +1142,57 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); }); + it("expands Hermes provider settings when the Hermes row is clicked", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + { + instanceId: ProviderInstanceId.make("hermes"), + driver: ProviderDriverKind.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: new Date().toISOString(), + models: [], + slashCommands: [], + skills: [], + }, + ], + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("hermes")]: { + driver: ProviderDriverKind.make("hermes"), + enabled: true, + config: { + enabled: true, + binaryPath: "/Users/me/.local/bin/hermes", + customModels: [], + }, + }, + }, + }, + }); + + mounted = await render( + + + , + ); + + await page.getByRole("button", { name: "Expand Hermes provider details" }).click(); + + await vi.waitFor(() => { + const input = Array.from(document.querySelectorAll("input")).find( + (element) => element.value === "/Users/me/.local/bin/hermes", + ); + expect(input).toBeTruthy(); + }); + }); + it("runs one-click provider updates from the provider card", async () => { const updateProvider = vi.fn().mockResolvedValue({ providers: [createOutdatedProvider("codex")], diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md new file mode 100644 index 00000000000..4b8e2d2754f --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,78 @@ +# Hermes + +This guide is for people who want to use Hermes Agent from T3 Code. + +Hermes runs as a local CLI process through ACP. T3 Code only starts `hermes acp` when a Hermes +conversation needs it, so provider refreshes stay fast. + +## Install Hermes + +Install and configure Hermes Agent from the upstream project: + +```bash +git clone https://github.com/nousresearch/hermes-agent.git ~/Projects/hermes-agent +cd ~/Projects/hermes-agent +python3 -m venv venv +./venv/bin/pip install -e . +``` + +Create a stable binary path that GUI apps can find: + +```bash +mkdir -p ~/.local/bin +ln -sf ~/Projects/hermes-agent/venv/bin/hermes ~/.local/bin/hermes +~/.local/bin/hermes --version +``` + +Then run Hermes setup: + +```bash +~/.local/bin/hermes model +``` + +Hermes stores its model configuration in: + +```text +~/.hermes/config.yaml +``` + +T3 Code reads `model.default` from that file and shows it in the model picker. If the config file is +missing or unreadable, T3 Code falls back to `Hermes Default`. + +## Configure T3 Code + +In Settings, enable Hermes and set: + +```text +Binary path: /Users/you/.local/bin/hermes +``` + +Using the full path is recommended on macOS because apps launched from the Dock or a desktop shell +may not inherit your terminal `PATH`. + +## Verify + +Run: + +```bash +/Users/you/.local/bin/hermes --version +/Users/you/.local/bin/hermes acp +``` + +The ACP command should stay running and print a startup message. Stop it with `Ctrl-C`. + +In T3 Code, select Hermes in the model picker and send a small prompt. If Hermes has a configured +default model, the picker should show that model name rather than only `Hermes Default`. + +## Troubleshooting + +If T3 Code says Hermes is not installed, use an absolute binary path instead of `hermes`. + +If the model picker says no models were found, refresh provider status and confirm that Hermes is +enabled. Disabled, missing, or not-ready providers are not treated as selectable model sources. + +If Hermes asks for auth or model setup, run: + +```bash +~/.local/bin/hermes model +``` From 2a3383506fad1e3e7bbbb85d3eed7571bdb0713b Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:19:07 -0400 Subject: [PATCH 05/23] docs: mention Hermes support in README --- README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c439743cea5..f04fc3809ca 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,19 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon). +T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, OpenCode, and Hermes, more coming soon). ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, and OpenCode. +> T3 Code currently supports Codex, Claude, OpenCode, and Hermes. > Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - Hermes: install [Hermes Agent](https://github.com/nousresearch/hermes-agent) and run `hermes model` + +Hermes setup notes: [docs/providers/hermes.md](./docs/providers/hermes.md) ### Run without installing From e518bddf5d49ddc19ca90e1c2a8b160b30bfee42 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 02:38:25 -0400 Subject: [PATCH 06/23] feat: smooth Hermes setup flow --- .../provider/Layers/HermesProvider.test.ts | 139 +++++++++- .../src/provider/Layers/HermesProvider.ts | 255 +++++++++++++----- .../settings/ProviderInstanceCard.tsx | 94 +++++++ .../settings/SettingsPanels.browser.tsx | 10 + docs/providers/hermes.md | 6 + packages/contracts/src/server.ts | 1 + 6 files changed, 434 insertions(+), 71 deletions(-) diff --git a/apps/server/src/provider/Layers/HermesProvider.test.ts b/apps/server/src/provider/Layers/HermesProvider.test.ts index 199a09580ef..d1ee096b20e 100644 --- a/apps/server/src/provider/Layers/HermesProvider.test.ts +++ b/apps/server/src/provider/Layers/HermesProvider.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -12,6 +14,7 @@ import { checkHermesProviderStatus, getHermesFallbackModels, parseHermesConfigModelDefaults, + resolveHermesBinary, } from "./HermesProvider.ts"; const encoder = new TextEncoder(); @@ -59,6 +62,19 @@ const makeHermesSettings = (overrides?: Partial): HermesSettings ...overrides, }); +const makeTempHome = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-test-" }); +}); + +const writeTestFile = (filePath: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + }); + describe("parseHermesConfigModelDefaults", () => { it("reads the model.default value from Hermes config.yaml", () => { assert.deepEqual( @@ -68,7 +84,7 @@ model: base_url: https://chatgpt.com/backend-api/codex default: gpt-5.5 `), - { defaultModel: "gpt-5.5" }, + { defaultModel: "gpt-5.5", malformed: false }, ); }); @@ -81,7 +97,17 @@ model: tools: default: also-wrong `), - { defaultModel: null }, + { defaultModel: null, malformed: true }, + ); + }); + + it("parses quoted model defaults and strips inline comments", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +model: + default: "gpt-5.5" # selected in hermes model +`), + { defaultModel: "gpt-5.5", malformed: false }, ); }); }); @@ -107,7 +133,11 @@ describe("checkHermesProviderStatus", () => { Effect.gen(function* () { const layer = Layer.merge( NodeServices.layer, - mockSpawnerLayer((_command, args) => { + mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") { + assert.equal(command, "/bin/zsh"); + return { stdout: "", code: 1 }; + } assert.deepEqual(args, ["--version"]); return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; }), @@ -122,4 +152,107 @@ describe("checkHermesProviderStatus", () => { assert.equal(snapshot.models[0]?.slug, "hermes-default"); }), ); + + it.effect("uses the detected common macOS binary path for the default hermes command", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + + const resolution = yield* resolveHermesBinary(makeHermesSettings(), { + HOME: home, + }); + + assert.equal(resolution.binaryPath, binaryPath); + assert.equal(resolution.suggestedBinaryPath, binaryPath); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("suggests a detected path when an explicit binary path is invalid", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings({ binaryPath: path.join(home, "missing/hermes") }), + { HOME: home }, + ); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.suggestedBinaryPath, binaryPath); + assert.match(snapshot.message ?? "", /Detected Hermes/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("shows the configured Hermes model from config.yaml", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + yield* writeTestFile( + path.join(home, ".hermes/config.yaml"), + ` +model: + provider: openai-codex + default: gpt-5.5 +`, + ); + const layer = mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + assert.equal(command, "hermes"); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: home, + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.models[0]?.slug, "gpt-5.5"); + assert.equal(snapshot.models[0]?.name, "GPT 5.5"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("guides setup when Hermes config has no model.default", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + yield* writeTestFile( + path.join(home, ".hermes/config.yaml"), + "model:\n provider: openai-codex\n", + ); + const layer = mockSpawnerLayer((_command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: home, + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.match(snapshot.message ?? "", /hermes model/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("surfaces stderr when the Hermes CLI health check exits nonzero", () => + Effect.gen(function* () { + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((_command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + return { stderr: "ACP failed to start: missing provider credentials", code: 2 }; + }), + ); + + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: "/tmp/no-hermes-config", + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "warning"); + assert.match(snapshot.message ?? "", /ACP failed to start/); + }), + ); }); diff --git a/apps/server/src/provider/Layers/HermesProvider.ts b/apps/server/src/provider/Layers/HermesProvider.ts index 9aa9772cd0c..17290173596 100644 --- a/apps/server/src/provider/Layers/HermesProvider.ts +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -46,9 +46,16 @@ const HERMES_FALLBACK_MODEL: ServerProviderModel = { }; const HERMES_DEFAULT_MODELS: ReadonlyArray = [HERMES_FALLBACK_MODEL]; const ABOUT_TIMEOUT_MS = 4_000; +const LOGIN_SHELL_TIMEOUT_MS = 2_000; export interface HermesConfigModelDefaults { readonly defaultModel: string | null; + readonly malformed: boolean; +} + +export interface HermesBinaryResolution { + readonly binaryPath: string; + readonly suggestedBinaryPath: string | null; } function stripQuotes(value: string): string { @@ -66,6 +73,7 @@ export function parseHermesConfigModelDefaults(raw: string): HermesConfigModelDe const lines = raw.replace(/\r\n?/g, "\n").split("\n"); let inModelBlock = false; let modelIndent = 0; + let sawModelBlock = false; for (const line of lines) { const withoutComment = line.replace(/\s+#.*$/, ""); @@ -76,6 +84,7 @@ export function parseHermesConfigModelDefaults(raw: string): HermesConfigModelDe const topLevelModelMatch = /^model\s*:\s*$/.exec(trimmed); if (topLevelModelMatch && indent === 0) { inModelBlock = true; + sawModelBlock = true; modelIndent = indent; continue; } @@ -89,10 +98,10 @@ export function parseHermesConfigModelDefaults(raw: string): HermesConfigModelDe if (!defaultMatch?.[1]) continue; const value = stripQuotes(defaultMatch[1]); - return { defaultModel: value.length > 0 ? value : null }; + return { defaultModel: value.length > 0 ? value : null, malformed: false }; } - return { defaultModel: null }; + return { defaultModel: null, malformed: sawModelBlock }; } function formatHermesModelName(slug: string): string { @@ -129,7 +138,90 @@ function readHermesConfigModelDefaults( const raw = yield* fs .readFileString(path.join(home, "config.yaml")) .pipe(Effect.catch(() => Effect.succeed(null))); - return raw ? parseHermesConfigModelDefaults(raw) : { defaultModel: null }; + return raw ? parseHermesConfigModelDefaults(raw) : { defaultModel: null, malformed: false }; + }); +} + +function isExplicitBinaryPath(binaryPath: string): boolean { + return binaryPath.includes("/") || binaryPath.includes("\\") || binaryPath.startsWith("~"); +} + +function commonHermesBinaryCandidates( + environment: NodeJS.ProcessEnv, +): Effect.Effect, never, Path.Path> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const home = environment.HOME || NodeOS.homedir(); + return [ + path.join(home, ".local/bin/hermes"), + path.join(home, "Projects/hermes-agent/venv/bin/hermes"), + "/opt/homebrew/bin/hermes", + "/usr/local/bin/hermes", + ]; + }); +} + +function expandHomePath(input: string, environment: NodeJS.ProcessEnv): string { + const home = environment.HOME || NodeOS.homedir(); + if (input === "~") return home; + if (input.startsWith("~/") || input.startsWith("~\\")) return `${home}${input.slice(1)}`; + return input; +} + +function firstExistingPath( + candidates: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const candidate of candidates) { + const exists = yield* fs.exists(candidate).pipe(Effect.catch(() => Effect.succeed(false))); + if (exists) return candidate; + } + return null; + }); +} + +function detectHermesFromLoginShell( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + const shell = environment.SHELL?.trim() || "/bin/zsh"; + return runRawCommand(shell, ["-lc", "command -v hermes"], environment).pipe( + Effect.timeoutOption(LOGIN_SHELL_TIMEOUT_MS), + Effect.map((result) => { + if (Option.isNone(result) || result.value.code !== 0) return null; + const candidate = result.value.stdout.trim().split(/\r?\n/u)[0]?.trim(); + return candidate && candidate.length > 0 ? candidate : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); +} + +export function resolveHermesBinary( + hermesSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect< + HermesBinaryResolution, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const configured = hermesSettings.binaryPath.trim() || "hermes"; + const explicitConfigured = isExplicitBinaryPath(configured); + const expandedConfigured = explicitConfigured + ? expandHomePath(configured, environment) + : configured; + const candidates = yield* commonHermesBinaryCandidates(environment); + const detected = + (yield* firstExistingPath(candidates)) ?? (yield* detectHermesFromLoginShell(environment)); + + if (!explicitConfigured && configured === "hermes" && detected) { + return { binaryPath: detected, suggestedBinaryPath: detected }; + } + + return { + binaryPath: expandedConfigured, + suggestedBinaryPath: detected && detected !== expandedConfigured ? detected : null, + }; }); } @@ -218,6 +310,7 @@ function parseHermesAboutOutput(result: CommandResult): HermesAboutResult { const combined = `${result.stdout}\n${result.stderr}`; const lower = combined.toLowerCase(); const version = extractVersion(combined); + const detail = stripAnsi(combined).trim(); if (lower.includes("not logged in") || lower.includes("authentication required")) { return { @@ -240,18 +333,20 @@ function parseHermesAboutOutput(result: CommandResult): HermesAboutResult { version, status: "warning", auth: { status: "unknown" }, - message: "Hermes Agent is installed, but T3 Code could not verify its auth status.", + message: detail + ? `Hermes Agent CLI health check exited with code ${result.code}: ${detail}` + : "Hermes Agent is installed, but T3 Code could not verify its auth status.", }; } -const runHermesCommand = ( - hermesSettings: HermesSettings, +const runRawCommand = ( + binaryPath: string, args: ReadonlyArray, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const command = ChildProcess.make(hermesSettings.binaryPath, [...args], { + const command = ChildProcess.make(binaryPath, [...args], { env: environment, shell: process.platform === "win32", }); @@ -267,12 +362,15 @@ const runHermesCommand = ( return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runHermesAboutCommand = ( - hermesSettings: HermesSettings, +const runHermesCommand = ( + binaryPath: string, + args: ReadonlyArray, environment: NodeJS.ProcessEnv = process.env, -) => - runHermesCommand(hermesSettings, ["--version"], environment).pipe( - Effect.catch(() => runHermesCommand(hermesSettings, ["about"], environment)), +) => runRawCommand(binaryPath, args, environment); + +const runHermesAboutCommand = (binaryPath: string, environment: NodeJS.ProcessEnv = process.env) => + runHermesCommand(binaryPath, ["--version"], environment).pipe( + Effect.catch(() => runHermesCommand(binaryPath, ["about"], environment)), ); export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( @@ -284,28 +382,35 @@ export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")( ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = getHermesModels( - hermesSettings, - yield* readHermesConfigModelDefaults(environment), - ); + const configDefaults = yield* readHermesConfigModelDefaults(environment); + const models = getHermesModels(hermesSettings, configDefaults); + const binaryResolution = yield* resolveHermesBinary(hermesSettings, environment); + const withHermesHints = (snapshot: ServerProviderDraft): ServerProviderDraft => ({ + ...snapshot, + ...(binaryResolution.suggestedBinaryPath + ? { suggestedBinaryPath: binaryResolution.suggestedBinaryPath } + : {}), + }); 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.", - }, - }); + return withHermesHints( + 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.", + }, + }), + ); } - const probe = yield* runHermesAboutCommand(hermesSettings, environment).pipe( + const probe = yield* runHermesAboutCommand(binaryResolution.binaryPath, environment).pipe( Effect.timeoutOption(ABOUT_TIMEOUT_MS), Effect.result, ); @@ -315,53 +420,67 @@ export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")( probe.failure instanceof Error ? probe.failure : new Error(typeof probe.failure === "string" ? probe.failure : String(probe.failure)); - return buildServerProvider({ - presentation: HERMES_PRESENTATION, - enabled: true, - checkedAt, - models: fallbackModels, - probe: { - installed: !isCommandMissingCause(error), - version: null, - status: "error", - auth: { status: "unknown" }, - message: isCommandMissingCause(error) - ? "Hermes Agent CLI (`hermes`) is not installed or not on PATH." - : `Failed to execute Hermes Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, - }, - }); + const isMissing = isCommandMissingCause(error); + const missingMessage = binaryResolution.suggestedBinaryPath + ? `Hermes Agent CLI was not found at \`${binaryResolution.binaryPath}\`. Detected Hermes at \`${binaryResolution.suggestedBinaryPath}\`; use the detected path or update Binary path.` + : "Hermes Agent CLI (`hermes`) is not installed or not on PATH."; + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !isMissing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: isMissing + ? missingMessage + : `Failed to execute Hermes Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }), + ); } if (Option.isNone(probe.success)) { - return buildServerProvider({ + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes Agent CLI is installed but timed out during the health check.", + }, + }), + ); + } + + const parsed = parseHermesAboutOutput(probe.success.value); + const configMessage = + parsed.status === "ready" && configDefaults.malformed + ? "Hermes Agent is ready, but `~/.hermes/config.yaml` has no `model.default`; run `hermes model` to choose the model T3 Code should display." + : parsed.message; + return withHermesHints( + buildServerProvider({ presentation: HERMES_PRESENTATION, enabled: true, checkedAt, - models: fallbackModels, + models, probe: { installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Hermes Agent CLI is installed but timed out during the health check.", + version: parsed.version, + status: parsed.status, + auth: parsed.auth, + ...(configMessage ? { message: configMessage } : {}), }, - }); - } - - const parsed = parseHermesAboutOutput(probe.success.value); - return buildServerProvider({ - presentation: HERMES_PRESENTATION, - enabled: true, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: parsed.version, - status: parsed.status, - auth: parsed.auth, - ...(parsed.message ? { message: parsed.message } : {}), - }, - }); + }), + ); }); export const enrichHermesSnapshot = (input: { diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index af40c711181..bcdd6231ede 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -397,6 +397,39 @@ function ProviderEnvironmentSection(props: { ); } +function ProviderSetupCommandRow(props: { + readonly command: string; + readonly label: string; + readonly onCopy: (command: string, label: string) => void; +}) { + return ( +
+ + + {props.command} + + + + props.onCopy(props.command, props.label)} + aria-label={`Copy ${props.label}`} + > + + + } + /> + Copy command + +
+ ); +} + interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -491,6 +524,8 @@ export function ProviderInstanceCard({ const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); const updateCommand = versionAdvisory?.updateCommand ?? null; + const suggestedBinaryPath = liveProvider?.suggestedBinaryPath?.trim(); + const isHermesDriver = String(instance.driver) === "hermes"; const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); @@ -570,6 +605,12 @@ export function ProviderInstanceCard({ onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }; + const applySuggestedBinaryPath = (binaryPath: string) => { + const nextConfig = nextConfigBlobWithValue(instance.config, "binaryPath", binaryPath); + const { config: _omit, ...rest } = instance; + onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); + }; + const updateEnvironment = (environment: ReadonlyArray) => { const cleaned = environment.filter((variable) => variable.name.trim().length > 0); const { environment: _omit, ...rest } = instance; @@ -691,6 +732,57 @@ export function ProviderInstanceCard({ {versionLabel} ) : null; + const hermesSetupNode = isHermesDriver ? ( +
+
+
+ Hermes setup +

+ T3 Code starts Hermes through ACP. Configure Hermes once, then verify the ACP command + before sending a turn. +

+
+ {suggestedBinaryPath ? ( +
+ + Detected Hermes at {suggestedBinaryPath} + + +
+ ) : null} +
+ copyToClipboard(command, { providerName: label })} + /> + copyToClipboard(command, { providerName: label })} + /> +
+ + Hermes setup docs + +
+
+ ) : null; + return (
+ {hermesSetupNode} + {driverOption ? ( { status: "ready", auth: { status: "unknown" }, checkedAt: new Date().toISOString(), + suggestedBinaryPath: "/opt/homebrew/bin/hermes", models: [], slashCommands: [], skills: [], @@ -1185,6 +1186,15 @@ describe("GeneralSettingsPanel observability", () => { await page.getByRole("button", { name: "Expand Hermes provider details" }).click(); + await expect.element(page.getByText("Hermes setup", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("hermes model")).toBeInTheDocument(); + await expect.element(page.getByText("hermes acp")).toBeInTheDocument(); + await expect.element(page.getByText("Hermes setup docs")).toBeInTheDocument(); + await expect.element(page.getByText(/Detected Hermes at/)).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Use detected Hermes path" })) + .toBeInTheDocument(); + await vi.waitFor(() => { const input = Array.from(document.querySelectorAll("input")).find( (element) => element.value === "/Users/me/.local/bin/hermes", diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md index 4b8e2d2754f..cde1f0c2ed3 100644 --- a/docs/providers/hermes.md +++ b/docs/providers/hermes.md @@ -50,6 +50,10 @@ Binary path: /Users/you/.local/bin/hermes Using the full path is recommended on macOS because apps launched from the Dock or a desktop shell may not inherit your terminal `PATH`. +T3 Code also checks common Hermes locations such as `~/.local/bin/hermes`, +`~/Projects/hermes-agent/venv/bin/hermes`, Homebrew paths, and your login-shell `PATH`. If it finds +Hermes somewhere else, the provider card shows a detected path you can apply from Settings. + ## Verify Run: @@ -68,6 +72,8 @@ default model, the picker should show that model name rather than only `Hermes D If T3 Code says Hermes is not installed, use an absolute binary path instead of `hermes`. +If Settings shows a detected Hermes path, click **Use detected path** and refresh provider status. + If the model picker says no models were found, refresh provider status and confirm that Hermes is enabled. Disabled, missing, or not-ready providers are not treated as selectable model sources. diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 85ff4a4b2cb..1d8878b65ec 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -172,6 +172,7 @@ export const ServerProvider = Schema.Struct({ auth: ServerProviderAuth, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + suggestedBinaryPath: Schema.optional(TrimmedNonEmptyString), // Optional for back-compat: every legacy producer omits this field and // an absent value is interpreted as `"available"` by consumers (see // `isProviderAvailable`). New `ProviderInstanceRegistry` outputs set it From 37da4aca5562e66af7f8a1a50d962d6e58b104fa Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 03:13:16 -0400 Subject: [PATCH 07/23] fix: clarify Hermes ready status --- .../src/components/settings/SettingsPanels.browser.tsx | 7 +++++++ apps/web/src/components/settings/providerStatus.ts | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index a52d1ea64f9..30760dd8fdb 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1186,6 +1186,13 @@ describe("GeneralSettingsPanel observability", () => { await page.getByRole("button", { name: "Expand Hermes provider details" }).click(); + await expect + .element( + page.getByText( + "Installed and ready. Hermes manages authentication through its own CLI and local config.", + ), + ) + .toBeInTheDocument(); await expect.element(page.getByText("Hermes setup", { exact: true })).toBeInTheDocument(); await expect.element(page.getByText("hermes model")).toBeInTheDocument(); await expect.element(page.getByText("hermes acp")).toBeInTheDocument(); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 06622a761b7..79441e2e8dd 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -21,6 +21,10 @@ export const PROVIDER_STATUS_STYLES = { export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; +function isHermesProvider(provider: ServerProvider): boolean { + return String(provider.driver) === "hermes"; +} + /** * Derive the headline + detail copy shown under a provider's name in the * settings page. Prefers `provider.message` for server-supplied detail and @@ -76,7 +80,11 @@ export function getProviderSummary(provider: ServerProvider | undefined) { } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: + provider.message ?? + (isHermesProvider(provider) + ? "Installed and ready. Hermes manages authentication through its own CLI and local config." + : "Installed and ready, but authentication could not be verified."), }; } From 59a16ae2b4340f807384175aec3a05534277424f Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 03:29:18 -0400 Subject: [PATCH 08/23] feat: theme chat surface for Hermes --- apps/web/src/components/ChatView.browser.tsx | 77 ++++++++++++++++++++ apps/web/src/components/ChatView.tsx | 9 ++- apps/web/src/index.css | 30 ++++++++ 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..9c2c6ccb374 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1738,6 +1738,83 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); + it("applies the Hermes chat background only when Hermes is selected", async () => { + const hermesSelection = { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes-default", + }; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-hermes-surface" as MessageId, + targetText: "hermes surface", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID + ? { ...project, defaultModelSelection: hermesSelection } + : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + modelSelection: hermesSelection, + session: thread.session + ? { + ...thread.session, + providerName: "hermes", + providerInstanceId: ProviderInstanceId.make("hermes"), + } + : thread.session, + } + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("hermes"), + instanceId: ProviderInstanceId.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "hermes-default", + name: "Hermes Default", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + }; + }, + }); + + try { + const surface = await waitForElement( + () => document.querySelector("[data-chat-provider-surface='hermes']"), + "Hermes chat provider surface was not applied.", + ); + + expect(surface.classList.contains("chat-surface-hermes")).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + it("renders locked single-environment mobile run context as a static workspace label", async () => { const mounted = await mountChatView({ viewport: COMPACT_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..b6b3e79d473 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1262,6 +1262,7 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const isHermesSelected = String(selectedProvider) === "hermes"; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -3551,7 +3552,13 @@ export default function ChatView(props: ChatViewProps) { {/* Chat column */}
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} Date: Mon, 18 May 2026 03:33:30 -0400 Subject: [PATCH 09/23] style: refine Hermes chat surface --- apps/web/src/index.css | 91 +++++++++++++++++++++++++++++++++++------- 1 file changed, 77 insertions(+), 14 deletions(-) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea0aace844c..9dc677325d9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -84,35 +84,98 @@ } .chat-surface-hermes { + isolation: isolate; background: - repeating-linear-gradient( - 135deg, - color-mix(in srgb, var(--foreground) 7%, transparent) 0 1px, - transparent 1px 22px - ), + linear-gradient(90deg, color-mix(in srgb, #f5c542 16%, transparent) 0 2px, transparent 2px) + 0 0 / 42px 42px, + linear-gradient(0deg, color-mix(in srgb, #d4961c 12%, transparent) 0 2px, transparent 2px) + 0 0 / 42px 42px, + radial-gradient( + circle at 12% 10%, + color-mix(in srgb, #f5c542 18%, transparent) 0 5px, + transparent 6px + ) + 0 0 / 96px 96px, linear-gradient( 180deg, - color-mix(in srgb, var(--background) 92%, #f97316) 0%, - color-mix(in srgb, var(--background) 97%, #14b8a6) 42%, + color-mix(in srgb, var(--background) 90%, #f5c542) 0%, + color-mix(in srgb, var(--background) 94%, #d4961c) 34%, + color-mix(in srgb, var(--background) 97%, #0f172a) 68%, var(--background) 100% ); } -.dark .chat-surface-hermes { +.chat-surface-hermes::before, +.chat-surface-hermes::after { + position: absolute; + pointer-events: none; + z-index: -1; + content: ""; +} + +.chat-surface-hermes::before { + top: max(2rem, 8%); + right: max(1.5rem, 5%); + width: min(32rem, 58vw); + aspect-ratio: 1; + opacity: 0.13; + background: + linear-gradient(#f5c542, #d4961c) 50% 12% / 3% 58% no-repeat, + radial-gradient(circle at 50% 12%, #fff8e1 0 2.5%, #f5c542 2.75% 6%, transparent 6.25%), + conic-gradient(from 300deg at 50% 28%, transparent 0 17%, #f5c542 17% 22%, transparent 22%), + conic-gradient(from 120deg at 50% 28%, transparent 0 17%, #f5c542 17% 22%, transparent 22%), + radial-gradient(ellipse at 38% 26%, transparent 0 42%, #f5c542 43% 47%, transparent 48%), + radial-gradient(ellipse at 62% 26%, transparent 0 42%, #d4961c 43% 47%, transparent 48%), + repeating-linear-gradient( + 90deg, + color-mix(in srgb, #f5c542 40%, transparent) 0 8px, + transparent 8px 16px + ); + clip-path: polygon(50% 0, 83% 22%, 67% 100%, 50% 80%, 33% 100%, 17% 22%); + filter: saturate(1.15); +} + +.chat-surface-hermes::after { + inset: 0; + opacity: 0.1; background: + linear-gradient(90deg, transparent 0 18px, #f5c542 18px 24px, transparent 24px 42px) + right 9% top 8% / 252px 54px no-repeat, + linear-gradient(90deg, transparent 0 12px, #d4961c 12px 18px, transparent 18px 36px) + right 6% top calc(8% + 54px) / 216px 42px no-repeat, repeating-linear-gradient( - 135deg, - color-mix(in srgb, var(--foreground) 6%, transparent) 0 1px, - transparent 1px 24px - ), + 0deg, + color-mix(in srgb, var(--foreground) 5%, transparent) 0 1px, + transparent 1px 4px + ); + mix-blend-mode: multiply; +} + +.dark .chat-surface-hermes { + background: + linear-gradient(90deg, color-mix(in srgb, #f5c542 16%, transparent) 0 2px, transparent 2px) + 0 0 / 42px 42px, + linear-gradient(0deg, color-mix(in srgb, #d4961c 12%, transparent) 0 2px, transparent 2px) + 0 0 / 42px 42px, + radial-gradient( + circle at 12% 10%, + color-mix(in srgb, #f5c542 16%, transparent) 0 5px, + transparent 6px + ) + 0 0 / 96px 96px, linear-gradient( 180deg, - color-mix(in srgb, var(--background) 86%, #f97316) 0%, - color-mix(in srgb, var(--background) 94%, #14b8a6) 48%, + color-mix(in srgb, var(--background) 80%, #f5c542) 0%, + color-mix(in srgb, var(--background) 88%, #d4961c) 34%, + color-mix(in srgb, var(--background) 96%, #0f172a) 68%, var(--background) 100% ); } +.dark .chat-surface-hermes::after { + mix-blend-mode: screen; +} + :root { color-scheme: light; --radius: 0.625rem; From b55b15199d0b684273637ce057fdbadde3e71010 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 03:53:16 -0400 Subject: [PATCH 10/23] docs: expand Hermes README setup --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index f04fc3809ca..f985e63ca21 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,31 @@ T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, OpenCod Hermes setup notes: [docs/providers/hermes.md](./docs/providers/hermes.md) +## Hermes Agent support + +T3 Code can run [Hermes Agent](https://github.com/nousresearch/hermes-agent) as a local ACP +provider. Enable Hermes from **Settings -> Providers**, point the binary path at your local +`hermes` executable, then select Hermes from the chat model picker. + +Recommended macOS setup: + +```bash +git clone https://github.com/nousresearch/hermes-agent.git ~/Projects/hermes-agent +cd ~/Projects/hermes-agent +python3 -m venv venv +./venv/bin/pip install -e . +mkdir -p ~/.local/bin +ln -sf ~/Projects/hermes-agent/venv/bin/hermes ~/.local/bin/hermes +~/.local/bin/hermes model +``` + +T3 Code auto-detects common Hermes paths such as `~/.local/bin/hermes`, +`~/Projects/hermes-agent/venv/bin/hermes`, `/opt/homebrew/bin/hermes`, and `/usr/local/bin/hermes`. +Hermes manages authentication through its own CLI and local config; T3 Code starts `hermes acp` +only when a Hermes conversation needs it. + +Full setup and troubleshooting guide: [docs/providers/hermes.md](./docs/providers/hermes.md) + ### Run without installing ```bash From 7191baeff44c5bd6e82fd9d30472c9801f74bc07 Mon Sep 17 00:00:00 2001 From: Joey Rodriguez Date: Mon, 18 May 2026 04:04:12 -0400 Subject: [PATCH 11/23] docs: add Hermes themed chat screenshot --- README.md | 2 ++ docs/assets/hermes-chat-theme.jpg | Bin 0 -> 94248 bytes 2 files changed, 2 insertions(+) create mode 100644 docs/assets/hermes-chat-theme.jpg diff --git a/README.md b/README.md index f985e63ca21..8ae1e88ded7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ T3 Code can run [Hermes Agent](https://github.com/nousresearch/hermes-agent) as provider. Enable Hermes from **Settings -> Providers**, point the binary path at your local `hermes` executable, then select Hermes from the chat model picker. +![Hermes themed chat surface](./docs/assets/hermes-chat-theme.jpg) + Recommended macOS setup: ```bash diff --git a/docs/assets/hermes-chat-theme.jpg b/docs/assets/hermes-chat-theme.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6f03a9d1e46bb7cf375122b4b50417ad05da1451 GIT binary patch literal 94248 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
copyToClipboard(updateCommand, { - providerName: displayName, + successTitle: `${displayName} update command copied`, + errorTitle: `Could not copy ${displayName} update command`, + description: "Run it in a terminal when you are ready to update.", }) } aria-label="Copy update command" @@ -1041,7 +1230,10 @@ export function ProviderInstanceCard({ className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground" onClick={() => copyToClipboard(updateCommand, { - providerName: displayName, + successTitle: `${displayName} update command copied`, + errorTitle: `Could not copy ${displayName} update command`, + description: + "Run it in a terminal when you are ready to update.", }) } aria-label="Copy update command" @@ -1120,6 +1312,7 @@ export function ProviderInstanceCard({ {hermesSetupNode} {piSetupNode} + {setupChecklistNode} {diagnosticsNode} {providerUpdateNode} diff --git a/docs/providers/release-readiness.md b/docs/providers/release-readiness.md new file mode 100644 index 00000000000..c7f4987ceb4 --- /dev/null +++ b/docs/providers/release-readiness.md @@ -0,0 +1,47 @@ +# Hermes and Pi release readiness + +Use this checklist before publishing a fork build with Hermes or Pi provider support. + +## Provider setup + +- Open **Settings -> Providers** and expand Hermes and Pi. +- Confirm each provider shows a complete setup checklist. +- Use **Copy diagnostics** if the provider is unavailable, then verify the reported binary paths in a terminal. +- Use **Update provider** when an update is offered, then refresh provider status. +- Open a fresh chat, select the provider from the model picker, and send a small prompt. + +## Mac validation + +- Build the Apple Silicon DMG: + +```sh +bun run dist:desktop:dmg:arm64 +``` + +- Mount the DMG and confirm `T3 Code (Alpha).app` appears. +- Install from the DMG on a clean macOS user account. +- Confirm Hermes and Pi are detected when installed under common paths such as `~/.local/bin`. +- Confirm absolute binary paths work when the packaged app cannot see the shell `PATH`. + +## Windows validation + +- Build the Windows installer: + +```sh +bun run dist:desktop:win:x64 +``` + +- Install the generated `.exe` on Windows. +- Confirm Hermes detection through `where hermes`. +- Confirm Pi detection through `where pi-acp` and `where pi`. +- Confirm `.cmd` paths under `%APPDATA%\npm` work from the packaged app. + +## Screenshots + +Capture fresh-chat screenshots after provider selection: + +- Hermes selected with the Hermes chat background visible. +- Pi selected with the Pi chat background visible. +- Settings -> Providers with the setup checklist and update action visible. + +Store screenshots under `docs/assets/` and keep the README image links current.