diff --git a/README.md b/README.md index 8ec101387f67..ccc23502ed6d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ T3 Code is an "agent harness control surface". It enables control of the agents on your machine with a best-in-class mobile app ([iOS](https://apps.apple.com/us/app/t3-code-remote-claude-more/id6787819824), [Android](https://play.google.com/store/apps/details?id=com.t3tools.t3code)), [web app](https://app.t3.codes) and [Electron-based desktop app](https://t3.codes). -Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, and OpenCode. If they're set up on your computer, T3 Code can control them. +Works with your subscriptions on Claude Code, Codex, Cursor, Grok Build, Hermes Agent, and OpenCode. If they're set up on your computer, T3 Code can control them. ## "Wait, what are you selling me?" @@ -13,12 +13,13 @@ We wanted something performant, remote-ready, and truly open. If we ever go the ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, Cursor, Grok Build and OpenCode. Install and authenticate at least one provider before use: +> T3 Code currently supports Codex, Claude, Cursor, Grok Build, Hermes Agent, and OpenCode. 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` > - Cursor: install [Cursor CLI](https://cursor.com/cli) and run `agent login` > - Grok Build: install [Grok Build CLI](https://x.ai/cli) and run `grok login` +> - Hermes Agent: install [Hermes](https://hermes-agent.nousresearch.com/docs/user-guide/installation) and run `hermes setup` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` ### Try it out (install-free) @@ -82,7 +83,7 @@ Full docs live in [docs/](./docs). There's no docs site yet. - [Remote access from a phone or another machine](./docs/user/remote-access.md) - [Keeping app and server in sync](./docs/user/updating.md) - [Source control integrations](./docs/user/source-control.md) -- Multiple accounts: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) +- Providers: [Codex](./docs/user/providers-codex.md) · [Claude](./docs/user/providers-claude.md) · [Hermes](./docs/user/providers-hermes.md) - Linux: [run T3 Code as a background service](./docs/user/background-service.md) Building from source? Start at [docs/internals/overview.md](./docs/internals/overview.md). diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 36f348d6370a..280d68b1b19c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, + [WS_METHODS.serverImportHermesSessions]: AuthOrchestrationOperateScope, + [WS_METHODS.serverImportLocalChats]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServerWithProgress]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 95adee0cf7f8..7d0b26dafa82 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -635,6 +635,9 @@ const make = Effect.gen(function* () { >, ) { if (event.type === "thread.message-sent") { + if (event.metadata.importedHistory === true) { + return; + } if ( event.payload.role !== "user" || event.payload.streaming || diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..59dbf3d9da8d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -851,6 +851,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } case "thread.message-sent": + case "thread.history-imported": case "thread.proposed-plan-upserted": case "thread.activity-appended": case "thread.approval-response-requested": @@ -986,6 +987,28 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-imported": { + yield* Effect.forEach( + event.payload.messages, + (message) => + projectionThreadMessageRepository.upsert({ + messageId: message.id, + threadId: event.payload.threadId, + turnId: message.turnId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined + ? { attachments: [...message.attachments] } + : {}), + isStreaming: false, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }), + { concurrency: 16, discard: true }, + ); + return; + } + case "thread.reverted": { const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1095,6 +1118,25 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.history-imported": + yield* Effect.forEach( + event.payload.activities, + (activity) => + projectionThreadActivityRepository.upsert({ + activityId: activity.id, + threadId: event.payload.threadId, + turnId: activity.turnId, + tone: activity.tone, + kind: activity.kind, + summary: activity.summary, + payload: activity.payload, + ...(activity.sequence !== undefined ? { sequence: activity.sequence } : {}), + createdAt: activity.createdAt, + }), + { concurrency: 16, discard: true }, + ); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadActivityRepository.listByThreadId({ threadId: event.payload.threadId, diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..93ba7bb6b960 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -17,6 +17,7 @@ import { ThreadUnpinnedPayload as ContractsThreadUnpinnedPayloadSchema, ThreadPinReorderedPayload as ContractsThreadPinReorderedPayloadSchema, ThreadMessageSentPayload as ContractsThreadMessageSentPayloadSchema, + ThreadHistoryImportedPayload as ContractsThreadHistoryImportedPayloadSchema, ThreadProposedPlanUpsertedPayload as ContractsThreadProposedPlanUpsertedPayloadSchema, ThreadSessionSetPayload as ContractsThreadSessionSetPayloadSchema, ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, @@ -50,6 +51,7 @@ export const ThreadUnpinnedPayload = ContractsThreadUnpinnedPayloadSchema; export const ThreadPinReorderedPayload = ContractsThreadPinReorderedPayloadSchema; export const MessageSentPayloadSchema = ContractsThreadMessageSentPayloadSchema; +export const ThreadHistoryImportedPayload = ContractsThreadHistoryImportedPayloadSchema; export const ThreadProposedPlanUpsertedPayload = ContractsThreadProposedPlanUpsertedPayloadSchema; export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.importMessage.test.ts b/apps/server/src/orchestration/decider.importMessage.test.ts new file mode 100644 index 000000000000..e7143cd13261 --- /dev/null +++ b/apps/server/src/orchestration/decider.importMessage.test.ts @@ -0,0 +1,131 @@ +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-08-15T12:00:00.000Z"; +const THREAD_ID = ThreadId.make("hermes-thread"); + +const readModel: OrchestrationReadModel = { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("hermes-project"), + title: "Imported Hermes chat", + modelSelection: { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes-agent", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, +}; + +it.layer(NodeServices.layer)("Hermes message import decider", (it) => { + it.effect("preserves the imported role, text, id, and timestamp", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.message.import", + commandId: CommandId.make("import-message"), + threadId: THREAD_ID, + messageId: MessageId.make("hermes-message"), + role: "assistant", + text: "Imported response", + createdAt: NOW, + }, + readModel, + }); + + const events: ReadonlyArray = Array.isArray(result) + ? result + : [result as OrchestrationEvent]; + expect(events).toHaveLength(1); + const event = events[0]; + if (event?.type === "thread.message-sent") { + expect(event.metadata.importedHistory).toBe(true); + expect(event.payload).toMatchObject({ + threadId: THREAD_ID, + messageId: MessageId.make("hermes-message"), + role: "assistant", + text: "Imported response", + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }); + } + }), + ); + + it.effect("imports a complete message and activity history atomically", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("import-history"), + threadId: THREAD_ID, + messages: [ + { + messageId: MessageId.make("history-message"), + role: "user", + text: "Imported prompt", + createdAt: NOW, + }, + ], + activities: [ + { + id: EventId.make("history-activity"), + tone: "tool", + kind: "tool.completed", + summary: "pnpm build", + payload: { itemType: "command_execution" }, + turnId: null, + sequence: 0, + createdAt: NOW, + }, + ], + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.history-imported"]); + expect(events[0]?.metadata.importedHistory).toBe(true); + if (events[0]?.type === "thread.history-imported") { + expect(events[0].payload.messages).toHaveLength(1); + expect(events[0].payload.activities).toHaveLength(1); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..c9ce7da01c9e 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1279,6 +1279,72 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.message.import": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + metadata: { importedHistory: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: command.messageId, + role: command.role, + text: command.text, + attachments: command.role === "user" ? [] : undefined, + turnId: null, + streaming: false, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + + case "thread.history.import": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const updatedAt = [...command.messages, ...command.activities].reduce( + (latest, item) => (item.createdAt > latest ? item.createdAt : latest), + "1970-01-01T00:00:00.000Z", + ); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: updatedAt, + commandId: command.commandId, + metadata: { importedHistory: true }, + })), + type: "thread.history-imported", + payload: { + threadId: command.threadId, + messages: command.messages.map((message) => ({ + id: message.messageId, + role: message.role, + text: message.text, + ...(message.role === "user" ? { attachments: [] } : {}), + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + })), + activities: command.activities, + updatedAt, + }, + }; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..e3a346153983 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -11,6 +11,7 @@ import * as Schema from "effect/Schema"; import { toProjectorDecodeError, type OrchestrationProjectorDecodeError } from "./Errors.ts"; import { MessageSentPayloadSchema, + ThreadHistoryImportedPayload, ProjectCreatedPayload, ProjectDeletedPayload, ProjectMetaUpdatedPayload, @@ -549,6 +550,29 @@ export function projectEvent( }; }); + case "thread.history-imported": + return decodeForEvent( + ThreadHistoryImportedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) return nextBase; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + messages: [...thread.messages, ...payload.messages].slice(-MAX_THREAD_MESSAGES), + activities: [...thread.activities, ...payload.activities] + .toSorted(compareThreadActivities) + .slice(-500), + updatedAt: payload.updatedAt, + }), + }; + }), + ); + case "thread.session-set": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 000000000000..10731eb54a5b --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,158 @@ +import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +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 { ChildProcessSpawner } from "effect/unstable/process"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { + buildInitialHermesProviderSnapshot, + checkHermesProviderStatus, +} 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 { + enrichProviderSnapshotWithVersionAdvisory, + makeGitHubReleaseProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeHermesSettings = Schema.decodeSync(HermesSettings); +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); + +export type HermesDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | ChildProcessSpawner.ChildProcessSpawner + | Crypto.Crypto + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: (): HermesSettings => decodeHermesSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + 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 = makeGitHubReleaseProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + repository: "NousResearch/hermes-agent", + updateExecutable: effectiveConfig.binaryPath || "hermes", + updateArgs: ["update", "--yes"], + updateLockKey: "hermes-native", + }); + + const adapter = yield* makeHermesAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = makeHermesTextGeneration(); + + const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialHermesProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + }).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 000000000000..59631cc11b75 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,1090 @@ +/** + * HermesAdapterLive — Hermes CLI (`hermes acp`) via ACP. + * + * @module HermesAdapterLive + */ + +import { + ApprovalRequestId, + type HermesSettings, + type ProviderOptionSelection, + 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 Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import * as EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { + ProviderAdapterProcessError, + type ProviderAdapterError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import type * as AcpSessionRuntime from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; +import { applyHermesAcpModelSelection, makeHermesAcpRuntime } from "../acp/HermesAcpSupport.ts"; +import type { ProviderAdapterShape as GenericProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { resolveHermesAcpBaseModelId } from "../acp/HermesAcpSupport.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); + +const PROVIDER = ProviderDriverKind.make("hermes"); +type ProviderAdapterShape = GenericProviderAdapterShape; +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?: ProviderInstanceId; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `hermesSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface HermesSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntime.AcpSessionRuntime["Service"]; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + /** Number of sendTurn prompts currently in flight or being prepared. + * >0 means a turn is actively running, so a new sendTurn is a steer that + * continues it, and only the last remaining prompt settles the turn. */ + promptsInFlight: number; + 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: AcpSessionRuntime.AcpSessionRuntime["Service"]; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly modelSelection: + | { + readonly model: string; + readonly options?: ReadonlyArray | null | undefined; + } + | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_config_option" | "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.modelSelection) { + yield* applyHermesAcpModelSelection({ + runtime: input.runtime, + model: input.modelSelection.model, + selections: input.modelSelection.options, + mapError: ({ cause }) => + input.mapError({ + cause, + method: "session/set_config_option", + }), + }); + } + + 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 crypto = yield* Crypto.Crypto; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + const makeAcpNativeLoggers = yield* makeAcpNativeLoggerFactory(); + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Hermes runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + const mapExtensionFailure = (effect: Effect.Effect) => + effect.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to process Hermes ACP extension event.", + cause, + }), + ), + ); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.hermes.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* randomUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: HermesSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.hermes.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: HermesSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const hermesModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: HermesSessionContext; + + const resumeSessionId = parseHermesResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the HermesSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { hermes: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveHermesSettings = options?.resolveSettings + ? yield* options.resolveSettings + : hermesSettings; + + const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const acp = yield* makeHermesAcpRuntime({ + hermesSettings: effectiveHermesSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...(mcpSession + ? { + mcpServers: [ + { + type: "http" as const, + name: "t3-code", + url: mcpSession.endpoint, + headers: [ + { + name: "Authorization", + value: mcpSession.authorizationHeader, + }, + ], + }, + ], + } + : {}), + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + mapExtensionFailure( + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(yield* randomUUIDv4); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + 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, + modelSelection: hermesModelSelection, + 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, + model: hermesModelSelection?.model, + 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, + promptsInFlight: 0, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "EventStreamBarrier": + yield* Deferred.succeed(event.acknowledge, undefined); + return; + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe( + Effect.catch((cause) => + Effect.logError("Failed to process Hermes runtime notification.", { cause }), + ), + // Fork into the session scope, not the calling fiber. `forkChild` + // makes this a child of `startSession`, and Effect interrupts a + // fiber's children when it completes, so the consumer died as soon + // as `startSession` returned and every later notification was + // dropped. The scope is created, stored on the context and closed + // on teardown already; only the fork target was wrong. + Effect.forkIn(ctx.scope), + ); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Hermes ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + // A sendTurn while a prompt is in flight is a steer: the agent folds + // the new prompt into the ongoing work, so the active turn id is + // reused instead of opening a new turn. + const steeringTurnId = ctx.promptsInFlight > 0 ? ctx.activeTurnId : undefined; + const turnId = steeringTurnId ?? TurnId.make(yield* randomUUIDv4); + // Count this prompt immediately so a superseded in-flight prompt + // resolving from here on does not settle the turn; the matching + // decrement is the `ensuring` below. + ctx.promptsInFlight += 1; + + return yield* Effect.gen(function* () { + const turnModelSelection = + input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; + const model = turnModelSelection?.model ?? ctx.session.model; + const resolvedModel = resolveHermesAcpBaseModelId(model); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + modelSelection: + model === undefined + ? undefined + : { + model, + options: turnModelSelection?.options, + }, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + if (steeringTurnId === undefined) { + ctx.lastPlanFingerprint = undefined; + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + if (steeringTurnId === undefined) { + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: resolvedModel }, + }); + } + + const 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), + ), + ); + + const turnRecord = ctx.turns.find((turn) => turn.id === turnId); + if (turnRecord) { + turnRecord.items.push({ prompt: promptParts, result }); + } else { + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + } + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model: resolvedModel, + }; + + // Only the last remaining prompt settles the turn — a steer- + // superseded prompt resolving (usually cancelled) while another is + // in flight or pending must leave the merged turn running. + if (ctx.promptsInFlight === 1) { + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + } + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + ctx.promptsInFlight = Math.max(0, ctx.promptsInFlight - 1); + }), + ), + ); + }); + + const interruptTurn: ProviderAdapterShape["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: ProviderAdapterShape["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: ProviderAdapterShape["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: ProviderAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: ProviderAdapterShape["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: ProviderAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: ProviderAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.catch((cause) => + Effect.logError("Failed to emit Hermes session shutdown event.", { cause }), + ), + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies ProviderAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/HermesProvider.ts b/apps/server/src/provider/Layers/HermesProvider.ts new file mode 100644 index 000000000000..5ee055edc83e --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -0,0 +1,193 @@ +import { type HermesSettings, type ModelCapabilities } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +const HERMES_PRESENTATION = { + displayName: "Hermes", + badgeLabel: "Experimental", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const VERSION_TIMEOUT_MS = 4_000; + +export function getHermesFallbackModels(hermesSettings: Pick) { + return providerModelsFromSettings( + [ + { + slug: "hermes-agent", + name: "Hermes Agent", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + ], + 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 availability...", + }, + }); + }); +} + +const runHermesCommand = ( + hermesSettings: HermesSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const env = { + ...environment, + ...(hermesSettings.homePath ? { HERMES_HOME: hermesSettings.homePath } : {}), + }; + const commandName = hermesSettings.binaryPath || "hermes"; + const spawnCommand = yield* resolveSpawnCommand(commandName, args, { env }); + const command = ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env, + shell: spawnCommand.shell, + }); + 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); + +export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + 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.", + }, + }); + } + + const versionProbe = yield* runHermesCommand(hermesSettings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Hermes CLI (`hermes`) is not installed or not on PATH." + : `Failed to execute Hermes CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Hermes CLI is installed but timed out while running `hermes --version`.", + }, + }); + } + + const result = versionProbe.success.value; + const combined = `${result.stdout}\n${result.stderr}`; + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: hermesSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parseGenericCliVersion(combined), + status: result.code === 0 ? "ready" : "warning", + auth: { status: "unknown" }, + ...(result.code === 0 ? {} : { message: "Hermes CLI responded with a non-zero exit code." }), + }, + }); +}); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 09fce6d56f9d..71a522bbd93c 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -61,6 +61,9 @@ export interface AcpSessionRuntimeOptions { readonly spawn: AcpSpawnInput; readonly cwd: string; readonly resumeSessionId?: string; + /** Hermes may print a short banner before beginning its JSON-RPC stream. */ + readonly discardNonJsonStdoutLines?: boolean; + readonly sessionCreateTimeout?: Duration.Input; readonly sessionLoadTimeout?: Duration.Input; readonly sessionLoadReplayIdleGap?: Duration.Input; readonly clientCapabilities?: EffectAcpSchema.InitializeRequest["clientCapabilities"]; @@ -78,6 +81,13 @@ export interface AcpSessionRuntimeOptions { }; } +export function resolveAcpAuthMethodId( + configuredMethodId: string, + initializeResult: Pick, +): string | undefined { + return configuredMethodId.trim() || initializeResult.authMethods?.[0]?.id.trim() || undefined; +} + export interface AcpSessionRequestLogEvent { readonly method: string; readonly payload: unknown; @@ -353,8 +363,12 @@ export const make = ( ), ); + const acpChild = options.discardNonJsonStdoutLines + ? { ...child, stdout: discardNonJsonStdoutLines(child.stdout) } + : child; + const acpContext = yield* Layer.build( - EffectAcpClient.layerChildProcess(child, { + EffectAcpClient.layerChildProcess(acpChild, { ...(options.protocolLogging?.logIncoming !== undefined ? { logIncoming: options.protocolLogging.logIncoming } : {}), @@ -541,15 +555,18 @@ export const make = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + const authMethodId = resolveAcpAuthMethodId(options.authMethodId, initializeResult); + if (authMethodId) { + const authenticatePayload = { + methodId: authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -635,11 +652,30 @@ export const make = ( cwd: options.cwd, mcpServers: options.mcpServers ?? [], } satisfies EffectAcpSchema.NewSessionRequest; - const created = yield* runLoggedRequest( + const createSession = runLoggedRequest( "session/new", createPayload, acp.agent.createSession(createPayload), ); + const created = yield* options.sessionCreateTimeout === undefined + ? createSession + : createSession.pipe( + Effect.timeoutOption(options.sessionCreateTimeout), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/new", + detail: "session/new timed out waiting for the agent to initialize", + cause: undefined, + }), + ), + onSome: Effect.succeed, + }), + ), + ); sessionId = created.sessionId; sessionSetupResult = created; } @@ -939,6 +975,38 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } +const stdoutDecoder = new TextDecoder(); +const stdoutEncoder = new TextEncoder(); + +function discardNonJsonStdoutLines( + stream: Stream.Stream, +): Stream.Stream { + return Stream.unwrap( + Effect.gen(function* () { + const pendingRef = yield* Ref.make(""); + return stream.pipe( + Stream.mapEffect((chunk) => + Ref.modify(pendingRef, (pending) => { + const text = pending + stdoutDecoder.decode(chunk, { stream: true }); + const lines = text.split(/\r?\n/g); + const nextPending = lines.pop() ?? ""; + const filtered = lines + .filter((line) => { + const trimmed = line.trimStart(); + return trimmed.startsWith("{") || trimmed.startsWith("["); + }) + .map((line) => `${line}\n`) + .join(""); + return [filtered, nextPending] as const; + }), + ), + Stream.filter((chunk) => chunk.length > 0), + Stream.map((chunk) => stdoutEncoder.encode(chunk)), + ); + }), + ); +} + const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; diff --git a/apps/server/src/provider/acp/HermesAcpCliProbe.test.ts b/apps/server/src/provider/acp/HermesAcpCliProbe.test.ts new file mode 100644 index 000000000000..15d66d5fae57 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpCliProbe.test.ts @@ -0,0 +1,44 @@ +/** + * Optional integration check against a real `hermes acp` install. + * Enable with: T3_HERMES_ACP_PROBE=1 vp test run HermesAcpCliProbe.test.ts + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import { describe, expect } from "vite-plus/test"; + +import { makeHermesAcpRuntime } from "./HermesAcpSupport.ts"; + +const makeProbeRuntime = Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + return yield* makeHermesAcpRuntime({ + hermesSettings: { + binaryPath: process.env.T3_HERMES_BINARY ?? "hermes", + homePath: process.env.T3_HERMES_HOME ?? "", + authMethodId: process.env.T3_HERMES_AUTH_METHOD ?? "", + }, + environment: process.env, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-hermes-probe", version: "0.0.0" }, + requestLogger: (event) => Console.log("Hermes ACP request", event), + protocolLogging: { + logIncoming: true, + logOutgoing: true, + logger: (event) => Console.log("Hermes ACP protocol", event), + }, + }); +}); + +describe.runIf(process.env.T3_HERMES_ACP_PROBE === "1")("Hermes ACP CLI probe", () => { + it.effect("initializes, authenticates, and creates a Hermes session", () => + Effect.gen(function* () { + const runtime = yield* makeProbeRuntime; + const started = yield* runtime.start(); + expect(started.initializeResult).toBeDefined(); + expect(typeof started.sessionId).toBe("string"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.test.ts b/apps/server/src/provider/acp/HermesAcpSupport.test.ts new file mode 100644 index 000000000000..ea7d78727cd0 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveAcpAuthMethodId } from "./AcpSessionRuntime.ts"; +import { buildHermesAcpSpawnInput, resolveHermesAcpBaseModelId } from "./HermesAcpSupport.ts"; + +describe("HermesAcpSupport", () => { + it("starts Hermes in ACP mode and carries the configured home and environment", () => { + expect( + buildHermesAcpSpawnInput( + { + binaryPath: "C:/tools/hermes.exe", + homePath: "C:/profiles/hermes", + authMethodId: "", + }, + "C:/workspace", + { HERMES_TEST: "1" }, + ), + ).toEqual({ + command: "C:/tools/hermes.exe", + args: ["acp"], + cwd: "C:/workspace", + env: { + HERMES_TEST: "1", + HERMES_HOME: "C:/profiles/hermes", + }, + }); + }); + + it("uses Hermes defaults when optional settings are blank", () => { + expect( + buildHermesAcpSpawnInput( + { binaryPath: "", homePath: " ", authMethodId: "" }, + "C:/workspace", + ), + ).toEqual({ command: "hermes", args: ["acp"], cwd: "C:/workspace", env: {} }); + expect(resolveHermesAcpBaseModelId(" ")).toBeUndefined(); + expect(resolveHermesAcpBaseModelId(" hermes-agent ")).toBeUndefined(); + expect(resolveHermesAcpBaseModelId("deepseek:deepseek-v4-flash")).toBe( + "deepseek:deepseek-v4-flash", + ); + }); + + it("uses the auth method advertised by Hermes unless explicitly overridden", () => { + const initialized = { + authMethods: [{ id: "deepseek", name: "DeepSeek" }], + }; + expect(resolveAcpAuthMethodId("", initialized)).toBe("deepseek"); + expect(resolveAcpAuthMethodId("custom-provider", initialized)).toBe("custom-provider"); + }); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 000000000000..493f9018cb1c --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,118 @@ +import { type HermesSettings, type ProviderOptionSelection } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import type * as EffectAcpErrors from "effect-acp/errors"; + +import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; + +type HermesAcpRuntimeSettings = Pick; + +export interface HermesAcpRuntimeInput extends Omit< + AcpSessionRuntime.AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "discardNonJsonStdoutLines" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export interface HermesAcpModelSelectionErrorContext { + readonly cause: EffectAcpErrors.AcpError; + readonly step: "set-config-option" | "set-model"; + readonly configId?: string; +} + +export function buildHermesAcpSpawnInput( + hermesSettings: HermesAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSessionRuntime.AcpSpawnInput { + const homePath = hermesSettings?.homePath.trim(); + return { + command: hermesSettings?.binaryPath || "hermes", + args: ["acp"], + cwd, + env: { + ...environment, + ...(homePath ? { HERMES_HOME: homePath } : {}), + }, + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect< + AcpSessionRuntime.AcpSessionRuntime["Service"], + EffectAcpErrors.AcpError, + Crypto.Crypto | Scope.Scope +> => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + authMethodId: input.hermesSettings?.authMethodId.trim() || "", + discardNonJsonStdoutLines: true, + sessionCreateTimeout: input.sessionCreateTimeout ?? Duration.seconds(60), + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(acpContext), + ); + }); + +interface HermesAcpModelSelectionRuntime { + readonly getConfigOptions: AcpSessionRuntime.AcpSessionRuntime["Service"]["getConfigOptions"]; + readonly setConfigOption: ( + configId: string, + value: string | boolean, + ) => Effect.Effect; + readonly setModel: (model: string) => Effect.Effect; +} + +export const resolveHermesAcpBaseModelId = ( + model: string | null | undefined, +): string | undefined => { + const normalized = model?.trim(); + return normalized && normalized !== "hermes-agent" ? normalized : undefined; +}; + +export function applyHermesAcpModelSelection(input: { + readonly runtime: HermesAcpModelSelectionRuntime; + readonly model: string | null | undefined; + readonly selections: ReadonlyArray | null | undefined; + readonly mapError: (context: HermesAcpModelSelectionErrorContext) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const model = resolveHermesAcpBaseModelId(input.model); + if (model) { + yield* input.runtime + .setModel(model) + .pipe(Effect.mapError((cause) => input.mapError({ cause, step: "set-model" }))); + } + + const availableIds = new Set( + (yield* input.runtime.getConfigOptions).map((option) => option.id), + ); + for (const selection of input.selections ?? []) { + if (!availableIds.has(selection.id)) continue; + yield* input.runtime.setConfigOption(selection.id, selection.value).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + step: "set-config-option", + configId: selection.id, + }), + ), + ); + } + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..3d727b97423f 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HermesDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + expect(isHermesSubagentSession({ source: "subagent", parent_session_id: "parent" })).toBe(true); + expect(isHermesSubagentSession({ source: " SUBAGENT " })).toBe(true); + expect(isHermesSubagentSession({ source: "cli", parent_session_id: "parent" })).toBe(false); + expect(isHermesSubagentSession({ source: "telegram", parent_session_id: "parent" })).toBe(false); + expect(isHermesSubagentSession({ parent_session_id: "parent" })).toBe(false); +}); + +it.effect("parses Hermes JSONL exports and normalizes visible conversation messages", () => + Effect.gen(function* () { + const sessions = yield* parseHermesSessionsExport( + '{"id":"session-1","started_at":1700000000,"messages":[' + + '{"id":1,"role":"user","content":" Explain ACP ","timestamp":1700000001},' + + '{"id":2,"role":"assistant","content":[{"text":"Agent Client"},{"text":"Protocol"}]},' + + '{"id":3,"role":"tool","content":"hidden tool output"},' + + '{"id":4,"role":"assistant","content":""}]}\n', + ); + + expect(sessions).toHaveLength(1); + const messages = hermesSessionMessages(sessions[0]!); + expect(messages).toEqual([ + { + id: "1", + role: "user", + text: "Explain ACP", + createdAt: "2023-11-14T22:13:21.000Z", + }, + { + id: "2", + role: "assistant", + text: "Agent Client\n\nProtocol", + createdAt: "2023-11-14T22:13:20.000Z", + }, + ]); + expect(hermesSessionTitle(sessions[0]!, messages)).toBe("Explain ACP"); + }), +); + +it.effect("loads Hermes tool outputs, commands, failures, and artifact paths", () => + Effect.gen(function* () { + const exportLine = yield* encodeJson({ + id: "session-tools", + started_at: 1_700_000_000, + messages: [ + { + id: "assistant-1", + role: "assistant", + tool_calls: [ + { + id: "call-terminal", + type: "function", + function: { name: "terminal", arguments: '{"command":"pnpm build"}' }, + }, + { + id: "call-write", + type: "function", + function: { name: "write_file", arguments: '{"path":"src/new.ts"}' }, + }, + ], + }, + { + id: "tool-1", + role: "tool", + tool_call_id: "call-terminal", + tool_name: "terminal", + content: '{"output":"Build complete\\n","exit_code":0}', + timestamp: 1_700_000_001, + }, + { + id: "tool-2", + role: "tool", + tool_call_id: "call-write", + tool_name: "write_file", + content: + '{"success":true,"resolved_path":"C:/repo/src/new.ts","files_modified":["C:/repo/src/new.ts"]}', + timestamp: 1_700_000_002, + }, + { + id: "tool-3", + role: "tool", + tool_call_id: "call-failed", + tool_name: "terminal", + content: '{"stderr":"command failed","exit_code":1}', + timestamp: 1_700_000_003, + }, + { + id: "hidden-tool", + role: "tool", + tool_name: "memory", + display_kind: "hidden", + content: "private memory payload", + }, + ], + }); + const sessions = yield* parseHermesSessionsExport(`${exportLine}\n`); + + const activities = hermesSessionActivities(sessions[0]!); + expect(activities).toHaveLength(3); + expect(activities[0]).toMatchObject({ + id: "tool-1", + summary: "Terminal", + tone: "tool", + payload: { + itemType: "command_execution", + status: "completed", + detail: "pnpm build", + data: { + toolCallId: "call-terminal", + rawInput: { command: "pnpm build" }, + rawOutput: { output: "Build complete\n", stdout: "Build complete\n", exit_code: 0 }, + item: { command: "pnpm build" }, + }, + }, + }); + expect(activities[1]).toMatchObject({ + summary: "Write File", + payload: { + itemType: "file_change", + data: { files: [{ path: "C:/repo/src/new.ts" }] }, + }, + }); + expect(activities[2]).toMatchObject({ + tone: "error", + payload: { status: "failed", detail: "command failed" }, + }); + }), +); + +it.effect("recovers structured Hermes results from untrusted wrappers", () => + Effect.gen(function* () { + const exportLine = yield* encodeJson({ + id: "session-wrapper", + messages: [ + { + id: "wrapped", + role: "tool", + tool_name: "web_search", + content: + '{"results":[{"title":"ACP"}]}', + }, + ], + }); + const sessions = yield* parseHermesSessionsExport(`${exportLine}\n`); + + expect(hermesSessionActivities(sessions[0]!)[0]).toMatchObject({ + payload: { + itemType: "web_search", + data: { rawOutput: { results: [{ title: "ACP" }] } }, + }, + }); + }), +); + +it.effect("rejects a malformed Hermes export instead of silently dropping it", () => + Effect.gen(function* () { + const exit = yield* Effect.exit(parseHermesSessionsExport("{not-json}\n")); + expect(exit._tag).toBe("Failure"); + }), +); diff --git a/apps/server/src/provider/hermesImport.ts b/apps/server/src/provider/hermesImport.ts new file mode 100644 index 000000000000..cdd064a597d0 --- /dev/null +++ b/apps/server/src/provider/hermesImport.ts @@ -0,0 +1,694 @@ +import * as NodeOS from "node:os"; + +import { + CommandId, + EventId, + HermesImportSessionsError, + type HermesImportSessionsInput, + type HermesImportSessionsResult, + HermesSettings, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as DateTime from "effect/DateTime"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { collectStreamAsString } from "./providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import * as ProviderSessionDirectory from "./Services/ProviderSessionDirectory.ts"; + +export interface HermesExportMessage { + readonly id?: unknown; + readonly role?: unknown; + readonly content?: unknown; + readonly timestamp?: unknown; + readonly tool_call_id?: unknown; + readonly tool_calls?: unknown; + readonly tool_name?: unknown; + readonly display_kind?: unknown; +} + +export interface HermesExportSession { + readonly id?: unknown; + readonly source?: unknown; + readonly parent_session_id?: unknown; + readonly title?: unknown; + readonly display_name?: unknown; + readonly model?: unknown; + readonly cwd?: unknown; + readonly git_repo_root?: unknown; + readonly started_at?: unknown; + readonly ended_at?: unknown; + readonly messages?: unknown; +} + +interface ResolvedHermesImportConfig { + readonly settings: HermesSettings; + readonly environment: NodeJS.ProcessEnv; + readonly instanceId: ProviderInstanceId; +} + +const decodeHermesSettings = Schema.decodeUnknownEffect(HermesSettings); + +function importError(reason: string, cause?: unknown) { + return new HermesImportSessionsError({ + reason, + ...(cause === undefined ? {} : { cause }), + }); +} + +function resolveImportConfig( + settings: ServerSettings, + input: HermesImportSessionsInput, +): Effect.Effect { + const instanceId = input.instanceId ?? ProviderInstanceId.make("hermes"); + const instance = settings.providerInstances[instanceId]; + + if (instance !== undefined && instance.driver !== "hermes") { + return Effect.fail(importError(`Provider instance '${instanceId}' is not a Hermes instance.`)); + } + + const rawSettings = + instance?.config ?? (instanceId === "hermes" ? settings.providers.hermes : undefined); + if (rawSettings === undefined) { + return Effect.fail(importError(`Hermes provider instance '${instanceId}' was not found.`)); + } + + return decodeHermesSettings(rawSettings).pipe( + Effect.map((providerSettings) => ({ + settings: providerSettings, + environment: mergeProviderInstanceEnvironment(instance?.environment), + instanceId, + })), + Effect.mapError((cause) => importError("The Hermes provider settings are invalid.", cause)), + ); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +/** Hermes marks spawned child-agent conversations explicitly. A parent id is + * not sufficient: ordinary CLI and Telegram continuations can have one too. */ +export function isHermesSubagentSession(session: HermesExportSession): boolean { + return stringValue(session.source)?.toLowerCase() === "subagent"; +} + +function messageText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) { + return value.map(messageText).filter(Boolean).join("\n\n"); + } + if (value && typeof value === "object") { + const record = value as Record; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + } + return ""; +} + +function isoTimestamp(value: unknown, fallback: string): string { + if (typeof value === "number" && Number.isFinite(value)) { + const milliseconds = value < 10_000_000_000 ? value * 1_000 : value; + const parsed = DateTime.make(milliseconds); + if (Option.isSome(parsed)) return DateTime.formatIso(parsed.value); + } + if (typeof value === "string") { + const parsed = DateTime.make(value); + if (Option.isSome(parsed)) return DateTime.formatIso(parsed.value); + } + return fallback; +} + +export function hermesSessionMessages(session: HermesExportSession): ReadonlyArray<{ + readonly id: string; + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +}> { + if (!Array.isArray(session.messages)) return []; + const fallback = isoTimestamp(session.started_at, "1970-01-01T00:00:00.000Z"); + return session.messages.flatMap((raw, index) => { + if (!raw || typeof raw !== "object") return []; + const message = raw as HermesExportMessage; + if (message.role !== "user" && message.role !== "assistant") return []; + const text = messageText(message.content).trim(); + if (!text) return []; + return [ + { + id: String(message.id ?? index), + role: message.role, + text, + createdAt: isoTimestamp(message.timestamp, fallback), + }, + ]; + }); +} + +type HermesToolItemType = + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + +interface HermesToolCall { + readonly id: string; + readonly name?: string; + readonly input?: unknown; +} + +export interface HermesSessionActivity { + readonly id: string; + readonly createdAt: string; + readonly sequence: number; + readonly tone: "tool" | "error"; + readonly summary: string; + readonly payload: { + readonly itemType: HermesToolItemType; + readonly status: "completed" | "failed"; + readonly detail?: string; + readonly data: Record; + }; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseJsonValue(value: unknown): unknown { + if (typeof value !== "string") return value; + const trimmed = value.trim(); + if (!trimmed) return undefined; + try { + return JSON.parse(trimmed) as unknown; + } catch { + // Some Hermes tools wrap JSON in an untrusted-result envelope. Retain the + // original text, but recover the structured body when one is present. + const firstObject = trimmed.indexOf("{"); + const lastObject = trimmed.lastIndexOf("}"); + if (firstObject >= 0 && lastObject > firstObject) { + try { + return JSON.parse(trimmed.slice(firstObject, lastObject + 1)) as unknown; + } catch { + return value; + } + } + return value; + } +} + +function hermesToolCalls(value: unknown): ReadonlyArray { + if (!Array.isArray(value)) return []; + return value.flatMap((raw, index) => { + const call = asRecord(raw); + const fn = asRecord(call?.function); + if (!call) return []; + const id = stringValue(call.id) ?? stringValue(call.call_id) ?? String(index); + const name = stringValue(fn?.name) ?? stringValue(call.name); + const rawInput = fn?.arguments ?? call.arguments ?? call.input; + return [{ id, ...(name ? { name } : {}), input: parseJsonValue(rawInput) }]; + }); +} + +function hermesToolItemType(name: string): HermesToolItemType { + const normalized = name.toLowerCase(); + if (["terminal", "execute_code", "process"].includes(normalized)) return "command_execution"; + if (["patch", "write_file", "file_edit", "apply_patch"].includes(normalized)) { + return "file_change"; + } + if (normalized === "web_search" || normalized === "web_extract") return "web_search"; + if ( + normalized === "vision_analyze" || + normalized === "image_view" || + normalized.includes("screenshot") + ) { + return "image_view"; + } + if (normalized === "delegate_task") return "collab_agent_tool_call"; + return "dynamic_tool_call"; +} + +function displayToolName(name: string): string { + return name + .replace(/[_-]+/g, " ") + .replace(/\b\w/g, (letter) => letter.toUpperCase()) + .trim(); +} + +function toolCommand(input: unknown): unknown { + const record = asRecord(input); + return record?.command ?? record?.cmd ?? record?.code; +} + +function isFailedToolOutput(output: unknown): boolean { + const record = asRecord(output); + if (!record) return false; + if (record.success === false || record.ok === false) return true; + if (typeof record.exit_code === "number" && record.exit_code !== 0) return true; + if (typeof record.exitCode === "number" && record.exitCode !== 0) return true; + return typeof record.error === "string" && record.error.trim().length > 0; +} + +function toolOutputRecord(content: unknown): Record { + const text = messageText(content).trim(); + const parsed = parseJsonValue(text); + const record = asRecord(parsed); + if (record) { + // Hermes' terminal tool calls its primary stream `output`; the T3 work-log + // renderer understands `stdout`. Keep both the exact export and the alias. + return typeof record.output === "string" && record.stdout === undefined + ? { ...record, stdout: record.output } + : record; + } + return text ? { content: text } : {}; +} + +function outputDetail(output: Record): string | undefined { + for (const candidate of [ + output.error, + output.stderr, + output.output, + output.stdout, + output.content, + ]) { + if (typeof candidate !== "string") continue; + const firstLine = candidate + .split(/\r?\n/u) + .map((line) => line.trim()) + .find(Boolean); + if (firstLine) return firstLine.slice(0, 240); + } + return undefined; +} + +function changedFilesFromOutput(output: Record): ReadonlyArray<{ path: string }> { + const candidates = [ + output.resolved_path, + output.path, + output.output_path, + output.screenshot_path, + output.artifact_path, + ...(Array.isArray(output.files_modified) ? output.files_modified : []), + ...(Array.isArray(output.files_created) ? output.files_created : []), + ]; + const seen = new Set(); + return candidates.flatMap((candidate) => { + const path = stringValue(candidate); + if (!path || seen.has(path)) return []; + seen.add(path); + return [{ path }]; + }); +} + +/** + * Converts Hermes tool-result rows into the same completed work-log activities + * used by live provider runtimes. Full input/output payloads remain persisted; + * snapshots apply the normal T3 payload projection before reaching clients. + */ +export function hermesSessionActivities( + session: HermesExportSession, +): ReadonlyArray { + if (!Array.isArray(session.messages)) return []; + const fallback = isoTimestamp(session.started_at, "1970-01-01T00:00:00.000Z"); + const calls = new Map(); + const activities: HermesSessionActivity[] = []; + + for (const [index, raw] of session.messages.entries()) { + const message = asRecord(raw) as HermesExportMessage | undefined; + if (!message) continue; + for (const call of hermesToolCalls(message.tool_calls)) calls.set(call.id, call); + if (message.role !== "tool" || message.display_kind === "hidden") continue; + + const callId = stringValue(message.tool_call_id) ?? `tool-${index}`; + const call = calls.get(callId); + const name = stringValue(message.tool_name) ?? call?.name ?? "tool"; + const itemType = hermesToolItemType(name); + const rawOutput = toolOutputRecord(message.content); + const failed = isFailedToolOutput(rawOutput); + const files = + itemType === "file_change" || itemType === "image_view" + ? changedFilesFromOutput(rawOutput) + : []; + const command = itemType === "command_execution" ? toolCommand(call?.input) : undefined; + const item: Record = { + type: name, + ...(call?.input !== undefined ? { input: call.input } : {}), + ...(command !== undefined ? { command } : {}), + }; + const detail = + itemType === "command_execution" + ? typeof command === "string" + ? command + : outputDetail(rawOutput) + : outputDetail(rawOutput); + + activities.push({ + id: String(message.id ?? callId), + createdAt: isoTimestamp(message.timestamp, fallback), + sequence: index, + tone: failed ? "error" : "tool", + summary: displayToolName(name) || "Tool", + payload: { + itemType, + status: failed ? "failed" : "completed", + ...(detail ? { detail } : {}), + data: { + toolCallId: callId, + toolName: name, + kind: + itemType === "command_execution" + ? "execute" + : itemType === "file_change" + ? "edit" + : itemType === "web_search" + ? "search" + : "other", + ...(call?.input !== undefined ? { rawInput: call.input } : {}), + rawOutput, + item, + ...(files.length > 0 ? { files } : {}), + }, + }, + }); + } + + return activities; +} + +export function hermesSessionTitle( + session: HermesExportSession, + messages: ReturnType, +) { + const explicit = stringValue(session.title) ?? stringValue(session.display_name); + const seed = + explicit ?? messages.find((message) => message.role === "user")?.text ?? "Hermes chat"; + return seed.replace(/\s+/g, " ").trim().slice(0, 160) || "Hermes chat"; +} + +export function parseHermesSessionsExport( + stdout: string, +): Effect.Effect, HermesImportSessionsError> { + const sessions: Array = []; + for (const [index, line] of stdout.split(/\r?\n/).entries()) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const value: unknown = JSON.parse(trimmed); + if (!value || typeof value !== "object" || Array.isArray(value)) { + return Effect.fail(importError(`Hermes export line ${index + 1} is not a session object.`)); + } + sessions.push(value as HermesExportSession); + } catch (cause) { + return Effect.fail(importError(`Hermes export line ${index + 1} is not valid JSON.`, cause)); + } + } + return Effect.succeed(sessions); +} + +const sessionIdFromResumeCursor = (cursor: unknown): string | undefined => { + if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return undefined; + return stringValue((cursor as Record).sessionId); +}; + +export const importHermesSessionsWithSettings = Effect.fn("importHermesSessionsWithSettings")( + function* ( + serverSettings: ServerSettings, + input: HermesImportSessionsInput, + ): Effect.fn.Return< + HermesImportSessionsResult, + HermesImportSessionsError, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | OrchestrationEngine.OrchestrationEngineService + | Path.Path + | ProjectionSnapshotQuery.ProjectionSnapshotQuery + | ProviderSessionDirectory.ProviderSessionDirectory + > { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const projection = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const resolved = yield* resolveImportConfig(serverSettings, input); + const env = { + ...resolved.environment, + ...(resolved.settings.homePath.trim() + ? { HERMES_HOME: resolved.settings.homePath.trim() } + : {}), + }; + const command = resolved.settings.binaryPath || "hermes"; + const spawnCommand = yield* resolveSpawnCommand(command, ["sessions", "export", "-", "--yes"], { + env, + }).pipe( + Effect.mapError((cause) => importError("Could not resolve the Hermes executable.", cause)), + ); + const result = yield* Effect.gen(function* () { + const child = yield* spawner.spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + env, + shell: spawnCommand.shell, + }), + ); + const [stdout, stderr, code] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, code }; + }).pipe( + Effect.scoped, + Effect.mapError((cause) => importError("Could not run the Hermes session exporter.", cause)), + ); + + if (result.code !== 0) { + return yield* importError( + result.stderr.trim() || `Hermes session exporter exited with code ${result.code}.`, + ); + } + + const sessions = yield* parseHermesSessionsExport(result.stdout); + const snapshot = yield* projection + .getShellSnapshot() + .pipe(Effect.mapError((cause) => importError("Could not read existing T3 threads.", cause))); + const bindings = yield* directory + .listBindings() + .pipe(Effect.mapError((cause) => importError("Could not read provider bindings.", cause))); + const hermesBindingsBySessionId = new Map(); + for (const binding of bindings) { + if (binding.provider !== "hermes" || binding.providerInstanceId !== resolved.instanceId) { + continue; + } + const sessionId = sessionIdFromResumeCursor(binding.resumeCursor); + if (sessionId) hermesBindingsBySessionId.set(sessionId, binding); + } + const importedHermesSessionIds = new Set(hermesBindingsBySessionId.keys()); + const visibleThreadIds = new Set(snapshot.threads.map((thread) => thread.id)); + const projectsByRoot = new Map( + snapshot.projects.map((project) => [project.workspaceRoot, project.id]), + ); + const ungroupedRoot = path.join(NodeOS.homedir(), ".t3", "imports", "hermes"); + yield* fileSystem + .makeDirectory(ungroupedRoot, { recursive: true }) + .pipe( + Effect.mapError((cause) => + importError("Could not prepare the ungrouped Hermes chat workspace.", cause), + ), + ); + + let imported = 0; + let removedSubagents = 0; + let skipped = 0; + let failed = 0; + const importedAt = DateTime.formatIso(yield* DateTime.now); + + for (const session of sessions) { + const hermesSessionId = stringValue(session.id); + if (!hermesSessionId) { + failed += 1; + continue; + } + const threadId = ThreadId.make(`hermes-import:${hermesSessionId}`); + if (isHermesSubagentSession(session)) { + const importedThreadId = + hermesBindingsBySessionId.get(hermesSessionId)?.threadId ?? threadId; + if (!visibleThreadIds.has(importedThreadId)) { + skipped += 1; + continue; + } + + const deletedExit = yield* Effect.exit( + engine.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`hermes-import:delete-subagent:${hermesSessionId}`), + threadId: importedThreadId, + }), + ); + if (deletedExit._tag === "Failure") { + failed += 1; + yield* Effect.logWarning("Could not remove an imported Hermes subagent session.", { + hermesSessionId, + threadId: importedThreadId, + cause: deletedExit.cause, + }); + } else { + visibleThreadIds.delete(importedThreadId); + removedSubagents += 1; + skipped += 1; + } + continue; + } + if (importedHermesSessionIds.has(hermesSessionId)) { + skipped += 1; + continue; + } + + const messages = hermesSessionMessages(session); + const activities = hermesSessionActivities(session); + if (messages.length === 0 && activities.length === 0) { + skipped += 1; + continue; + } + + const requestedRoot = stringValue(session.git_repo_root) ?? stringValue(session.cwd); + const workspaceRoot = requestedRoot + ? yield* fileSystem.stat(requestedRoot).pipe( + Effect.map((info) => (info.type === "Directory" ? requestedRoot : ungroupedRoot)), + Effect.orElseSucceed(() => ungroupedRoot), + ) + : ungroupedRoot; + + const importOne = Effect.gen(function* () { + let projectId = projectsByRoot.get(workspaceRoot); + if (!projectId) { + projectId = ProjectId.make( + `hermes-import-project:${Buffer.from(workspaceRoot).toString("base64url")}`, + ); + const createdAt = isoTimestamp(session.started_at, importedAt); + const projectCommand: OrchestrationCommand = { + type: "project.create", + commandId: CommandId.make(`hermes-import:project:${projectId}`), + projectId, + title: + workspaceRoot === ungroupedRoot + ? "Chats not in a project" + : path.basename(workspaceRoot) || "Hermes Imports", + workspaceRoot, + defaultModelSelection: { + instanceId: resolved.instanceId, + model: "hermes-agent", + }, + createdAt, + }; + yield* engine.dispatch(projectCommand); + projectsByRoot.set(workspaceRoot, projectId); + } + + const createdAt = isoTimestamp( + session.started_at, + messages[0]?.createdAt ?? activities[0]?.createdAt ?? importedAt, + ); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`hermes-import:thread:${hermesSessionId}`), + threadId, + projectId, + title: hermesSessionTitle(session, messages), + modelSelection: { + instanceId: resolved.instanceId, + model: "hermes-agent", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`hermes-import:history:${hermesSessionId}`), + threadId, + messages: messages.map((message) => ({ + messageId: MessageId.make(`hermes-import-message:${hermesSessionId}:${message.id}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + activities: activities.map((activity) => ({ + id: EventId.make(`hermes-import-activity:${hermesSessionId}:${activity.id}`), + tone: activity.tone, + kind: "tool.completed", + summary: activity.summary, + payload: activity.payload, + turnId: null, + sequence: activity.sequence, + createdAt: activity.createdAt, + })), + }); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("hermes"), + providerInstanceId: resolved.instanceId, + status: "stopped", + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: hermesSessionId }, + runtimePayload: { + modelSelection: { + instanceId: resolved.instanceId, + model: "hermes-agent", + }, + importedFrom: "hermes", + ...(stringValue(session.model) ? { importedModel: stringValue(session.model) } : {}), + }, + }); + + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`hermes-import:settle:${hermesSessionId}`), + threadId, + }); + }).pipe( + Effect.mapError((cause) => + importError(`Could not import Hermes session '${hermesSessionId}'.`, cause), + ), + ); + + const importedExit = yield* Effect.exit(importOne); + if (importedExit._tag === "Failure") { + failed += 1; + yield* Effect.logWarning("Hermes session import failed.", { + hermesSessionId, + cause: importedExit.cause, + }); + continue; + } + importedHermesSessionIds.add(hermesSessionId); + imported += 1; + } + + return { discovered: sessions.length, imported, removedSubagents, skipped, failed }; + }, +); diff --git a/apps/server/src/provider/localChatImport.test.ts b/apps/server/src/provider/localChatImport.test.ts new file mode 100644 index 000000000000..0affa62062a4 --- /dev/null +++ b/apps/server/src/provider/localChatImport.test.ts @@ -0,0 +1,117 @@ +import { expect, it } from "@effect/vitest"; + +import { parseCodexTranscript, parseOpenCodeRows } from "./localChatImport.ts"; + +it("parses Codex messages, titles, commands, outputs, and artifact paths", () => { + const transcript = [ + { + timestamp: "2026-01-01T10:00:00Z", + type: "session_meta", + payload: { id: "codex-1", cwd: "C:/repo", timestamp: "2026-01-01T10:00:00Z" }, + }, + { + timestamp: "2026-01-01T10:00:01Z", + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Build it" }], + }, + }, + { + timestamp: "2026-01-01T10:00:02Z", + type: "response_item", + payload: { + type: "function_call", + name: "exec_command", + call_id: "call-1", + arguments: '{"cmd":"pnpm build","workdir":"C:/repo"}', + }, + }, + { + timestamp: "2026-01-01T10:00:03Z", + type: "response_item", + payload: { + type: "function_call_output", + call_id: "call-1", + output: '{"stdout":"Build complete","exit_code":0}', + }, + }, + ] + .map((value) => JSON.stringify(value)) + .join("\n"); + + const session = parseCodexTranscript( + { path: "rollout.jsonl", contents: transcript }, + new Map([["codex-1", "Imported Codex work"]]), + ); + + expect(session?.id).toBe("codex-1"); + expect(session?.title).toBe("Imported Codex work"); + expect(session?.cwd).toBe("C:/repo"); + expect(session?.messages).toHaveLength(1); + expect(session?.activities[0]?.payload.itemType).toBe("command_execution"); + expect(session?.activities[0]?.payload.data.command).toBe("pnpm build"); + expect(session?.activities[0]?.payload.data.stdout).toBe("Build complete"); + expect(session?.activities[0]?.payload.data.files).toContain("C:/repo"); +}); + +it("parses OpenCode text and completed tool parts", () => { + const sessions = parseOpenCodeRows({ + sessions: [ + { + id: "oc-1", + directory: "C:/repo", + title: "OpenCode work", + model: '{"id":"model-1"}', + time_created: 1_700_000_000_000, + }, + ], + messages: [ + { + id: "message-1", + session_id: "oc-1", + time_created: 1_700_000_001_000, + data: '{"role":"assistant"}', + }, + ], + parts: [ + { + id: "part-text", + message_id: "message-1", + time_created: 1_700_000_001_000, + data: '{"type":"text","text":"Done"}', + }, + { + id: "part-tool", + message_id: "message-1", + time_created: 1_700_000_002_000, + data: JSON.stringify({ + type: "tool", + tool: "write", + state: { + status: "completed", + input: { filePath: "C:/repo/src/new.ts" }, + output: "Wrote file", + }, + }), + }, + ], + }); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.messages[0]?.text).toBe("Done"); + expect(sessions[0]?.model).toBe("model-1"); + expect(sessions[0]?.activities[0]?.payload.itemType).toBe("file_change"); + expect(sessions[0]?.activities[0]?.payload.data.files).toEqual(["C:/repo/src/new.ts"]); +}); + +it("skips malformed and empty Codex transcripts", () => { + expect(parseCodexTranscript({ path: "bad.jsonl", contents: "not-json" })).toBeNull(); + expect( + parseCodexTranscript({ + path: "empty.jsonl", + contents: JSON.stringify({ type: "session_meta", payload: { id: "empty" } }), + }), + ).toBeNull(); +}); diff --git a/apps/server/src/provider/localChatImport.ts b/apps/server/src/provider/localChatImport.ts new file mode 100644 index 000000000000..2fe4eecd2eae --- /dev/null +++ b/apps/server/src/provider/localChatImport.ts @@ -0,0 +1,790 @@ +import * as NodeOS from "node:os"; +import * as NodeSqlite from "node:sqlite"; + +import { + CodexSettings, + CommandId, + EventId, + LocalChatImportError, + type LocalChatImportInput, + type LocalChatImportPlatform, + type LocalChatImportResult, + MessageId, + OpenCodeSettings, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type ServerSettings, +} from "@t3tools/contracts"; +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 Schema from "effect/Schema"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import { resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import * as ProviderSessionDirectory from "./Services/ProviderSessionDirectory.ts"; + +export type ImportedToolItemType = + | "command_execution" + | "file_change" + | "mcp_tool_call" + | "dynamic_tool_call" + | "collab_agent_tool_call" + | "web_search" + | "image_view"; + +export interface ImportedChatMessage { + readonly id: string; + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface ImportedChatActivity { + readonly id: string; + readonly createdAt: string; + readonly sequence: number; + readonly tone: "tool" | "error"; + readonly summary: string; + readonly payload: { + readonly itemType: ImportedToolItemType; + readonly status: "completed" | "failed"; + readonly detail?: string; + readonly data: Record; + }; +} + +export interface ImportedChatSession { + readonly id: string; + readonly title: string; + readonly cwd?: string; + readonly model?: string; + readonly createdAt: string; + readonly messages: ReadonlyArray; + readonly activities: ReadonlyArray; +} + +interface ResolvedImportConfig { + readonly platform: LocalChatImportPlatform; + readonly instanceId: ProviderInstanceId; + readonly environment: NodeJS.ProcessEnv; + readonly config: CodexSettings | OpenCodeSettings; +} + +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +const decodeOpenCodeSettings = Schema.decodeUnknownEffect(OpenCodeSettings); + +function importError(platform: LocalChatImportPlatform, reason: string, cause?: unknown) { + return new LocalChatImportError({ + platform, + reason, + ...(cause === undefined ? {} : { cause }), + }); +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseJson(value: unknown): unknown { + if (typeof value !== "string") return value; + try { + return JSON.parse(value) as unknown; + } catch { + return value; + } +} + +function isoTimestamp(value: unknown, fallback = "1970-01-01T00:00:00.000Z"): string { + const parsed = + typeof value === "number" && Number.isFinite(value) + ? DateTime.make(value < 10_000_000_000 ? value * 1_000 : value) + : typeof value === "string" + ? DateTime.make(value) + : Option.none(); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function contentText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(contentText).filter(Boolean).join("\n\n"); + const record = asRecord(value); + return record + ? (stringValue(record.text) ?? stringValue(record.content) ?? stringValue(record.output) ?? "") + : ""; +} + +function toolItemType(name: string): ImportedToolItemType { + const normalized = name.toLowerCase(); + if ( + normalized.includes("exec") || + normalized.includes("shell") || + normalized.includes("terminal") || + normalized === "bash" || + normalized === "process" + ) { + return "command_execution"; + } + if ( + normalized.includes("patch") || + normalized.includes("write") || + normalized.includes("edit") || + normalized.includes("file_change") + ) { + return "file_change"; + } + if (normalized.includes("web_search") || normalized === "search") return "web_search"; + if ( + normalized.includes("image") || + normalized.includes("vision") || + normalized.includes("screenshot") + ) { + return "image_view"; + } + if ( + normalized.includes("collab") || + normalized.includes("delegate") || + normalized.includes("agent") + ) { + return "collab_agent_tool_call"; + } + if (normalized.startsWith("mcp") || normalized.includes("__")) return "mcp_tool_call"; + return "dynamic_tool_call"; +} + +function pathCandidates(value: unknown): ReadonlyArray { + const paths = new Set(); + const visit = (candidate: unknown, key = "") => { + if (typeof candidate === "string") { + if ( + /(?:path|file|image|artifact|target|destination)/i.test(key) || + /^(?:[a-zA-Z]:[\\/]|\/|\.\.?[\\/])/.test(candidate.trim()) + ) { + const trimmed = candidate.trim(); + if (trimmed) paths.add(trimmed); + } + return; + } + if (Array.isArray(candidate)) { + for (const item of candidate) visit(item, key); + return; + } + const record = asRecord(candidate); + if (!record) return; + for (const [childKey, child] of Object.entries(record)) visit(child, childKey); + }; + visit(value); + return [...paths]; +} + +function activityFromTool(input: { + readonly id: string; + readonly name: string; + readonly rawInput: unknown; + readonly rawOutput: unknown; + readonly createdAt: string; + readonly sequence: number; + readonly failed?: boolean; +}): ImportedChatActivity { + const itemType = toolItemType(input.name); + const outputRecord = asRecord(input.rawOutput); + const failed = + input.failed === true || + outputRecord?.error !== undefined || + outputRecord?.status === "failed" || + outputRecord?.status === "error"; + const detail = + stringValue(outputRecord?.error) ?? + stringValue(outputRecord?.message) ?? + (failed ? contentText(input.rawOutput).slice(0, 1_000) : undefined); + const files = [ + ...new Set([...pathCandidates(input.rawInput), ...pathCandidates(input.rawOutput)]), + ]; + const commandInput = asRecord(input.rawInput); + const command = + stringValue(commandInput?.cmd) ?? + stringValue(commandInput?.command) ?? + stringValue(commandInput?.script); + const stdout = + stringValue(outputRecord?.stdout) ?? + stringValue(outputRecord?.output) ?? + (typeof input.rawOutput === "string" ? input.rawOutput : undefined); + return { + id: input.id, + createdAt: input.createdAt, + sequence: input.sequence, + tone: failed ? "error" : "tool", + summary: failed ? `${input.name} failed` : input.name, + payload: { + itemType, + status: failed ? "failed" : "completed", + ...(detail ? { detail } : {}), + data: { + toolCallId: input.id, + toolName: input.name, + kind: + itemType === "command_execution" + ? "execute" + : itemType === "file_change" + ? "edit" + : itemType === "web_search" + ? "search" + : "other", + ...(input.rawInput !== undefined ? { rawInput: input.rawInput } : {}), + ...(input.rawOutput !== undefined ? { rawOutput: input.rawOutput } : {}), + ...(command ? { command } : {}), + ...(stdout ? { stdout } : {}), + ...(files.length > 0 ? { files } : {}), + }, + }, + }; +} + +export interface CodexTranscriptFile { + readonly path: string; + readonly contents: string; +} + +export function parseCodexTranscript( + file: CodexTranscriptFile, + titleById: ReadonlyMap = new Map(), +): ImportedChatSession | null { + let sessionId: string | undefined; + let cwd: string | undefined; + let model: string | undefined; + let createdAt = "1970-01-01T00:00:00.000Z"; + const messages: ImportedChatMessage[] = []; + const calls = new Map< + string, + { readonly name: string; readonly input: unknown; readonly createdAt: string } + >(); + const activities: ImportedChatActivity[] = []; + + for (const [lineIndex, line] of file.contents.split(/\r?\n/).entries()) { + if (!line.trim()) continue; + let envelope: Record; + try { + const decoded = JSON.parse(line) as unknown; + const record = asRecord(decoded); + if (!record) continue; + envelope = record; + } catch { + continue; + } + const timestamp = isoTimestamp(envelope.timestamp, createdAt); + const payload = asRecord(envelope.payload); + if (envelope.type === "session_meta" && payload) { + sessionId = stringValue(payload.id) ?? sessionId; + cwd = stringValue(payload.cwd) ?? cwd; + createdAt = isoTimestamp(payload.timestamp, timestamp); + model = stringValue(payload.model_provider) ?? model; + continue; + } + if (envelope.type === "turn_context" && payload) { + cwd = stringValue(payload.cwd) ?? cwd; + model = stringValue(payload.model) ?? model; + continue; + } + if (envelope.type !== "response_item" || !payload) continue; + if (payload.type === "message" && (payload.role === "user" || payload.role === "assistant")) { + const text = contentText(payload.content).trim(); + if (!text) continue; + messages.push({ + id: `${lineIndex}`, + role: payload.role, + text, + createdAt: timestamp, + }); + continue; + } + if (payload.type === "function_call" || payload.type === "custom_tool_call") { + const callId = stringValue(payload.call_id) ?? `${lineIndex}`; + const name = stringValue(payload.name) ?? stringValue(payload.tool) ?? "Tool call"; + calls.set(callId, { + name, + input: parseJson(payload.arguments ?? payload.input), + createdAt: timestamp, + }); + continue; + } + if (payload.type === "function_call_output" || payload.type === "custom_tool_call_output") { + const callId = stringValue(payload.call_id) ?? `${lineIndex}`; + const call = calls.get(callId); + activities.push( + activityFromTool({ + id: callId, + name: call?.name ?? "Tool call", + rawInput: call?.input, + rawOutput: parseJson(payload.output), + createdAt: timestamp, + sequence: activities.length, + }), + ); + } + } + + if (!sessionId || (messages.length === 0 && activities.length === 0)) return null; + const firstUserText = messages.find((message) => message.role === "user")?.text; + const title = + titleById.get(sessionId) ?? + firstUserText?.replace(/\s+/g, " ").trim().slice(0, 160) ?? + "Codex chat"; + return { + id: sessionId, + title, + ...(cwd ? { cwd } : {}), + ...(model ? { model } : {}), + createdAt, + messages, + activities, + }; +} + +interface OpenCodeSessionRow { + readonly id: unknown; + readonly directory: unknown; + readonly title: unknown; + readonly model: unknown; + readonly time_created: unknown; +} + +interface OpenCodeMessageRow { + readonly id: unknown; + readonly session_id: unknown; + readonly time_created: unknown; + readonly data: unknown; +} + +interface OpenCodePartRow { + readonly id: unknown; + readonly message_id: unknown; + readonly time_created: unknown; + readonly data: unknown; +} + +export function parseOpenCodeRows(input: { + readonly sessions: ReadonlyArray; + readonly messages: ReadonlyArray; + readonly parts: ReadonlyArray; +}): ReadonlyArray { + const partsByMessage = new Map(); + for (const part of input.parts) { + const messageId = stringValue(part.message_id); + if (!messageId) continue; + const list = partsByMessage.get(messageId) ?? []; + list.push(part); + partsByMessage.set(messageId, list); + } + const messagesBySession = new Map(); + for (const message of input.messages) { + const sessionId = stringValue(message.session_id); + if (!sessionId) continue; + const list = messagesBySession.get(sessionId) ?? []; + list.push(message); + messagesBySession.set(sessionId, list); + } + + return input.sessions.flatMap((session) => { + const sessionId = stringValue(session.id); + if (!sessionId) return []; + const messages: ImportedChatMessage[] = []; + const activities: ImportedChatActivity[] = []; + for (const message of messagesBySession.get(sessionId) ?? []) { + const messageId = stringValue(message.id); + if (!messageId) continue; + const data = asRecord(parseJson(message.data)); + const role = data?.role; + const parts = (partsByMessage.get(messageId) ?? []).toSorted( + (left, right) => Number(left.time_created) - Number(right.time_created), + ); + if (role === "user" || role === "assistant") { + const text = parts + .flatMap((part) => { + const value = asRecord(parseJson(part.data)); + return value?.type === "text" && stringValue(value.text) + ? [stringValue(value.text)!] + : []; + }) + .join("\n\n") + .trim(); + if (text) { + messages.push({ + id: messageId, + role, + text, + createdAt: isoTimestamp(message.time_created), + }); + } + } + for (const part of parts) { + const partId = stringValue(part.id); + const value = asRecord(parseJson(part.data)); + if (!partId || value?.type !== "tool") continue; + const state = asRecord(value.state); + const status = stringValue(state?.status); + activities.push( + activityFromTool({ + id: partId, + name: stringValue(value.tool) ?? "Tool call", + rawInput: state?.input, + rawOutput: state?.output, + createdAt: isoTimestamp(part.time_created ?? message.time_created), + sequence: activities.length, + failed: status === "error" || status === "failed", + }), + ); + } + } + if (messages.length === 0 && activities.length === 0) return []; + const modelRecord = asRecord(parseJson(session.model)); + const model = stringValue(modelRecord?.id); + const cwd = stringValue(session.directory); + return [ + { + id: sessionId, + title: stringValue(session.title) ?? "OpenCode chat", + ...(cwd ? { cwd } : {}), + ...(model ? { model } : {}), + createdAt: isoTimestamp(session.time_created), + messages, + activities, + }, + ]; + }); +} + +const resolveImportConfig = Effect.fn("LocalChatImport.resolveConfig")(function* ( + settings: ServerSettings, + input: LocalChatImportInput, +): Effect.fn.Return { + const instanceId = + input.instanceId ?? ProviderInstanceId.make(input.platform === "codex" ? "codex" : "opencode"); + const instance = settings.providerInstances[instanceId]; + if (instance !== undefined && instance.driver !== input.platform) { + return yield* importError( + input.platform, + `Provider instance '${instanceId}' is not a ${input.platform} instance.`, + ); + } + const rawConfig = instance?.config ?? settings.providers[input.platform]; + const config = + input.platform === "codex" + ? yield* decodeCodexSettings(rawConfig).pipe( + Effect.mapError((cause) => + importError("codex", "The Codex provider settings are invalid.", cause), + ), + ) + : yield* decodeOpenCodeSettings(rawConfig).pipe( + Effect.mapError((cause) => + importError("opencode", "The OpenCode provider settings are invalid.", cause), + ), + ); + return { + platform: input.platform, + instanceId, + environment: mergeProviderInstanceEnvironment(instance?.environment), + config, + } satisfies ResolvedImportConfig; +}); + +const loadCodexSessions = Effect.fn("LocalChatImport.loadCodexSessions")(function* ( + resolved: ResolvedImportConfig, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layout = yield* resolveCodexHomeLayout(resolved.config as CodexSettings); + const home = layout.sharedHomePath; + const titleById = new Map(); + const indexPath = path.join(home, "session_index.jsonl"); + const indexContents = yield* fileSystem + .readFileString(indexPath) + .pipe(Effect.orElseSucceed(() => "")); + for (const line of indexContents.split(/\r?\n/)) { + const value = asRecord(parseJson(line)); + const id = stringValue(value?.id); + const title = stringValue(value?.thread_name); + if (id && title) titleById.set(id, title); + } + const transcriptPaths: string[] = []; + for (const directoryName of ["sessions", "archived_sessions"]) { + const directory = path.join(home, directoryName); + const entries = yield* fileSystem + .readDirectory(directory, { recursive: true }) + .pipe(Effect.orElseSucceed(() => [] as string[])); + for (const entry of entries) { + if (entry.toLowerCase().endsWith(".jsonl")) transcriptPaths.push(path.join(directory, entry)); + } + } + const parsed = yield* Effect.forEach( + transcriptPaths, + (transcriptPath) => + fileSystem.readFileString(transcriptPath).pipe( + Effect.map((contents) => + parseCodexTranscript({ path: transcriptPath, contents }, titleById), + ), + Effect.orElseSucceed(() => null), + ), + { concurrency: 8 }, + ); + const unique = new Map(); + for (const session of parsed) if (session) unique.set(session.id, session); + return [...unique.values()]; +}); + +const loadOpenCodeSessions = Effect.fn("LocalChatImport.loadOpenCodeSessions")(function* ( + resolved: ResolvedImportConfig, +) { + const path = yield* Path.Path; + const configuredDataRoot = + stringValue(resolved.environment.OPENCODE_DATA_DIR) ?? + stringValue(resolved.environment.XDG_DATA_HOME) ?? + path.join(NodeOS.homedir(), ".local", "share"); + const databasePath = + stringValue(resolved.environment.OPENCODE_DB_PATH) ?? + path.join(expandHomePath(configuredDataRoot), "opencode", "opencode.db"); + return yield* Effect.try({ + try: () => { + const database = new NodeSqlite.DatabaseSync(databasePath, { readOnly: true }); + try { + return parseOpenCodeRows({ + sessions: database + .prepare( + "SELECT id, directory, title, model, time_created FROM session ORDER BY time_created", + ) + .all() as unknown as OpenCodeSessionRow[], + messages: database + .prepare( + "SELECT id, session_id, time_created, data FROM message ORDER BY session_id, time_created", + ) + .all() as unknown as OpenCodeMessageRow[], + parts: database + .prepare( + "SELECT id, message_id, time_created, data FROM part ORDER BY message_id, time_created", + ) + .all() as unknown as OpenCodePartRow[], + }); + } finally { + database.close(); + } + }, + catch: (cause) => importError("opencode", `Could not read '${databasePath}'.`, cause), + }); +}); + +const sessionIdFromResumeCursor = (cursor: unknown): string | undefined => { + const record = asRecord(cursor); + return stringValue(record?.sessionId); +}; + +export const importLocalChatsWithSettings = Effect.fn("importLocalChatsWithSettings")(function* ( + settings: ServerSettings, + input: LocalChatImportInput, +): Effect.fn.Return< + LocalChatImportResult, + LocalChatImportError, + | FileSystem.FileSystem + | OrchestrationEngine.OrchestrationEngineService + | Path.Path + | ProjectionSnapshotQuery.ProjectionSnapshotQuery + | ProviderSessionDirectory.ProviderSessionDirectory +> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const projection = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const resolved = yield* resolveImportConfig(settings, input); + const sessions = yield* resolved.platform === "codex" + ? loadCodexSessions(resolved) + : loadOpenCodeSessions(resolved); + const snapshot = yield* projection + .getShellSnapshot() + .pipe( + Effect.mapError((cause) => + importError(input.platform, "Could not read existing T3 threads.", cause), + ), + ); + const bindings = yield* directory + .listBindings() + .pipe( + Effect.mapError((cause) => + importError(input.platform, "Could not read provider bindings.", cause), + ), + ); + const importedSessionIds = new Set( + bindings + .filter( + (binding) => + binding.provider === resolved.platform && + binding.providerInstanceId === resolved.instanceId, + ) + .flatMap((binding) => { + const sessionId = sessionIdFromResumeCursor(binding.resumeCursor); + return sessionId ? [sessionId] : []; + }), + ); + const projectsByRoot = new Map( + snapshot.projects.map((project) => [path.resolve(project.workspaceRoot), project.id]), + ); + const virtualRoot = path.join(NodeOS.homedir(), ".t3", "imports", resolved.platform); + yield* fileSystem + .makeDirectory(virtualRoot, { recursive: true }) + .pipe( + Effect.mapError((cause) => + importError(input.platform, "Could not prepare the ungrouped chat workspace.", cause), + ), + ); + let skipped = 0; + const pending: Array<{ + readonly session: ImportedChatSession; + readonly projectId: ProjectId; + readonly threadId: ThreadId; + }> = []; + for (const session of sessions) { + if (importedSessionIds.has(session.id)) { + skipped += 1; + continue; + } + const requestedRoot = session.cwd ? path.resolve(expandHomePath(session.cwd)) : undefined; + const workspaceRoot = requestedRoot + ? yield* fileSystem.stat(requestedRoot).pipe( + Effect.map((info) => (info.type === "Directory" ? requestedRoot : virtualRoot)), + Effect.orElseSucceed(() => virtualRoot), + ) + : virtualRoot; + const threadId = ThreadId.make(`${resolved.platform}-import:${session.id}`); + let projectId = projectsByRoot.get(path.resolve(workspaceRoot)); + if (!projectId) { + projectId = ProjectId.make( + `${resolved.platform}-import-project:${Buffer.from(path.resolve(workspaceRoot)).toString("base64url")}`, + ); + const projectCommand: OrchestrationCommand = { + type: "project.create", + commandId: CommandId.make(`${resolved.platform}-import:project:${projectId}`), + projectId, + title: + workspaceRoot === virtualRoot + ? "Chats not in a project" + : path.basename(workspaceRoot) || `${resolved.platform} imports`, + workspaceRoot, + defaultModelSelection: { + instanceId: resolved.instanceId, + model: session.model ?? resolved.platform, + }, + createdAt: session.createdAt, + }; + yield* engine + .dispatch(projectCommand) + .pipe( + Effect.mapError((cause) => + importError(input.platform, `Could not prepare project '${workspaceRoot}'.`, cause), + ), + ); + projectsByRoot.set(path.resolve(workspaceRoot), projectId); + } + pending.push({ session, projectId, threadId }); + } + + const outcomes = yield* Effect.forEach( + pending, + ({ session, projectId, threadId }) => { + const importOne = Effect.gen(function* () { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${resolved.platform}-import:thread:${session.id}`), + threadId, + projectId, + title: session.title.slice(0, 160), + modelSelection: { + instanceId: resolved.instanceId, + model: session.model ?? resolved.platform, + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: session.createdAt, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`${resolved.platform}-import:history:${session.id}`), + threadId, + messages: session.messages.map((message) => ({ + messageId: MessageId.make( + `${resolved.platform}-import-message:${session.id}:${message.id}`, + ), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + activities: session.activities.map((activity) => ({ + id: EventId.make(`${resolved.platform}-import-activity:${session.id}:${activity.id}`), + tone: activity.tone, + kind: "tool.completed", + summary: activity.summary, + payload: activity.payload, + turnId: null, + sequence: activity.sequence, + createdAt: activity.createdAt, + })), + }); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make(resolved.platform), + providerInstanceId: resolved.instanceId, + status: "stopped", + runtimeMode: "full-access", + resumeCursor: { schemaVersion: 1, sessionId: session.id }, + runtimePayload: { + modelSelection: { + instanceId: resolved.instanceId, + model: session.model ?? resolved.platform, + }, + importedFrom: resolved.platform, + }, + }); + yield* engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make(`${resolved.platform}-import:settle:${session.id}`), + threadId, + }); + }).pipe( + Effect.mapError((cause) => + importError(input.platform, `Could not import session '${session.id}'.`, cause), + ), + ); + return Effect.exit(importOne).pipe(Effect.map((exit) => ({ session, exit }))); + }, + { concurrency: 4 }, + ); + let imported = 0; + let failed = 0; + for (const { session, exit } of outcomes) { + if (exit._tag === "Failure") { + failed += 1; + yield* Effect.logWarning("Local chat import failed.", { + platform: input.platform, + sessionId: session.id, + cause: exit.cause, + }); + } else { + importedSessionIds.add(session.id); + imported += 1; + } + } + return { discovered: sessions.length, imported, skipped, failed }; +}); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 5683da2c1a82..7640819320f2 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -8,10 +8,11 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3 import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import { HttpClient } from "effect/unstable/http"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { createProviderVersionAdvisory, enrichProviderSnapshotWithVersionAdvisory, + makeGitHubReleaseProviderMaintenanceCapabilities, makePackageManagedProviderMaintenanceResolver, makeProviderMaintenanceCapabilities, makeStaticProviderMaintenanceResolver, @@ -68,6 +69,13 @@ const staticToolUpdate = makeStaticProviderMaintenanceResolver( updateLockKey: "static-tool", }), ); +const hermesUpdate = makeGitHubReleaseProviderMaintenanceCapabilities({ + provider: driver("hermes"), + repository: "NousResearch/hermes-agent", + updateExecutable: "hermes", + updateArgs: ["update", "--yes"], + updateLockKey: "hermes-native", +}); const installedPackageToolProvider: ServerProvider = { instanceId: ProviderInstanceId.make("packageTool"), driver: driver("packageTool"), @@ -83,6 +91,49 @@ const installedPackageToolProvider: ServerProvider = { }; it.layer(NodeServices.layer)("providerMaintenance", (it) => { + it.effect("reads Hermes latest versions from GitHub release names", () => + resolveLatestProviderVersion(hermesUpdate).pipe( + Effect.provideService(ProviderVersionCache, new Map()), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.url).toBe( + "https://api.github.com/repos/NousResearch/hermes-agent/releases/latest", + ); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { name: "Hermes Agent v0.20.1 (2026.8.13)" }, + { headers: { "content-type": "application/json" } }, + ), + ), + ); + }), + ), + Effect.map((version) => { + expect(version).toBe("0.20.1"); + }), + ), + ); + + it("uses Hermes' native non-interactive update command", () => { + expect(hermesUpdate).toEqual({ + provider: driver("hermes"), + packageName: null, + latestVersionSource: { + kind: "github-release", + repository: "NousResearch/hermes-agent", + }, + update: { + command: "hermes update --yes", + executable: "hermes", + args: ["update", "--yes"], + lockKey: "hermes-native", + }, + }); + }); + it.effect("reads cached versions through the injectable cache reference", () => resolveLatestProviderVersion(packageToolUpdate.resolve()).pipe( Effect.provideService( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index 14d17cf365c3..5f9ce8afd876 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -40,9 +40,15 @@ const readCommandLookupEnv = CommandLookupEnvConfig.pipe(Effect.orElseSucceed(() export interface ProviderMaintenanceCapabilities { readonly provider: ProviderDriverKind; readonly packageName: string | null; + readonly latestVersionSource?: ProviderLatestVersionSource; readonly update: ProviderMaintenanceCommandAction | null; } +export interface ProviderLatestVersionSource { + readonly kind: "github-release"; + readonly repository: string; +} + export interface ProviderMaintenanceCommandAction { readonly command: string; readonly executable: string; @@ -89,6 +95,9 @@ export const ProviderVersionCache = Context.Reference 0 ? value.trim() : null; @@ -130,6 +139,28 @@ export function makeManualOnlyProviderMaintenanceCapabilities(input: { }); } +export function makeGitHubReleaseProviderMaintenanceCapabilities(input: { + readonly provider: ProviderDriverKind; + readonly repository: string; + readonly updateExecutable: string; + readonly updateArgs: ReadonlyArray; + readonly updateLockKey: string; +}): ProviderMaintenanceCapabilities { + return { + ...makeProviderMaintenanceCapabilities({ + provider: input.provider, + packageName: null, + updateExecutable: input.updateExecutable, + updateArgs: input.updateArgs, + updateLockKey: input.updateLockKey, + }), + latestVersionSource: { + kind: "github-release", + repository: input.repository, + }, + }; +} + function makeNpmGlobalProviderMaintenanceCapabilities( definition: PackageManagedProviderMaintenanceDefinition, ): ProviderMaintenanceCapabilities { @@ -453,23 +484,58 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack return payload ? nonEmptyString(payload.version) : null; }); +const fetchGitHubLatestReleaseVersion = Effect.fn("fetchGitHubLatestReleaseVersion")(function* ( + repository: string, +) { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get( + `https://api.github.com/repos/${repository}/releases/latest`, + ).pipe( + HttpClientRequest.setHeader("accept", "application/vnd.github+json"), + HttpClientRequest.setHeader("user-agent", "t3-code"), + ); + const response = yield* client.execute(request).pipe( + Effect.timeoutOption(LATEST_VERSION_TIMEOUT_MS), + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isNone(response)) { + return null; + } + const httpResponse = response.value; + if (httpResponse.status < 200 || httpResponse.status >= 300) { + return null; + } + const payload = yield* httpResponse.json.pipe( + Effect.flatMap(Schema.decodeUnknownEffect(GitHubLatestReleaseResponse)), + Effect.orElseSucceed(() => null), + ); + const releaseName = payload ? nonEmptyString(payload.name) : null; + return releaseName?.match(/\bv?(\d+\.\d+\.\d+)\b/)?.[1] ?? null; +}); + export const resolveLatestProviderVersion = Effect.fn("resolveLatestProviderVersion")(function* ( maintenanceCapabilities: ProviderMaintenanceCapabilities, ) { + const latestVersionSource = maintenanceCapabilities.latestVersionSource; const packageName = maintenanceCapabilities.packageName; - if (!packageName) { + const cacheKey = latestVersionSource + ? `${latestVersionSource.kind}:${latestVersionSource.repository}` + : packageName; + if (!cacheKey) { return null; } const latestVersionCache = yield* ProviderVersionCache; - const cached = latestVersionCache.get(packageName); + const cached = latestVersionCache.get(cacheKey); const now = DateTime.toEpochMillis(yield* DateTime.now); if (cached && cached.expiresAt > now) { return cached.version; } - const version = yield* fetchNpmLatestVersion(packageName); - latestVersionCache.set(packageName, { + const version = latestVersionSource + ? yield* fetchGitHubLatestReleaseVersion(latestVersionSource.repository) + : yield* fetchNpmLatestVersion(cacheKey); + latestVersionCache.set(cacheKey, { expiresAt: now + LATEST_VERSION_CACHE_TTL_MS, version, }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..dd470ca0568a 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -114,6 +114,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import { PersistenceSqlError } from "./persistence/Errors.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; @@ -386,6 +387,9 @@ const buildAppUnderTest = (options?: { layers?: { keybindings?: Partial; providerRegistry?: Partial; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; serverSettings?: Partial; externalLauncher?: Partial; vcsDriver?: Partial; @@ -627,18 +631,28 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([]), - refresh: () => Effect.succeed([]), - refreshInstance: () => Effect.succeed([]), - getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => - Effect.succeed( - makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), - ), - setProviderMaintenanceActionState: () => Effect.succeed([]), - streamChanges: Stream.empty, - ...options?.layers?.providerRegistry, - }), + Layer.mergeAll( + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([]), + refresh: () => Effect.succeed([]), + refreshInstance: () => Effect.succeed([]), + getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => + Effect.succeed( + makeManualOnlyProviderMaintenanceCapabilities({ provider, packageName: null }), + ), + setProviderMaintenanceActionState: () => Effect.succeed([]), + streamChanges: Stream.empty, + ...options?.layers?.providerRegistry, + }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getProvider: () => Effect.die("ProviderSessionDirectory.getProvider not stubbed"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), + ), ), Layer.provide( Layer.mock(ServerSettings.ServerSettingsService)({ diff --git a/apps/server/src/textGeneration/HermesTextGeneration.ts b/apps/server/src/textGeneration/HermesTextGeneration.ts new file mode 100644 index 000000000000..c7d144889812 --- /dev/null +++ b/apps/server/src/textGeneration/HermesTextGeneration.ts @@ -0,0 +1,25 @@ +import { TextGenerationError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import type { TextGenerationShape } from "./TextGeneration.ts"; + +type TextGenerationOperation = + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + +const unsupported = (operation: TextGenerationOperation) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes text generation is not implemented yet. Use Hermes for chat sessions.", + }), + ); + +export const makeHermesTextGeneration = (): TextGenerationShape => ({ + generateCommitMessage: () => unsupported("generateCommitMessage"), + generatePrContent: () => unsupported("generatePrContent"), + generateBranchName: () => unsupported("generateBranchName"), + generateThreadTitle: () => unsupported("generateThreadTitle"), +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..0071f214076c 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "hermes" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 56ea24a4a8b8..c65af93e53a1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -79,6 +79,9 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; +import { importHermesSessionsWithSettings } from "./provider/hermesImport.ts"; +import { importLocalChatsWithSettings } from "./provider/localChatImport.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -276,6 +279,7 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract { type: | "thread.message-sent" + | "thread.history-imported" | "thread.proposed-plan-upserted" | "thread.activity-appended" | "thread.turn-diff-completed" @@ -285,6 +289,7 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract > { return ( event.type === "thread.message-sent" || + event.type === "thread.history-imported" || event.type === "thread.proposed-plan-upserted" || event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || @@ -371,6 +376,7 @@ const makeWsRpcLayer = ( const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; + yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; @@ -1454,6 +1460,22 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverImportHermesSessions]: (input) => + observeRpcEffect( + WS_METHODS.serverImportHermesSessions, + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => importHermesSessionsWithSettings(settings, input)), + ), + { "rpc.aggregate": "server" }, + ), + [WS_METHODS.serverImportLocalChats]: (input) => + observeRpcEffect( + WS_METHODS.serverImportLocalChats, + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => importLocalChatsWithSettings(settings, input)), + ), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ae583b66bb2..2c52de142dad 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -35,12 +35,15 @@ import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, AlarmClockOffIcon, + ArchiveIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, ClockIcon, + EllipsisIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, @@ -94,6 +97,7 @@ import { buildSidebarProjectSnapshots, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; +import { buildSidebarPlatformGroups } from "../sidebarPlatformGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; @@ -121,7 +125,9 @@ import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { + archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, + buildMultiSelectThreadContextMenuItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -162,6 +168,7 @@ import { } from "./Sidebar.snooze"; import { ProjectFavicon } from "./ProjectFavicon"; import { ProviderInstanceIcon } from "./chat/ProviderInstanceIcon"; +import { getDriverOption } from "./settings/providerDriverMeta"; import { getTriggerDisplayModelLabel } from "./chat/providerIconUtils"; import { deriveProviderInstanceEntries, @@ -193,6 +200,16 @@ const SETTLED_TAIL_PAGE_COUNT = 25; // Keep the v2 key so existing preferences survive the v2-to-default rename. const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; +const SIDEBAR_PLATFORM_EXPANSION_PREFIX = "sidebar-platform:"; +const SIDEBAR_PLATFORM_PROJECT_EXPANSION_PREFIX = "sidebar-platform-project:"; + +function platformExpansionKey(platformKey: string): string { + return `${SIDEBAR_PLATFORM_EXPANSION_PREFIX}${platformKey}`; +} + +function platformProjectExpansionKey(platformKey: string, projectKey: string): string { + return `${SIDEBAR_PLATFORM_PROJECT_EXPANSION_PREFIX}${platformKey}:${projectKey}`; +} function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -728,6 +745,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { isRenaming: boolean; renamingTitle: string; onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onArchive: (threadRef: ScopedThreadRef, title: string) => void; onSettle: (threadRef: ScopedThreadRef) => void; onUnsettle: (threadRef: ScopedThreadRef) => void; onSnooze: (threadRef: ScopedThreadRef, preset: SnoozePreset) => void; @@ -747,6 +765,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onCancelRename, onCommitRename, onContextMenu, + onArchive, onAcknowledgeWoke, onRenameTitleChange, onSettle, @@ -968,6 +987,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onContextMenu, threadRef], ); + const handleActionsClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + onContextMenu(threadRef, { x: rect.right, y: rect.bottom }); + }, + [onContextMenu, threadRef], + ); + const archiveDisabled = + thread.session?.status === "running" && thread.session.activeTurnId != null; + const handleArchiveClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + if (archiveDisabled) return; + onArchive(threadRef, thread.title); + }, + [archiveDisabled, onArchive, thread.title, threadRef], + ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { if (event.target !== event.currentTarget) return; @@ -1305,6 +1344,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} + + {props.jumpLabel ? : null} {detailsTooltip} @@ -1495,6 +1555,27 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null} + +
{title} @@ -1679,6 +1760,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); @@ -1850,6 +1933,15 @@ export default function Sidebar() { ), [serverProviders], ); + const driverByInstanceId = useMemo( + () => + new Map( + [...providerEntryByInstanceId].map( + ([instanceId, entry]) => [instanceId, entry.driverKind] as const, + ), + ), + [providerEntryByInstanceId], + ); const projectCwdByKey = useMemo( () => new Map( @@ -2132,7 +2224,6 @@ export default function Sidebar() { setSettledVisibleCount(SETTLED_TAIL_INITIAL_COUNT); } const visibleSettledThreads = useMemo(() => { - if (settledThreads.length <= settledVisibleCount) return settledThreads; const visible = settledThreads.slice(0, settledVisibleCount); // The open thread must never hide under "Show more": navigating into a // deep settled thread (search, deep link) pulls its row into the visible @@ -2551,6 +2642,56 @@ export default function Sidebar() { getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), }); }, [optimisticPinnedOrder, pinnedThreads]); + const platformGroups = useMemo( + () => + buildSidebarPlatformGroups({ + threads: [ + ...orderedPinnedThreads, + ...activeThreads, + ...visibleSnoozedThreads, + ...renderedSettledThreads, + ], + totalThreads: [ + ...orderedPinnedThreads, + ...activeThreads, + ...snoozedThreads, + ...settledThreads, + ], + driverByInstanceId, + projectTitleByKey: projectDisplayNameByKey, + platformLabel: (driver, instanceId) => + (driver ? getDriverOption(driver)?.label : undefined) ?? + providerEntryByInstanceId.get(instanceId)?.displayName ?? + instanceId, + }), + [ + activeThreads, + driverByInstanceId, + orderedPinnedThreads, + projectDisplayNameByKey, + providerEntryByInstanceId, + renderedSettledThreads, + settledThreads, + snoozedThreads, + visibleSnoozedThreads, + ], + ); + const sidebarSectionByThreadKey = useMemo(() => { + const sections = new Map(); + const add = ( + section: "pinned" | "active" | "snoozed" | "settled", + items: ReadonlyArray, + ) => { + for (const thread of items) { + sections.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section); + } + }; + add("pinned", orderedPinnedThreads); + add("active", activeThreads); + add("snoozed", visibleSnoozedThreads); + add("settled", renderedSettledThreads); + return sections; + }, [activeThreads, orderedPinnedThreads, renderedSettledThreads, visibleSnoozedThreads]); useEffect(() => { if (optimisticPinnedOrder === null) return; const canonical = pinnedThreads.filter((thread) => @@ -2826,8 +2967,13 @@ export default function Sidebar() { ] : []), ...(titleRegenerationMenuItem ? [titleRegenerationMenuItem] : []), - { id: "mark-unread", label: `Mark unread (${count})` }, - { id: "delete", label: `Delete (${count})`, destructive: true }, + ...buildMultiSelectThreadContextMenuItems({ + count, + hasRunningThread: selectedThreads.some( + (thread) => + thread.session?.status === "running" && thread.session.activeTurnId != null, + ), + }), ], position, ), @@ -2938,6 +3084,37 @@ export default function Sidebar() { clearSelection(); return; } + if (clicked.value === "archive") { + if (confirmThreadArchive) { + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive ${count} thread${count === 1 ? "" : "s"}?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + const outcome = await archiveSelectedThreadEntries({ + entries: selectedThreads.map((thread) => ({ + threadKey: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + threadRef: scopeThreadRef(thread.environmentId, thread.id), + })), + archive: (entry, onArchived) => archiveThread(entry.threadRef, { onArchived }), + }); + removeFromSelection(outcome.archivedThreadKeys); + const failure = outcome.mutationFailure ?? outcome.followupFailures[0] ?? null; + if (failure !== null && !isAtomCommandInterrupted(failure)) { + const error = squashAtomCommandFailure(failure); + toastManager.add( + stackedThreadToast({ + type: outcome.mutationFailure === null ? "warning" : "error", + title: + outcome.mutationFailure === null + ? "Threads archived, but navigation failed" + : "Failed to archive threads", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return; + } if (clicked.value !== "delete") return; if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -2982,7 +3159,9 @@ export default function Sidebar() { [ attemptSettle, attemptSnooze, + archiveThread, clearSelection, + confirmThreadArchive, confirmThreadDelete, deleteThread, markThreadUnread, @@ -2995,6 +3174,40 @@ export default function Sidebar() { ], ); + const attemptArchive = useCallback( + (threadRef: ScopedThreadRef, title: string) => { + void (async () => { + if (confirmThreadArchive) { + const api = readLocalApi(); + if (!api) return; + const confirmed = await settlePromise(() => + api.dialogs.confirm(`Archive thread "${title}"?`), + ); + if (confirmed._tag === "Failure" || !confirmed.value) return; + } + let didArchive = false; + const result = await archiveThread(threadRef, { + onArchived: () => { + didArchive = true; + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: didArchive + ? "Thread archived, but navigation failed" + : "Failed to archive thread", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + })(); + }, + [archiveThread, confirmThreadArchive], + ); + const handleThreadContextMenu = useCallback( (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { void (async () => { @@ -3147,31 +3360,7 @@ export default function Sidebar() { copyThreadIdToClipboard(thread.id, { threadId: thread.id }); return; case "archive": { - if (confirmThreadArchive) { - const confirmed = await settlePromise(() => - api.dialogs.confirm(`Archive thread "${thread.title}"?`), - ); - if (confirmed._tag === "Failure" || !confirmed.value) return; - } - let didArchive = false; - const result = await archiveThread(threadRef, { - onArchived: () => { - didArchive = true; - }, - }); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: didArchive - ? "Thread archived, but navigation failed" - : "Failed to archive thread", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - return; - } + attemptArchive(threadRef, thread.title); return; } case "delete": { @@ -3207,14 +3396,13 @@ export default function Sidebar() { })(); }, [ - archiveThread, + attemptArchive, attemptPin, attemptSettle, attemptSnooze, attemptUnpin, attemptUnsettle, attemptUnsnooze, - confirmThreadArchive, confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, @@ -3696,6 +3884,7 @@ export default function Sidebar() { isRenaming={renamingThreadKey === threadKey} renamingTitle={renamingThreadKey === threadKey ? renamingTitle : ""} onContextMenu={handleThreadContextMenu} + onArchive={attemptArchive} onSettle={attemptSettle} onUnsettle={attemptUnsettle} onSnooze={attemptSnooze} @@ -3707,13 +3896,6 @@ export default function Sidebar() { /> ); }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). const items: ReactNode[] = [ , - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} - > - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); - } - return ( - - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} - - , ]; - if (pinnedThreads.length > 0) { - items.push( -
  • , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled — out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. if (snoozedThreads.length > 0) { items.push(
  • , ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } } if (settledThreads.length > 0) { items.push( @@ -3838,9 +3969,123 @@ export default function Sidebar() { , ); } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); + const hierarchyItems: ReactNode[] = []; + for (const platform of platformGroups) { + const platformExpanded = + projectExpandedById[platformExpansionKey(platform.key)] ?? true; + const PlatformIcon = + (platform.driver ? getDriverOption(platform.driver)?.icon : undefined) ?? + MessageSquareIcon; + hierarchyItems.push( +
  • + +
  • , + ); + if (!platformExpanded) continue; + for (const project of platform.projects) { + const projectExpanded = + projectExpandedById[ + platformProjectExpansionKey(platform.key, project.key) + ] ?? true; + hierarchyItems.push( +
  • + +
  • , + ); + if (!projectExpanded) continue; + for (const thread of project.threads) { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + const section = sidebarSectionByThreadKey.get(threadKey) ?? "active"; + if (section === "pinned" && reorderablePinnedKeys.has(threadKey)) { + hierarchyItems.push( + + {(bag) => renderThreadRow(thread, section, bag)} + , + ); + } else { + hierarchyItems.push(renderThreadRow(thread, section)); + } + } + } } + items.push( + + + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ) + .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} + strategy={verticalListSortingStrategy} + > + {hierarchyItems} + + , + ); return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( @@ -3851,7 +4096,7 @@ export default function Sidebar() { className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground/55 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - Show {Math.min(hiddenSettledCount, SETTLED_TAIL_PAGE_COUNT)} more + Show more ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index 773463a3835c..fedafedb35eb 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -8,6 +8,7 @@ import { import { defaultInstanceIdForDriver, type EnvironmentId, + type LocalChatImportPlatform, PROVIDER_DISPLAY_NAMES, ProviderDriverKind, type ProviderInstanceConfig, @@ -24,6 +25,7 @@ import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; import { CloudIcon, + DownloadIcon, LaptopIcon, LoaderIcon, MonitorIcon, @@ -374,10 +376,19 @@ export function EnvironmentProviderSettings({ const refreshServerProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); + const importHermesSessions = useAtomCommand(serverEnvironment.importHermesSessions, { + reportFailure: false, + }); + const importLocalChats = useAtomCommand(serverEnvironment.importLocalChats, { + reportFailure: false, + }); const updateProvider = useAtomCommand(serverEnvironment.updateProvider, { reportFailure: false, }); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const [isImportingHermes, setIsImportingHermes] = useState(false); + const [importingLocalPlatform, setImportingLocalPlatform] = + useState(null); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); const [updatingProviderDrivers, setUpdatingProviderDrivers] = useState< ReadonlySet @@ -419,6 +430,9 @@ export function EnvironmentProviderSettings({ serverProviders[0]!.checkedAt, ) : null; + const hermesInstance = serverProviders.find((provider) => provider.driver === "hermes"); + const codexInstance = serverProviders.find((provider) => provider.driver === "codex"); + const openCodeInstance = serverProviders.find((provider) => provider.driver === "opencode"); const refreshProviders = useCallback(() => { if (refreshingRef.current) return; @@ -441,6 +455,86 @@ export function EnvironmentProviderSettings({ })(); }, [environmentId, refreshServerProviders]); + const importAllHermesChats = useCallback(() => { + if (isImportingHermes || !hermesInstance) return; + setIsImportingHermes(true); + void (async () => { + const result = await importHermesSessions({ + environmentId, + input: { instanceId: hermesInstance.instanceId }, + }); + setIsImportingHermes(false); + if (result._tag === "Success") { + const { discovered, imported, removedSubagents, skipped, failed } = result.value; + toastManager.add( + stackedThreadToast({ + type: failed > 0 ? "warning" : "success", + title: + imported > 0 + ? `Imported ${imported} Hermes chat${imported === 1 ? "" : "s"}` + : "Hermes chats are up to date", + description: `${discovered} found, ${skipped} already imported, empty, or subagent${removedSubagents > 0 ? `; removed ${removedSubagents} previously imported subagent chat${removedSubagents === 1 ? "" : "s"}` : ""}${failed > 0 ? `, ${failed} failed` : ""}.`, + }), + ); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not import Hermes chats", + description: + error instanceof Error ? error.message : "The Hermes exporter could not be read.", + }), + ); + } + })(); + }, [environmentId, hermesInstance, importHermesSessions, isImportingHermes]); + + const importAllLocalChats = useCallback( + (platform: LocalChatImportPlatform, instanceId: ProviderInstanceId) => { + if (importingLocalPlatform !== null) return; + setImportingLocalPlatform(platform); + void (async () => { + const result = await importLocalChats({ + environmentId, + input: { platform, instanceId }, + }); + setImportingLocalPlatform(null); + const label = platform === "codex" ? "Codex" : "OpenCode"; + if (result._tag === "Success") { + const { discovered, imported, skipped, failed } = result.value; + toastManager.add( + stackedThreadToast({ + type: failed > 0 ? "warning" : "success", + title: + imported > 0 + ? `Imported ${imported} ${label} chat${imported === 1 ? "" : "s"}` + : `${label} chats are up to date`, + description: `${discovered} found, ${skipped} already imported or empty${failed > 0 ? `, ${failed} failed` : ""}.`, + }), + ); + return; + } + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Could not import ${label} chats`, + description: + error instanceof Error + ? error.message + : `The local ${label} history could not be read.`, + }), + ); + } + })(); + }, + [environmentId, importLocalChats, importingLocalPlatform], + ); + const runProviderUpdate = useCallback( async (candidate: ProviderUpdateCandidate) => { // Ref-based re-entry guard, mirroring refreshProviders: a state updater @@ -712,6 +806,66 @@ export function EnvironmentProviderSettings({ description={`This session can view ${environmentLabel}'s providers, but its credential does not allow changing their configuration.`} /> ) : null} + {!readOnly && hermesInstance ? ( + + + + ) : null} + {!readOnly && codexInstance ? ( + + + + ) : null} + {!readOnly && openCodeInstance ? ( + + + + ) : null}
    >; @@ -61,6 +70,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: GrokSettings, }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + icon: PiAgentIcon, + badgeLabel: "Experimental", + settingsSchema: HermesSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/sidebarPlatformGrouping.test.ts b/apps/web/src/sidebarPlatformGrouping.test.ts new file mode 100644 index 000000000000..ac0157df49b7 --- /dev/null +++ b/apps/web/src/sidebarPlatformGrouping.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + EnvironmentId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; + +import { buildSidebarPlatformGroups } from "./sidebarPlatformGrouping"; + +function thread(id: string, projectId: string, instanceId: string): EnvironmentThreadShell { + return { + id: ThreadId.make(id), + projectId: ProjectId.make(projectId), + environmentId: EnvironmentId.make("local"), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model: "model" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +describe("buildSidebarPlatformGroups", () => { + it("groups chats by platform, then project, with ungrouped chats last", () => { + const groups = buildSidebarPlatformGroups({ + threads: [ + thread("codex-loose", "loose", "codex"), + thread("open-code", "repo", "opencode"), + thread("codex-project", "repo", "codex-personal"), + ], + driverByInstanceId: new Map([ + ["codex", ProviderDriverKind.make("codex")], + ["codex-personal", ProviderDriverKind.make("codex")], + ["opencode", ProviderDriverKind.make("opencode")], + ]), + projectTitleByKey: new Map([ + ["local:repo", "t3code"], + ["local:loose", "Chats not in a project"], + ]), + platformLabel: (driver, instanceId) => driver ?? instanceId, + }); + + expect(groups.map((group) => group.label)).toEqual(["codex", "opencode"]); + expect(groups[0]?.projects.map((project) => project.title)).toEqual([ + "t3code", + "Chats not in a project", + ]); + expect(groups[0]?.threadCount).toBe(2); + }); +}); diff --git a/apps/web/src/sidebarPlatformGrouping.ts b/apps/web/src/sidebarPlatformGrouping.ts new file mode 100644 index 000000000000..224ab8f12765 --- /dev/null +++ b/apps/web/src/sidebarPlatformGrouping.ts @@ -0,0 +1,95 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import type { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; + +export const UNGROUPED_CHAT_PROJECT_TITLE = "Chats not in a project"; + +export interface SidebarPlatformProjectGroup { + readonly key: string; + readonly title: string; + readonly threads: ReadonlyArray; + readonly threadCount: number; +} + +export interface SidebarPlatformGroup { + readonly key: string; + readonly label: string; + readonly driver: ProviderDriverKind | null; + readonly projects: ReadonlyArray; + readonly threadCount: number; +} + +export function buildSidebarPlatformGroups(input: { + readonly threads: ReadonlyArray; + readonly totalThreads?: ReadonlyArray; + readonly driverByInstanceId: ReadonlyMap; + readonly projectTitleByKey: ReadonlyMap; + readonly platformLabel: ( + driver: ProviderDriverKind | null, + instanceId: ProviderInstanceId, + ) => string; +}): ReadonlyArray { + const totalPlatformCounts = new Map(); + const totalProjectCounts = new Map(); + for (const thread of input.totalThreads ?? input.threads) { + const driver = input.driverByInstanceId.get(thread.modelSelection.instanceId) ?? null; + const platformKey = driver ?? `instance:${thread.modelSelection.instanceId}`; + const projectKey = `${platformKey}:${thread.environmentId}:${thread.projectId}`; + totalPlatformCounts.set(platformKey, (totalPlatformCounts.get(platformKey) ?? 0) + 1); + totalProjectCounts.set(projectKey, (totalProjectCounts.get(projectKey) ?? 0) + 1); + } + const platforms = new Map< + string, + { + label: string; + driver: ProviderDriverKind | null; + projects: Map; + } + >(); + + for (const thread of input.threads) { + const instanceId = thread.modelSelection.instanceId; + const driver = input.driverByInstanceId.get(instanceId) ?? null; + const platformKey = driver ?? `instance:${instanceId}`; + let platform = platforms.get(platformKey); + if (!platform) { + platform = { + label: input.platformLabel(driver, instanceId), + driver, + projects: new Map(), + }; + platforms.set(platformKey, platform); + } + const projectKey = `${thread.environmentId}:${thread.projectId}`; + const projectTitle = input.projectTitleByKey.get(projectKey) ?? UNGROUPED_CHAT_PROJECT_TITLE; + let project = platform.projects.get(projectKey); + if (!project) { + project = { title: projectTitle, threads: [] }; + platform.projects.set(projectKey, project); + } + project.threads.push(thread); + } + + return [...platforms.entries()].map(([key, platform]) => { + const projects = [...platform.projects.entries()] + .map(([projectKey, project]) => ({ key: projectKey, ...project })) + .map((project) => ({ + ...project, + threadCount: totalProjectCounts.get(`${key}:${project.key}`) ?? project.threads.length, + })) + .toSorted((left, right) => { + const leftUngrouped = left.title === UNGROUPED_CHAT_PROJECT_TITLE; + const rightUngrouped = right.title === UNGROUPED_CHAT_PROJECT_TITLE; + if (leftUngrouped !== rightUngrouped) return leftUngrouped ? 1 : -1; + return 0; + }); + return { + key, + label: platform.label, + driver: platform.driver, + projects, + threadCount: + totalPlatformCounts.get(key) ?? + projects.reduce((total, project) => total + project.threads.length, 0), + }; + }); +} diff --git a/docs/README.md b/docs/README.md index 30653e7d5035..3ae31f1b6129 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,13 +6,14 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Import existing chats](./user/importing-chats.md) - [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) - [Background service (Linux)](./user/background-service.md) -- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) +- Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) · [Hermes](./user/providers-hermes.md) Mobile app: [apps/mobile/README.md](../apps/mobile/README.md) diff --git a/docs/user/importing-chats.md b/docs/user/importing-chats.md new file mode 100644 index 000000000000..bb025e3076b5 --- /dev/null +++ b/docs/user/importing-chats.md @@ -0,0 +1,40 @@ +# Importing existing chats + +T3 Code can import local chat history from Codex, OpenCode, and Hermes. + +Open **Settings → Providers**, find the platform's chat history section, and select +**Import all chats**. Re-running an import is safe: sessions that are already linked to a T3 Code +thread are skipped. + +Imports preserve the conversation text and supported work-log details, including tool calls, +commands, command output, failures, and file artifact paths. Codex imports include active and +archived sessions. OpenCode imports read the local OpenCode database. Hermes child-agent sessions +do not appear as separate chats. Re-running a Hermes import removes child-agent chats imported by +an older T3 Code version. + +## Sidebar organization + +Imported and native threads use the same hierarchy in the settled and active shelves: + +```text +Platform +└── Project + ├── Chat + └── Chat +``` + +Threads with no usable working directory appear under **Chats not in a project**, which is kept at +the end of that platform's project list. Provider instances that use the same platform, such as two +Codex accounts, remain under one platform heading. + +The project is determined from the session's recorded working directory. Importing history does not +modify the original Codex, OpenCode, or Hermes files. + +## Default history locations + +- Codex: `~/.codex/sessions` and `~/.codex/archived_sessions` +- OpenCode: `~/.local/share/opencode/opencode.db` +- Hermes: the configured Hermes home + +The importer uses the provider's configured home when available, so non-default profiles can be +imported as well. diff --git a/docs/user/providers-hermes.md b/docs/user/providers-hermes.md new file mode 100644 index 000000000000..a8b288997a96 --- /dev/null +++ b/docs/user/providers-hermes.md @@ -0,0 +1,55 @@ +# Hermes Agent + +T3 Code connects to Hermes through its Agent Client Protocol (ACP) server and can import existing +Hermes session history. + +## Set Up Hermes + +Install Hermes and complete its provider setup first: + +```bash +hermes setup +hermes --version +``` + +Open T3 Code Settings, select **Providers**, and enable Hermes. The default binary is `hermes`. If +it is not on the server's `PATH`, set **Binary path** to the full Hermes executable path. + +Leave **ACP auth method** blank in normal setups. T3 Code uses the authentication method Hermes +advertises during the ACP handshake. Set it explicitly only for a custom Hermes configuration. + +## Import All Existing Chats + +In **Settings → Providers**, find **Hermes chat history** and select **Import all chats**. + +The import: + +- reads every session from `hermes sessions export - --yes` +- creates T3 Code projects based on each session's repository root or working directory +- preserves user and assistant messages, titles, and timestamps +- links the imported thread to its original Hermes session so it can be continued +- safely skips chats that were already imported or contain no visible conversation + +Run the import again whenever you want to bring in newer Hermes chats. Existing imported sessions +are not duplicated. + +For a non-default profile, set **HERMES_HOME path** on the Hermes provider before importing. The +same provider instance and home are used for both chat sessions and history import. + +## Updates + +When provider update checks are enabled, T3 Code compares the installed Hermes version with the +latest stable Hermes GitHub release. An available release appears on the Hermes provider row in +**Settings → Providers**. + +Select **Update now** to run Hermes's supported non-interactive updater on the server machine. You +can also copy and run the displayed `hermes update --yes` command yourself. If the provider uses a +custom **Binary path**, T3 Code runs that binary for the update. + +## Troubleshooting + +- **Hermes is unavailable:** verify the configured binary runs with `hermes --version` on the T3 + Code server machine. +- **Import fails:** run `hermes sessions export - --yes` directly and check that it completes. +- **A new chat times out during startup:** run `hermes acp` or a normal `hermes` chat directly. + Hermes may be waiting on one of its configured plugins or MCP servers during session creation. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 70b3cccc962a..5bedad342ff5 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -1,9 +1,21 @@ # Organizing threads +Threads are grouped first by provider platform and then by project. Threads whose imported session +has no usable project directory appear under **Chats not in a project** at the end of the platform. +See [Importing existing chats](./importing-chats.md) for supported platforms and preserved history. + +Select a platform or project heading to collapse or expand its chats. T3 Code remembers these +choices on that device. The **Settled** shelf shows recent settled chats first; use **Show more** +to reveal the next page across all platforms. + Pin a thread from its context menu to keep it in the pinned section above your active work. Pinned threads are shown independently of their project, including when you connect to more than one environment. +Hover a thread and select its **…** button to archive or delete it. Archive hides the thread while +keeping its history; delete permanently clears it. Select multiple threads before opening the menu +to archive or delete them together. + On web and desktop, drag a pinned thread to change its position. On mobile, open the thread's menu and choose **Move up** or **Move down**. The order is stored by the server and appears on your other connected devices. diff --git a/package.json b/package.json index 3fc66d0dd021..ac0b223e01c7 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "screenshots:mobile": "node scripts/mobile-showcase.ts", "icons:export": "node scripts/export-brand-icons.ts", "icons:check": "node scripts/export-brand-icons.ts --check", - "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", + "build": "vp run --filter \"./apps/*\" --filter \"./packages/*\" --filter \"./oxlint-plugin-t3code\" --filter \"./scripts\" build", "build:marketing": "vp run --filter @t3tools/marketing build", "build:desktop": "vp run --filter @t3tools/desktop --filter t3 build", "build:resource-monitor": "cargo build --locked --release --manifest-path native/resource-monitor/Cargo.toml", diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..2b0b5207c225 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -731,6 +731,22 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), + importHermesSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:import-hermes-sessions", + tag: WS_METHODS.serverImportHermesSessions, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + }), + importLocalChats: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:import-local-chats", + tag: WS_METHODS.serverImportLocalChats, + concurrency: { + mode: "singleFlight", + key: ({ environmentId, input }) => `${environmentId}:${input.platform}`, + }, + }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, diff --git a/packages/contracts/src/chatImport.ts b/packages/contracts/src/chatImport.ts new file mode 100644 index 000000000000..54586e7af040 --- /dev/null +++ b/packages/contracts/src/chatImport.ts @@ -0,0 +1,35 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const LocalChatImportPlatform = Schema.Literals(["codex", "opencode"]); +export type LocalChatImportPlatform = typeof LocalChatImportPlatform.Type; + +export const LocalChatImportInput = Schema.Struct({ + platform: LocalChatImportPlatform, + instanceId: Schema.optionalKey(ProviderInstanceId), +}); +export type LocalChatImportInput = typeof LocalChatImportInput.Type; + +export const LocalChatImportResult = Schema.Struct({ + discovered: NonNegativeInt, + imported: NonNegativeInt, + skipped: NonNegativeInt, + failed: NonNegativeInt, +}); +export type LocalChatImportResult = typeof LocalChatImportResult.Type; + +export class LocalChatImportError extends Schema.TaggedErrorClass()( + "LocalChatImportError", + { + platform: LocalChatImportPlatform, + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const label = this.platform === "codex" ? "Codex" : "OpenCode"; + return `${label} chat import failed: ${this.reason}`; + } +} diff --git a/packages/contracts/src/hermes.ts b/packages/contracts/src/hermes.ts new file mode 100644 index 000000000000..9b65199da30c --- /dev/null +++ b/packages/contracts/src/hermes.ts @@ -0,0 +1,30 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +export const HermesImportSessionsInput = Schema.Struct({ + instanceId: Schema.optionalKey(ProviderInstanceId), +}); +export type HermesImportSessionsInput = typeof HermesImportSessionsInput.Type; + +export const HermesImportSessionsResult = Schema.Struct({ + discovered: NonNegativeInt, + imported: NonNegativeInt, + removedSubagents: NonNegativeInt, + skipped: NonNegativeInt, + failed: NonNegativeInt, +}); +export type HermesImportSessionsResult = typeof HermesImportSessionsResult.Type; + +export class HermesImportSessionsError extends Schema.TaggedErrorClass()( + "HermesImportSessionsError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Hermes chat import failed: ${this.reason}`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..187a45c31ac9 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,4 +30,6 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./hermes.ts"; +export * from "./chatImport.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..cc604d8d28d8 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); @@ -220,6 +221,7 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [HERMES_DRIVER_KIND]: "Hermes", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..0f9091268c78 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -991,6 +991,31 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadMessageImportCommand = Schema.Struct({ + type: Schema.Literal("thread.message.import"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, +}); + +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ), + activities: Schema.Array(OrchestrationThreadActivity), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1041,6 +1066,8 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadMessageImportCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -1074,6 +1101,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.runtime-mode-set", "thread.interaction-mode-set", "thread.message-sent", + "thread.history-imported", "thread.turn-start-requested", "thread.turn-interrupt-requested", "thread.approval-response-requested", @@ -1244,6 +1272,13 @@ export const ThreadMessageSentPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadHistoryImportedPayload = Schema.Struct({ + threadId: ThreadId, + messages: Schema.Array(OrchestrationMessage), + activities: Schema.Array(OrchestrationThreadActivity), + updatedAt: IsoDateTime, +}); + export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, @@ -1325,6 +1360,9 @@ export const OrchestrationEventMetadata = Schema.Struct({ adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + // Historical imports must project like ordinary messages without + // triggering live-turn side effects such as checkpoint capture. + importedHistory: Schema.optional(Schema.Boolean), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; @@ -1431,6 +1469,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.message-sent"), payload: ThreadMessageSentPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.history-imported"), + payload: ThreadHistoryImportedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-start-requested"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 115fc8a13114..836b09f1ee2a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -67,6 +67,12 @@ import { OrchestrationGetWorkflowScriptError, } from "./orchestration.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { + HermesImportSessionsError, + HermesImportSessionsInput, + HermesImportSessionsResult, +} from "./hermes.ts"; +import { LocalChatImportError, LocalChatImportInput, LocalChatImportResult } from "./chatImport.ts"; import { PullRequestActionInput, PullRequestActivity, @@ -253,6 +259,8 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverImportHermesSessions: "server.importHermesSessions", + serverImportLocalChats: "server.importLocalChats", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -350,6 +358,22 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerImportHermesSessionsRpc = Rpc.make(WS_METHODS.serverImportHermesSessions, { + payload: HermesImportSessionsInput, + success: HermesImportSessionsResult, + error: Schema.Union([ + HermesImportSessionsError, + ServerSettingsError, + EnvironmentAuthorizationError, + ]), +}); + +export const WsServerImportLocalChatsRpc = Rpc.make(WS_METHODS.serverImportLocalChats, { + payload: LocalChatImportInput, + success: LocalChatImportResult, + error: Schema.Union([LocalChatImportError, ServerSettingsError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -977,6 +1001,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerImportHermesSessionsRpc, + WsServerImportLocalChatsRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 570157292b54..cef9005852c4 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -16,6 +16,26 @@ const decodeServerSettings = Schema.decodeUnknownSync(ServerSettings); const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); const encodeServerSettings = Schema.encodeSync(ServerSettings); +describe("Hermes provider settings", () => { + it("enables the built-in Hermes CLI defaults", () => { + expect(decodeServerSettings({}).providers.hermes).toEqual({ + enabled: true, + binaryPath: "hermes", + homePath: "", + authMethodId: "", + customModels: [], + }); + }); + + it("accepts Hermes provider patches", () => { + expect( + decodeServerSettingsPatch({ + providers: { hermes: { binaryPath: "C:/tools/hermes.exe", homePath: "C:/hermes" } }, + }).providers?.hermes, + ).toEqual({ binaryPath: "C:/tools/hermes.exe", homePath: "C:/hermes" }); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 22ce210ed898..f1b3e86e4112 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -401,6 +401,45 @@ export const CursorSettings = makeProviderSettingsSchema( ); export type CursorSettings = typeof CursorSettings.Type; +export const HermesSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("hermes").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Hermes binary used by this instance.", + providerSettingsForm: { placeholder: "hermes", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "HERMES_HOME path", + description: "Custom Hermes home and config directory.", + providerSettingsForm: { placeholder: "~/.hermes", clearWhenEmpty: "omit" }, + }), + ), + authMethodId: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "ACP auth method", + description: + "Optional ACP auth method id. Leave blank to use the method Hermes advertises.", + providerSettingsForm: { placeholder: "Auto-detect", clearWhenEmpty: "omit" }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { order: ["binaryPath", "homePath", "authMethodId"] }, +); +export type HermesSettings = typeof HermesSettings.Type; + export const GrokSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -603,6 +642,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), @@ -700,6 +740,14 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + authMethodId: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -746,6 +794,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }),