History

feat: add Hermes gateway provider

Ben Davis committed 3 months ago

Commit 5b8cfdb1 parent 202e56048 files · +7430 −302Browse files

Unified · Split

48 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +56 −0
  2. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +91 −0
  3. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +9 −3
  4. apps/server/src/provider/Drivers/HermesDriver.ts +131 −0
  5. apps/server/src/provider/Layers/HermesAdapter.test.ts +171 −0
  6. apps/server/src/provider/Layers/HermesAdapter.ts +562 −0
  7. apps/server/src/provider/Layers/HermesGatewayBroker.test.ts +544 −0
  8. apps/server/src/provider/Layers/HermesGatewayBroker.ts +892 −0
  9. apps/server/src/provider/Services/HermesGatewayBroker.ts +101 −0
  10. apps/server/src/provider/builtInDrivers.ts +3 −0
  11. apps/server/src/provider/hermesGatewayHttp.ts +116 −0
  12. apps/server/src/server.ts +8 −1
  13. apps/server/src/ws.ts +30 −0
  14. apps/web/src/components/HermesIcon.tsx +18 −0
  15. apps/web/src/components/chat/ChatComposer.tsx +99 −72
  16. apps/web/src/components/chat/ComposerPrimaryActions.test.ts +40 −1
  17. apps/web/src/components/chat/ComposerPrimaryActions.tsx +54 −1
  18. apps/web/src/components/chat/providerIconUtils.ts +2 −0
  19. apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts +39 −0
  20. apps/web/src/components/settings/AddProviderInstanceDialog.test.ts +56 −1
  21. apps/web/src/components/settings/AddProviderInstanceDialog.tsx +365 −193
  22. apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts +38 −0
  23. apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts +46 −0
  24. apps/web/src/components/settings/HermesGatewayInstanceSection.tsx +276 −0
  25. apps/web/src/components/settings/ProviderInstanceCard.test.ts +12 −1
  26. apps/web/src/components/settings/ProviderInstanceCard.tsx +124 −28
  27. apps/web/src/components/settings/providerDriverMeta.ts +8 −0
  28. apps/web/src/modelSelection.ts +1 −1
  29. apps/web/src/providerInstances.test.ts +89 −0
  30. apps/web/src/providerInstances.ts +94 −0
  31. integrations/hermes-t3-gateway/COMPATIBILITY.md +48 −0
  32. integrations/hermes-t3-gateway/README.md +71 −0
  33. integrations/hermes-t3-gateway/__init__.py +77 −0
  34. integrations/hermes-t3-gateway/adapter.py +940 −0
  35. integrations/hermes-t3-gateway/cli.py +125 −0
  36. integrations/hermes-t3-gateway/connection.py +247 −0
  37. integrations/hermes-t3-gateway/plugin.yaml +24 −0
  38. integrations/hermes-t3-gateway/protocol.py +168 −0
  39. integrations/hermes-t3-gateway/tests/test_adapter.py +411 −0
  40. integrations/hermes-t3-gateway/tests/test_connection.py +103 −0
  41. integrations/hermes-t3-gateway/tests/test_protocol.py +63 −0
  42. packages/client-runtime/src/state/server.ts +16 −0
  43. packages/contracts/src/hermesGateway.test.ts +414 −0
  44. packages/contracts/src/hermesGateway.ts +574 −0
  45. packages/contracts/src/index.ts +1 −0
  46. packages/contracts/src/model.ts +4 −0
  47. packages/contracts/src/rpc.ts +50 −0
  48. packages/contracts/src/settings.ts +19 −0
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+56−0
@@ -1181,6 +1181,62 @@ describe("ProviderCommandReactor", () => { · 1180 unmodified lines
11811181 expect(harness.stopSession.mock.calls.length).toBe(0);
11821182 });
11831183
1184+ it("reuses a cwd-bookkept Hermes session for active follow-up guidance", async () => {
1185+ const harness = await createHarness({
1186+ threadModelSelection: {
1187+ instanceId: ProviderInstanceId.make("hermes"),
1188+ model: "hermes",
1189+ },
1190+ });
1191+ const now = "2026-01-01T00:00:00.000Z";
1192+
1193+ await Effect.runPromise(
1194+ harness.engine.dispatch({
1195+ type: "thread.turn.start",
1196+ commandId: CommandId.make("cmd-hermes-guidance-1"),
1197+ threadId: ThreadId.make("thread-1"),
1198+ message: {
1199+ messageId: asMessageId("user-message-hermes-guidance-1"),
1200+ role: "user",
1201+ text: "first",
1202+ attachments: [],
1203+ },
1204+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1205+ runtimeMode: "approval-required",
1206+ createdAt: now,
1207+ }),
1208+ );
1209+
1210+ await waitFor(() => harness.startSession.mock.calls.length === 1);
1211+ await waitFor(() => harness.sendTurn.mock.calls.length === 1);
1212+ expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
1213+ cwd: "/tmp/provider-project",
1214+ provider: ProviderDriverKind.make("hermes"),
1215+ providerInstanceId: ProviderInstanceId.make("hermes"),
1216+ });
1217+
1218+ await Effect.runPromise(
1219+ harness.engine.dispatch({
1220+ type: "thread.turn.start",
1221+ commandId: CommandId.make("cmd-hermes-guidance-2"),
1222+ threadId: ThreadId.make("thread-1"),
1223+ message: {
1224+ messageId: asMessageId("user-message-hermes-guidance-2"),
1225+ role: "user",
1226+ text: "follow up",
1227+ attachments: [],
1228+ },
1229+ interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1230+ runtimeMode: "approval-required",
1231+ createdAt: now,
1232+ }),
1233+ );
1234+
1235+ await waitFor(() => harness.sendTurn.mock.calls.length === 2);
1236+ expect(harness.startSession).toHaveBeenCalledTimes(1);
1237+ expect(harness.stopSession).not.toHaveBeenCalled();
1238+ });
1239+
11841240 it("restarts an existing Codex thread on a compatible requested instance", async () => {
11851241 const harness = await createHarness();
11861242 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+91−0
@@ -1873,6 +1873,97 @@ describe("ProviderRuntimeIngestion", () => { · 1872 unmodified lines
18731873 expect(message?.streaming).toBe(false);
18741874 });
18751875
1876+ it("treats turn.aborted as terminal and finalizes buffered assistant text", async () => {
1877+ const harness = await createHarness();
1878+ const now = "2026-01-01T00:00:00.000Z";
1879+ const turnId = asTurnId("turn-aborted-buffered");
1880+ const itemId = asItemId("item-aborted-buffered");
1881+
1882+ harness.emit({
1883+ type: "turn.started",
1884+ eventId: asEventId("evt-turn-started-before-abort"),
1885+ provider: ProviderDriverKind.make("hermes"),
1886+ createdAt: now,
1887+ threadId: asThreadId("thread-1"),
1888+ turnId,
1889+ });
1890+ await waitForThread(
1891+ harness.readModel,
1892+ (thread) => thread.session?.status === "running" && thread.session.activeTurnId === turnId,
1893+ );
1894+
1895+ harness.emit({
1896+ type: "content.delta",
1897+ eventId: asEventId("evt-content-before-abort"),
1898+ provider: ProviderDriverKind.make("hermes"),
1899+ createdAt: now,
1900+ threadId: asThreadId("thread-1"),
1901+ turnId,
1902+ itemId,
1903+ payload: {
1904+ streamKind: "assistant_text",
1905+ delta: "partial response",
1906+ },
1907+ });
1908+ harness.emit({
1909+ type: "turn.aborted",
1910+ eventId: asEventId("evt-turn-aborted"),
1911+ provider: ProviderDriverKind.make("hermes"),
1912+ createdAt: now,
1913+ threadId: asThreadId("thread-1"),
1914+ turnId,
1915+ payload: {
1916+ reason: "Interrupted by user.",
1917+ },
1918+ });
1919+
1920+ const thread = await waitForThread(
1921+ harness.readModel,
1922+ (entry) =>
1923+ entry.session?.status === "ready" &&
1924+ entry.session.activeTurnId === null &&
1925+ entry.messages.some(
1926+ (message: ProviderRuntimeTestMessage) =>
1927+ message.id === `assistant:${itemId}` &&
1928+ message.text === "partial response" &&
1929+ !message.streaming,
1930+ ),
1931+ );
1932+ expect(thread.session?.lastError).toBeNull();
1933+
1934+ const nextTurnId = asTurnId("turn-after-abort");
1935+ harness.emit({
1936+ type: "turn.started",
1937+ eventId: asEventId("evt-turn-started-after-abort"),
1938+ provider: ProviderDriverKind.make("hermes"),
1939+ createdAt: now,
1940+ threadId: asThreadId("thread-1"),
1941+ turnId: nextTurnId,
1942+ });
1943+ await waitForThread(
1944+ harness.readModel,
1945+ (entry) => entry.session?.status === "running" && entry.session.activeTurnId === nextTurnId,
1946+ );
1947+ harness.emit({
1948+ type: "turn.aborted",
1949+ eventId: asEventId("evt-stale-turn-aborted"),
1950+ provider: ProviderDriverKind.make("hermes"),
1951+ createdAt: now,
1952+ threadId: asThreadId("thread-1"),
1953+ turnId,
1954+ payload: {
1955+ reason: "Late abort for the previous turn.",
1956+ },
1957+ });
1958+ await harness.drain();
1959+ const afterStaleAbort = await harness.readModel();
1960+ const activeThread = afterStaleAbort.threads.find(
1961+ (entry) => entry.id === ThreadId.make("thread-1"),
1962+ );
1963+ expect(activeThread?.session?.status).toBe("running");
1964+ expect(activeThread?.session?.activeTurnId).toBe(nextTurnId);
1965+ });
1966+
18761967 it("flushes and completes buffered assistant text when an approval request opens", async () => {
18771968 const harness = await createHarness();
18781969 const now = "2026-01-01T00:00:00.000Z";
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9−3
@@ -1343,6 +1343,7 @@ const make = Effect.gen(function* () { · 1342 unmodified lines
13431343 case "turn.started":
13441344 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
13451345 case "turn.completed":
1346+ case "turn.aborted":
13461347 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
13471348 return false;
13481349 }
@@ -1367,7 +1368,8 @@ const make = Effect.gen(function* () { · 18 unmodified lines
13671368 event.type === "session.exited" ||
13681369 event.type === "thread.started" ||
13691370 event.type === "turn.started" ||
1370− event.type === "turn.completed"
1371+ event.type === "turn.completed" ||
1372+ event.type === "turn.aborted"
13711373 ) {
13721374 const status = (() => {
13731375 switch (event.type) {
@@ -1383,6 +1385,8 @@ const make = Effect.gen(function* () { · 9 unmodified lines
13831385 return normalizeRuntimeTurnState(event.payload.state) === "failed"
13841386 ? "error"
13851387 : "ready";
1388+ case "turn.aborted":
1389+ return "ready";
13861390 case "session.started":
13871391 case "thread.started":
13881392 // Provider thread/session start notifications can arrive during an
@@ -1393,7 +1397,9 @@ const make = Effect.gen(function* () { · 4 unmodified lines
13931397 const nextActiveTurnId =
13941398 event.type === "turn.started"
13951399 ? (eventTurnId ?? null)
1396− : event.type === "turn.completed" || event.type === "session.exited"
1400+ : event.type === "turn.completed" ||
1401+ event.type === "turn.aborted" ||
1402+ event.type === "session.exited"
13971403 ? null
13981404 : event.type === "session.state.changed" &&
13991405 !sessionStatusAllowsActiveTurn(
@@ -1634,7 +1640,7 @@ const make = Effect.gen(function* () { · 234 unmodified lines
16341640 });
16351641 }
16361642
1637− if (event.type === "turn.completed") {
1643+ if (event.type === "turn.completed" || event.type === "turn.aborted") {
16381644 const detailedThread = yield* getLoadedThreadDetail();
16391645 const messages = detailedThread?.messages ?? [];
16401646 const proposedPlans = detailedThread?.proposedPlans ?? [];
apps/server/src/provider/Drivers/HermesDriver.ts+131−0
@@ -0,0 +1,131 @@
1+import {
2+ DEFAULT_HERMES_MODEL,
3+ HERMES_DRIVER_KIND,
4+ HermesSettings,
5+ TextGenerationError,
6+ type ServerProvider,
7+} from "@t3tools/contracts";
8+import * as Crypto from "effect/Crypto";
9+import * as DateTime from "effect/DateTime";
10+import * as Effect from "effect/Effect";
11+import * as Schema from "effect/Schema";
12+import * as Stream from "effect/Stream";
13+
14+import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
15+import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
16+import {
17+ defaultProviderContinuationIdentity,
18+ type ProviderDriver,
19+ type ProviderInstance,
20+} from "../ProviderDriver.ts";
21+import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
22+import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts";
23+
24+const decodeHermesSettings = Schema.decodeSync(HermesSettings);
25+
26+export type HermesDriverEnv = Crypto.Crypto;
27+
28+const unsupportedTextGeneration = (
29+ operation:
30+ | "generateCommitMessage"
31+ | "generatePrContent"
32+ | "generateBranchName"
33+ | "generateThreadTitle",
34+) =>
35+ Effect.fail(
36+ new TextGenerationError({
37+ operation,
38+ detail: "Hermes gateway instances do not support utility text generation.",
39+ }),
40+ );
41+
42+const makeTextGeneration = (): TextGenerationShape => ({
43+ generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"),
44+ generatePrContent: () => unsupportedTextGeneration("generatePrContent"),
45+ generateBranchName: () => unsupportedTextGeneration("generateBranchName"),
46+ generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"),
47+});
48+
49+export const HermesDriver: ProviderDriver<HermesSettings, HermesDriverEnv> = {
50+ driverKind: HERMES_DRIVER_KIND,
51+ metadata: {
52+ displayName: "Hermes",
53+ supportsMultipleInstances: true,
54+ },
55+ configSchema: HermesSettings,
56+ defaultConfig: () => decodeHermesSettings({}),
57+ create: ({ instanceId, displayName, accentColor, enabled }) =>
58+ Effect.gen(function* () {
59+ const broker = yield* HermesGatewayBroker;
60+ const adapter = yield* makeHermesAdapter({ instanceId });
61+ const continuationIdentity = defaultProviderContinuationIdentity({
62+ driverKind: HERMES_DRIVER_KIND,
63+ instanceId,
64+ });
65+ const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({
66+ provider: HERMES_DRIVER_KIND,
67+ packageName: null,
68+ });
69+ const getSnapshot = Effect.gen(function* () {
70+ const connected = yield* broker.isConnected(instanceId);
71+ const status = yield* broker
72+ .getInstanceStatus(instanceId)
73+ .pipe(Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)));
74+ return {
75+ instanceId,
76+ driver: HERMES_DRIVER_KIND,
77+ ...(displayName ? { displayName } : {}),
78+ ...(accentColor ? { accentColor } : {}),
79+ continuation: { groupKey: continuationIdentity.continuationKey },
80+ showInteractionModeToggle: false,
81+ requiresNewThreadForModelChange: true,
82+ enabled,
83+ installed: true,
84+ version: status?.hermesVersion ?? null,
85+ status: !enabled ? "disabled" : connected ? "ready" : "warning",
86+ auth: {
87+ status: connected ? "authenticated" : "unauthenticated",
88+ type: "gateway",
89+ label: status?.nickname ?? displayName ?? "Hermes",
90+ },
91+ checkedAt: DateTime.formatIso(DateTime.nowUnsafe()),
92+ ...(!connected && enabled
93+ ? { message: "Hermes is offline. Reconnect its T3 Code gateway plugin." }
94+ : {}),
95+ availability: "available",
96+ models: [
97+ {
98+ slug: DEFAULT_HERMES_MODEL,
99+ name: "Hermes",
100+ isCustom: false,
101+ isDefault: true,
102+ capabilities: null,
103+ },
104+ ],
105+ slashCommands: [],
106+ skills: [],
107+ } satisfies ServerProvider;
108+ });
109+ const snapshot = {
110+ maintenanceCapabilities,
111+ getSnapshot,
112+ refresh: getSnapshot,
113+ streamChanges: broker.streamStatuses.pipe(
114+ Stream.filter((status) => status.instanceId === instanceId),
115+ Stream.mapEffect(() => getSnapshot),
116+ ),
117+ };
118+
119+ return {
120+ instanceId,
121+ driverKind: HERMES_DRIVER_KIND,
122+ continuationIdentity,
123+ displayName,
124+ accentColor,
125+ enabled,
126+ snapshot,
127+ adapter,
128+ textGeneration: makeTextGeneration(),
129+ } satisfies ProviderInstance;
130+ }),
131+};
apps/server/src/provider/Layers/HermesAdapter.test.ts+171−0
@@ -0,0 +1,171 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import {
4+ HERMES_GATEWAY_PROTOCOL_VERSION,
5+ HermesGatewayRequestId,
6+ HermesGatewaySessionId,
7+ ProviderInstanceId,
8+ ThreadId,
9+ type HermesGatewayT3ToPluginMessage,
10+} from "@t3tools/contracts";
11+import * as Effect from "effect/Effect";
12+import * as Fiber from "effect/Fiber";
13+import * as Option from "effect/Option";
14+import * as PubSub from "effect/PubSub";
15+import * as Stream from "effect/Stream";
16+
17+import {
18+ HermesGatewayBroker,
19+ type HermesGatewayBrokerShape,
20+ type HermesGatewayEnvelope,
21+} from "../Services/HermesGatewayBroker.ts";
22+import {
23+ makeHermesAdapter,
24+ sanitizeHermesItemData,
25+ sanitizeHermesRequestArgs,
26+ shouldProjectHermesTurnStarted,
27+} from "./HermesAdapter.ts";
28+
29+it("sanitizes persisted gateway payloads and suppresses steering acknowledgements", () => {
30+ assert.deepEqual(
31+ sanitizeHermesItemData("command_execution", {
32+ command: "git status",
33+ cwd: "/workspace",
34+ arbitrarySecret: "drop-me",
35+ result: { unbounded: true },
36+ }),
37+ { command: "git status", cwd: "/workspace" },
38+ );
39+ assert.deepEqual(
40+ sanitizeHermesRequestArgs("command_execution_approval", {
41+ command: "git push",
42+ arbitrarySecret: "drop-me",
43+ }),
44+ { command: "git push" },
45+ );
46+ const steerRequestId = HermesGatewayRequestId.make("steer-request");
47+ const startRequestId = HermesGatewayRequestId.make("start-request");
48+ const pendingSteerRequestIds = new Set([steerRequestId]);
49+ assert.isFalse(shouldProjectHermesTurnStarted(pendingSteerRequestIds, steerRequestId));
50+ assert.isTrue(shouldProjectHermesTurnStarted(pendingSteerRequestIds, startRequestId));
51+ assert.isFalse(pendingSteerRequestIds.has(steerRequestId));
52+});
53+
54+it.effect("keeps cwd local while forwarding turn text byte-for-byte and steering follow-ups", () =>
55+ Effect.gen(function* () {
56+ const instanceId = ProviderInstanceId.make("hermes_remote");
57+ const threadId = ThreadId.make("thread-1");
58+ const sessionId = HermesGatewaySessionId.make("session-1");
59+ const sent: Array<HermesGatewayT3ToPluginMessage> = [];
60+ const brokerEvents = yield* PubSub.unbounded<HermesGatewayEnvelope>();
61+ const broker: HermesGatewayBrokerShape = {
62+ createEnrollment: () => Effect.die(new Error("unused")),
63+ getInstanceStatus: () => Effect.die(new Error("unused")),
64+ listInstances: Effect.succeed([]),
65+ revokeInstance: () => Effect.die(new Error("unused")),
66+ registerConnection: () => Effect.die(new Error("unused")),
67+ receive: () => Effect.void,
68+ disconnect: () => Effect.void,
69+ request: (_instanceId, message) => {
70+ sent.push(message);
71+ if (message.type === "session.ensure") {
72+ return Effect.succeed({
73+ type: "session.ready",
74+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
75+ requestId: message.requestId,
76+ threadId: message.threadId,
77+ sessionId,
78+ resumed: false,
79+ });
80+ }
81+ if (message.type === "turn.start" || message.type === "turn.steer") {
82+ return Effect.succeed({
83+ type: "turn.started",
84+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
85+ requestId: message.requestId,
86+ threadId: message.threadId,
87+ sessionId: message.sessionId,
88+ turnId: message.turnId,
89+ });
90+ }
91+ return Effect.die(new Error(`unexpected request ${message.type}`));
92+ },
93+ send: (_instanceId, message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid),
94+ isConnected: () => Effect.succeed(true),
95+ stream: Stream.fromPubSub(brokerEvents),
96+ streamStatuses: Stream.empty,
97+ };
98+ const adapter = yield* makeHermesAdapter({ instanceId }).pipe(
99+ Effect.provideService(HermesGatewayBroker, broker),
100+ );
101+ yield* Effect.yieldNow;
102+ const session = yield* adapter.startSession({
103+ threadId,
104+ providerInstanceId: instanceId,
105+ cwd: "/must/not/leak",
106+ runtimeMode: "full-access",
107+ });
108+ assert.equal(session.cwd, "/must/not/leak");
109+ const sessionEnsure = sent.find((message) => message.type === "session.ensure");
110+ assert.isFalse(sessionEnsure !== undefined && "cwd" in sessionEnsure);
111+
112+ const original = " /help keep all whitespace \n";
113+ yield* adapter.sendTurn({ threadId, input: original });
114+ const turnStart = sent.find((message) => message.type === "turn.start");
115+ assert.equal(turnStart?.type === "turn.start" ? turnStart.text : undefined, original);
116+
117+ if (!turnStart || turnStart.type !== "turn.start") {
118+ return yield* Effect.die(new Error("turn.start was not sent"));
119+ }
120+ const startEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
121+ Effect.forkChild({ startImmediately: true }),
122+ );
123+ yield* PubSub.publish(brokerEvents, {
124+ instanceId,
125+ message: {
126+ type: "turn.started",
127+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
128+ requestId: turnStart.requestId,
129+ threadId,
130+ sessionId,
131+ turnId: turnStart.turnId,
132+ },
133+ });
134+ const startEvent = Option.getOrUndefined(yield* Fiber.join(startEventFiber));
135+ assert.equal(startEvent?.type, "turn.started");
136+
137+ yield* adapter.sendTurn({ threadId, input: "follow up" });
138+ const turnSteer = sent.find((message) => message.type === "turn.steer");
139+ if (!turnSteer || turnSteer.type !== "turn.steer") {
140+ return yield* Effect.die(new Error("turn.steer was not sent"));
141+ }
142+ const nextEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(
143+ Effect.forkChild({ startImmediately: true }),
144+ );
145+ yield* PubSub.publish(brokerEvents, {
146+ instanceId,
147+ message: {
148+ type: "turn.started",
149+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
150+ requestId: turnSteer.requestId,
151+ threadId,
152+ sessionId,
153+ turnId: turnSteer.turnId,
154+ },
155+ });
156+ yield* PubSub.publish(brokerEvents, {
157+ instanceId,
158+ message: {
159+ type: "content.delta",
160+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
161+ threadId,
162+ sessionId,
163+ turnId: turnSteer.turnId,
164+ streamKind: "assistant_text",
165+ delta: "continued",
166+ },
167+ });
168+ const nextEvent = Option.getOrUndefined(yield* Fiber.join(nextEventFiber));
169+ assert.equal(nextEvent?.type, "content.delta");
170+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
171+);
apps/server/src/provider/Layers/HermesAdapter.ts+562−0
@@ -0,0 +1,562 @@
1+import {
2+ EventId,
3+ HERMES_GATEWAY_PROTOCOL_VERSION,
4+ HermesGatewayRequestId,
5+ HermesGatewayResumeCursor,
6+ HermesGatewaySessionId,
7+ ProviderDriverKind,
8+ RuntimeItemId,
9+ RuntimeRequestId,
10+ ThreadId,
11+ TurnId,
12+ type HermesGatewayPluginToT3Message,
13+ type ProviderInstanceId,
14+ type ProviderRuntimeEvent,
15+ type ProviderSession,
16+} from "@t3tools/contracts";
17+import * as Crypto from "effect/Crypto";
18+import * as DateTime from "effect/DateTime";
19+import * as Effect from "effect/Effect";
20+import * as PubSub from "effect/PubSub";
21+import * as Schema from "effect/Schema";
22+import * as Stream from "effect/Stream";
23+
24+import {
25+ ProviderAdapterRequestError,
26+ ProviderAdapterSessionNotFoundError,
27+ ProviderAdapterValidationError,
28+} from "../Errors.ts";
29+import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
30+import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts";
31+
32+const PROVIDER = ProviderDriverKind.make("hermes");
33+const isResumeCursor = Schema.is(HermesGatewayResumeCursor);
34+
35+type HermesAdapterShape = ProviderAdapterShape<
36+ ProviderAdapterRequestError | ProviderAdapterSessionNotFoundError | ProviderAdapterValidationError
37+>;
38+
39+interface SessionContext {
40+ readonly hermesSessionId: HermesGatewaySessionId;
41+ readonly turns: Array<{ readonly id: TurnId; readonly items: Array<unknown> }>;
42+ session: ProviderSession;
43+}
44+
45+type PluginMessage = Exclude<HermesGatewayPluginToT3Message, { readonly type: "connection.hello" }>;
46+
47+const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe());
48+const MAX_PERSISTED_GATEWAY_FIELD_CHARS = 4_096;
49+
50+const boundedString = (value: unknown) =>
51+ typeof value === "string" && value.length <= MAX_PERSISTED_GATEWAY_FIELD_CHARS
52+ ? value
53+ : undefined;
54+
55+const asRecord = (value: unknown) =>
56+ typeof value === "object" && value !== null
57+ ? (value as Readonly<Record<string, unknown>>)
58+ : undefined;
59+
60+export const sanitizeHermesItemData = (
61+ itemType: Extract<PluginMessage, { readonly type: "item.started" }>["itemType"],
62+ value: unknown,
63+) => {
64+ const record = asRecord(value);
65+ if (!record) return undefined;
66+ const fields =
67+ itemType === "command_execution"
68+ ? ["command", "cwd"]
69+ : itemType === "file_change"
70+ ? ["path"]
71+ : itemType === "web_search"
72+ ? ["query"]
73+ : itemType === "image_view"
74+ ? ["path"]
75+ : itemType === "mcp_tool_call"
76+ ? ["server", "operation"]
77+ : [];
78+ const sanitized = Object.fromEntries(
79+ fields.flatMap((field) => {
80+ const value = boundedString(record[field]);
81+ return value === undefined ? [] : [[field, value]];
82+ }),
83+ );
84+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
85+};
86+
87+export const sanitizeHermesRequestArgs = (
88+ requestType: Extract<PluginMessage, { readonly type: "request.opened" }>["requestType"],
89+ value: unknown,
90+) => {
91+ const record = asRecord(value);
92+ if (!record) return undefined;
93+ const fields =
94+ requestType === "command_execution_approval" || requestType === "exec_command_approval"
95+ ? ["command", "cwd"]
96+ : requestType === "file_read_approval" ||
97+ requestType === "file_change_approval" ||
98+ requestType === "apply_patch_approval"
99+ ? ["path"]
100+ : [];
101+ const sanitized = Object.fromEntries(
102+ fields.flatMap((field) => {
103+ const value = boundedString(record[field]);
104+ return value === undefined ? [] : [[field, value]];
105+ }),
106+ );
107+ return Object.keys(sanitized).length > 0 ? sanitized : undefined;
108+};
109+
110+export const shouldProjectHermesTurnStarted = (
111+ pendingSteerRequestIds: Set<HermesGatewayRequestId>,
112+ incomingRequestId: HermesGatewayRequestId,
113+) => !pendingSteerRequestIds.delete(incomingRequestId);
114+
115+export const makeHermesAdapter = Effect.fn("makeHermesAdapter")(function* (input: {
116+ readonly instanceId: ProviderInstanceId;
117+}) {
118+ const crypto = yield* Crypto.Crypto;
119+ const broker = yield* HermesGatewayBroker;
120+ const events = yield* PubSub.unbounded<ProviderRuntimeEvent>();
121+ const sessions = new Map<ThreadId, SessionContext>();
122+ const pendingSteerRequestIds = new Set<HermesGatewayRequestId>();
123+
124+ const randomId = crypto.randomUUIDv4.pipe(
125+ Effect.mapError(
126+ (cause) =>
127+ new ProviderAdapterRequestError({
128+ provider: PROVIDER,
129+ method: "crypto/randomUUIDv4",
130+ detail: "Failed to generate a Hermes runtime identifier.",
131+ cause,
132+ }),
133+ ),
134+ );
135+ const requestId = randomId.pipe(
136+ Effect.map((value) => HermesGatewayRequestId.make(`t3-${value}`)),
137+ );
138+ const eventBase = (message: {
139+ readonly threadId: ThreadId;
140+ readonly turnId?: string | undefined;
141+ readonly itemId?: string | undefined;
142+ readonly requestId?: string | undefined;
143+ }) =>
144+ randomId.pipe(
145+ Effect.map((value) => ({
146+ eventId: EventId.make(value),
147+ provider: PROVIDER,
148+ providerInstanceId: input.instanceId,
149+ threadId: message.threadId,
150+ createdAt: nowIso(),
151+ ...(message.turnId ? { turnId: TurnId.make(message.turnId) } : {}),
152+ ...(message.itemId ? { itemId: RuntimeItemId.make(message.itemId) } : {}),
153+ ...(message.requestId ? { requestId: RuntimeRequestId.make(message.requestId) } : {}),
154+ })),
155+ );
156+
157+ const emit = (event: ProviderRuntimeEvent) => PubSub.publish(events, event).pipe(Effect.asVoid);
158+
159+ const findContext = (threadId: ThreadId) =>
160+ Effect.gen(function* (): Effect.fn.Return<SessionContext, ProviderAdapterSessionNotFoundError> {
161+ const context = sessions.get(threadId);
162+ if (!context) {
163+ return yield* new ProviderAdapterSessionNotFoundError({
164+ provider: PROVIDER,
165+ threadId,
166+ });
167+ }
168+ return context;
169+ });
170+
171+ const updateSession = (context: SessionContext, patch: Partial<ProviderSession>) => {
172+ context.session = {
173+ ...context.session,
174+ ...patch,
175+ updatedAt: nowIso(),
176+ };
177+ };
178+
179+ const toRuntimeEvent = (message: PluginMessage) =>
180+ Effect.gen(function* (): Effect.fn.Return<
181+ ProviderRuntimeEvent | undefined,
182+ ProviderAdapterRequestError
183+ > {
184+ if (!("threadId" in message)) return undefined;
185+ const context = sessions.get(message.threadId);
186+ const base = yield* eventBase(message);
187+ switch (message.type) {
188+ case "session.ready":
189+ return undefined;
190+ case "turn.started": {
191+ const isSteeringAcknowledgement = !shouldProjectHermesTurnStarted(
192+ pendingSteerRequestIds,
193+ message.requestId,
194+ );
195+ if (context) {
196+ updateSession(context, {
197+ status: "running",
198+ activeTurnId: TurnId.make(message.turnId),
199+ });
200+ if (!context.turns.some((turn) => turn.id === message.turnId)) {
201+ context.turns.push({ id: TurnId.make(message.turnId), items: [] });
202+ }
203+ }
204+ if (isSteeringAcknowledgement) return undefined;
205+ return {
206+ ...base,
207+ type: "turn.started",
208+ payload: {},
209+ };
210+ }
211+ case "content.delta":
212+ return {
213+ ...base,
214+ type: "content.delta",
215+ payload: {
216+ streamKind: message.streamKind,
217+ delta: message.delta,
218+ ...(message.contentIndex !== undefined ? { contentIndex: message.contentIndex } : {}),
219+ },
220+ };
221+ case "item.started":
222+ case "item.updated":
223+ case "item.completed": {
224+ const data = sanitizeHermesItemData(message.itemType, message.data);
225+ const payload = {
226+ itemType: message.itemType,
227+ ...(message.status ? { status: message.status } : {}),
228+ ...(message.title ? { title: message.title } : {}),
229+ ...(message.detail ? { detail: message.detail } : {}),
230+ ...(data ? { data } : {}),
231+ };
232+ const turn = context?.turns.find((entry) => entry.id === message.turnId);
233+ if (turn && message.type === "item.completed") turn.items.push(payload);
234+ return { ...base, type: message.type, payload };
235+ }
236+ case "request.opened": {
237+ const args = sanitizeHermesRequestArgs(message.requestType, message.args);
238+ return {
239+ ...base,
240+ type: "request.opened",
241+ payload: {
242+ requestType: message.requestType,
243+ ...(message.detail ? { detail: message.detail } : {}),
244+ ...(args ? { args } : {}),
245+ },
246+ };
247+ }
248+ case "request.resolved":
249+ return {
250+ ...base,
251+ type: "request.resolved",
252+ payload: {
253+ requestType: message.requestType,
254+ ...(message.decision ? { decision: message.decision } : {}),
255+ },
256+ };
257+ case "user-input.requested":
258+ return {
259+ ...base,
260+ type: "user-input.requested",
261+ payload: { questions: message.questions },
262+ };
263+ case "user-input.resolved":
264+ return {
265+ ...base,
266+ type: "user-input.resolved",
267+ payload: { answers: message.answers },
268+ };
269+ case "turn.completed":
270+ if (context) {
271+ updateSession(context, { status: message.state === "failed" ? "error" : "ready" });
272+ const { activeTurnId: _activeTurnId, ...session } = context.session;
273+ context.session = session;
274+ }
275+ return {
276+ ...base,
277+ type: "turn.completed",
278+ payload: {
279+ state: message.state,
280+ ...(message.stopReason !== undefined ? { stopReason: message.stopReason } : {}),
281+ ...(message.errorMessage ? { errorMessage: message.errorMessage } : {}),
282+ },
283+ };
284+ case "turn.aborted":
285+ if (context) {
286+ const { activeTurnId: _activeTurnId, ...session } = context.session;
287+ context.session = { ...session, status: "ready", updatedAt: nowIso() };
288+ }
289+ return {
290+ ...base,
291+ type: "turn.aborted",
292+ payload: { reason: message.reason },
293+ };
294+ case "session.exited":
295+ if (context) updateSession(context, { status: message.recoverable ? "error" : "closed" });
296+ return {
297+ ...base,
298+ type: "session.exited",
299+ payload: {
300+ ...(message.reason ? { reason: message.reason } : {}),
301+ recoverable: message.recoverable,
302+ exitKind: message.recoverable ? "error" : "graceful",
303+ },
304+ };
305+ default:
306+ return undefined;
307+ }
308+ });
309+
310+ yield* broker.stream.pipe(
311+ Stream.filter((envelope) => envelope.instanceId === input.instanceId),
312+ Stream.runForEach((envelope) =>
313+ toRuntimeEvent(envelope.message).pipe(
314+ Effect.flatMap((event) => (event ? emit(event) : Effect.void)),
315+ ),
316+ ),
317+ Effect.forkScoped,
318+ );
319+
320+ const startSession: HermesAdapterShape["startSession"] = Effect.fn("startSession")(
321+ function* (sessionInput) {
322+ const connected = yield* broker.isConnected(input.instanceId);
323+ if (!connected) {
324+ return yield* new ProviderAdapterRequestError({
325+ provider: PROVIDER,
326+ method: "session.ensure",
327+ detail: `Hermes gateway instance '${input.instanceId}' is offline.`,
328+ });
329+ }
330+ if (sessionInput.providerInstanceId && sessionInput.providerInstanceId !== input.instanceId) {
331+ return yield* new ProviderAdapterValidationError({
332+ provider: PROVIDER,
333+ operation: "startSession",
334+ issue: `Session targets instance '${sessionInput.providerInstanceId}', expected '${input.instanceId}'.`,
335+ });
336+ }
337+ const resume =
338+ sessionInput.resumeCursor === undefined
339+ ? undefined
340+ : isResumeCursor(sessionInput.resumeCursor)
341+ ? sessionInput.resumeCursor
342+ : undefined;
343+ if (sessionInput.resumeCursor !== undefined && !resume) {
344+ return yield* new ProviderAdapterValidationError({
345+ provider: PROVIDER,
346+ operation: "startSession",
347+ issue: "The Hermes resume cursor is invalid or from an unsupported protocol version.",
348+ });
349+ }
350+ const response = yield* broker.request(input.instanceId, {
351+ type: "session.ensure",
352+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
353+ requestId: yield* requestId,
354+ threadId: sessionInput.threadId,
355+ ...(resume ? { resumeSessionId: resume.sessionId } : {}),
356+ });
357+ if (response.type !== "session.ready" || response.threadId !== sessionInput.threadId) {
358+ return yield* new ProviderAdapterRequestError({
359+ provider: PROVIDER,
360+ method: "session.ensure",
361+ detail:
362+ response.type === "protocol.error"
363+ ? response.message
364+ : `Expected session.ready, received '${response.type}'.`,
365+ });
366+ }
367+ const createdAt = nowIso();
368+ const session = {
369+ provider: PROVIDER,
370+ providerInstanceId: input.instanceId,
371+ status: "ready",
372+ runtimeMode: sessionInput.runtimeMode,
373+ ...(sessionInput.cwd !== undefined ? { cwd: sessionInput.cwd } : {}),
374+ ...(sessionInput.modelSelection?.model ? { model: sessionInput.modelSelection.model } : {}),
375+ threadId: sessionInput.threadId,
376+ resumeCursor: {
377+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
378+ sessionId: response.sessionId,
379+ } satisfies HermesGatewayResumeCursor,
380+ createdAt,
381+ updatedAt: createdAt,
382+ } satisfies ProviderSession;
383+ sessions.set(sessionInput.threadId, {
384+ session,
385+ hermesSessionId: response.sessionId,
386+ turns: [],
387+ });
388+ yield* emit({
389+ ...(yield* eventBase({ threadId: sessionInput.threadId })),
390+ type: "session.started",
391+ payload: {
392+ message: response.resumed ? "Hermes session resumed" : "Hermes session started",
393+ resume: session.resumeCursor,
394+ },
395+ });
396+ yield* emit({
397+ ...(yield* eventBase({ threadId: sessionInput.threadId })),
398+ type: "thread.started",
399+ payload: { providerThreadId: response.sessionId },
400+ });
401+ return session;
402+ },
403+ );
404+
405+ const sendTurn: HermesAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (turnInput) {
406+ const context = yield* findContext(turnInput.threadId);
407+ if (turnInput.attachments && turnInput.attachments.length > 0) {
408+ return yield* new ProviderAdapterValidationError({
409+ provider: PROVIDER,
410+ operation: "sendTurn",
411+ issue: "Hermes gateway attachments are not supported yet.",
412+ });
413+ }
414+ const text = turnInput.input;
415+ if (!text || text.trim().length === 0) {
416+ return yield* new ProviderAdapterValidationError({
417+ provider: PROVIDER,
418+ operation: "sendTurn",
419+ issue: "Hermes turns require text input.",
420+ });
421+ }
422+ const activeTurnId = context.session.activeTurnId;
423+ const turnId = activeTurnId ?? TurnId.make(`hermes-turn-${yield* randomId}`);
424+ const outboundRequestId = yield* requestId;
425+ if (activeTurnId) pendingSteerRequestIds.add(outboundRequestId);
426+ const response = yield* broker
427+ .request(input.instanceId, {
428+ type: activeTurnId ? "turn.steer" : "turn.start",
429+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
430+ requestId: outboundRequestId,
431+ threadId: turnInput.threadId,
432+ sessionId: context.hermesSessionId,
433+ turnId,
434+ text,
435+ })
436+ .pipe(
437+ Effect.tapError(() => Effect.sync(() => pendingSteerRequestIds.delete(outboundRequestId))),
438+ );
439+ if (response.type !== "turn.started") {
440+ pendingSteerRequestIds.delete(outboundRequestId);
441+ return yield* new ProviderAdapterRequestError({
442+ provider: PROVIDER,
443+ method: activeTurnId ? "turn.steer" : "turn.start",
444+ detail:
445+ response.type === "protocol.error"
446+ ? response.message
447+ : `Expected turn.started, received '${response.type}'.`,
448+ });
449+ }
450+ updateSession(context, { status: "running", activeTurnId: turnId });
451+ return {
452+ threadId: turnInput.threadId,
453+ turnId,
454+ resumeCursor: context.session.resumeCursor,
455+ };
456+ });
457+
458+ const adapter: HermesAdapterShape = {
459+ provider: PROVIDER,
460+ capabilities: { sessionModelSwitch: "unsupported" },
461+ startSession,
462+ sendTurn,
463+ interruptTurn: (threadId, selectedTurnId) =>
464+ Effect.gen(function* () {
465+ const context = yield* findContext(threadId);
466+ const turnId = selectedTurnId ?? context.session.activeTurnId;
467+ if (!turnId) return;
468+ yield* broker.send(input.instanceId, {
469+ type: "turn.interrupt",
470+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
471+ requestId: yield* requestId,
472+ threadId,
473+ sessionId: context.hermesSessionId,
474+ turnId,
475+ });
476+ }),
477+ respondToRequest: (threadId, selectedRequestId, decision) =>
478+ Effect.gen(function* () {
479+ const context = yield* findContext(threadId);
480+ if (!context.session.activeTurnId) {
481+ return yield* new ProviderAdapterValidationError({
482+ provider: PROVIDER,
483+ operation: "respondToRequest",
484+ issue: "The Hermes session has no active turn.",
485+ });
486+ }
487+ yield* broker.send(input.instanceId, {
488+ type: "approval.respond",
489+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
490+ requestId: HermesGatewayRequestId.make(selectedRequestId),
491+ threadId,
492+ sessionId: context.hermesSessionId,
493+ turnId: context.session.activeTurnId,
494+ decision,
495+ });
496+ }),
497+ respondToUserInput: (threadId, selectedRequestId, answers) =>
498+ Effect.gen(function* () {
499+ const context = yield* findContext(threadId);
500+ if (!context.session.activeTurnId) {
501+ return yield* new ProviderAdapterValidationError({
502+ provider: PROVIDER,
503+ operation: "respondToUserInput",
504+ issue: "The Hermes session has no active turn.",
505+ });
506+ }
507+ yield* broker.send(input.instanceId, {
508+ type: "user-input.respond",
509+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
510+ requestId: HermesGatewayRequestId.make(selectedRequestId),
511+ threadId,
512+ sessionId: context.hermesSessionId,
513+ turnId: context.session.activeTurnId,
514+ answers,
515+ });
516+ }),
517+ stopSession: (threadId) =>
518+ Effect.gen(function* () {
519+ const context = yield* findContext(threadId);
520+ if (yield* broker.isConnected(input.instanceId)) {
521+ yield* broker.send(input.instanceId, {
522+ type: "session.stop",
523+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
524+ requestId: yield* requestId,
525+ threadId,
526+ sessionId: context.hermesSessionId,
527+ });
528+ }
529+ updateSession(context, { status: "closed" });
530+ sessions.delete(threadId);
531+ }),
532+ listSessions: () => Effect.sync(() => Array.from(sessions.values(), ({ session }) => session)),
533+ hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)),
534+ readThread: (threadId) =>
535+ findContext(threadId).pipe(
536+ Effect.map((context) => ({
537+ threadId,
538+ turns: context.turns,
539+ })),
540+ ),
541+ rollbackThread: (threadId) =>
542+ findContext(threadId).pipe(
543+ Effect.flatMap(() =>
544+ Effect.fail(
545+ new ProviderAdapterValidationError({
546+ provider: PROVIDER,
547+ operation: "rollbackThread",
548+ issue: "Hermes gateway thread rollback is not supported.",
549+ }),
550+ ),
551+ ),
552+ ),
553+ stopAll: () =>
554+ Effect.forEach(Array.from(sessions.keys()), (threadId) => adapter.stopSession(threadId), {
555+ discard: true,
556+ }),
557+ streamEvents: Stream.fromPubSub(events),
558+ };
559+
560+ yield* Effect.addFinalizer(() => adapter.stopAll().pipe(Effect.ignore));
561+ return adapter;
562+});
apps/server/src/provider/Layers/HermesGatewayBroker.test.ts+544−0
@@ -0,0 +1,544 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import {
4+ HERMES_GATEWAY_PROTOCOL_VERSION,
5+ HermesGatewayCredential,
6+ HermesGatewayRequestId,
7+ HermesGatewaySessionId,
8+ ProviderInstanceId,
9+ ThreadId,
10+ type HermesGatewayConnectionHello,
11+ type HermesGatewayT3ToPluginMessage,
12+} from "@t3tools/contracts";
13+import * as Effect from "effect/Effect";
14+import * as Fiber from "effect/Fiber";
15+import * as Context from "effect/Context";
16+import * as Deferred from "effect/Deferred";
17+import * as Layer from "effect/Layer";
18+import * as Option from "effect/Option";
19+import * as Stream from "effect/Stream";
20+
21+import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
22+import * as ServerSettings from "../../serverSettings.ts";
23+import { HermesDriver } from "../Drivers/HermesDriver.ts";
24+import type { ProviderInstance } from "../ProviderDriver.ts";
25+import {
26+ HermesGatewayBroker,
27+ type HermesGatewayTransport,
28+} from "../Services/HermesGatewayBroker.ts";
29+import { HermesGatewayBrokerLive, makeHermesGatewayBroker } from "./HermesGatewayBroker.ts";
30+
31+const instanceId = ProviderInstanceId.make("hermes_remote");
32+const otherInstanceId = ProviderInstanceId.make("hermes_other");
33+class HermesTestInstance extends Context.Service<HermesTestInstance, ProviderInstance>()(
34+ "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance",
35+) {}
36+
37+const capabilities = {
38+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
39+ streaming: true,
40+ activity: true,
41+ approvals: true,
42+ userInput: true,
43+ attachments: false,
44+} as const;
45+
46+const makeSecretStore = () => {
47+ const values = new Map<string, Uint8Array>();
48+ const service: ServerSecretStore.ServerSecretStore["Service"] = {
49+ get: (name) => Effect.succeed(Option.fromUndefinedOr(values.get(name))),
50+ set: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid),
51+ create: (name, value) => Effect.sync(() => values.set(name, value)).pipe(Effect.asVoid),
52+ getOrCreateRandom: (_name, bytes) => Effect.succeed(new Uint8Array(bytes)),
53+ remove: (name) => Effect.sync(() => values.delete(name)).pipe(Effect.asVoid),
54+ };
55+ return service;
56+};
57+
58+const makeBroker = (secrets: ServerSecretStore.ServerSecretStore["Service"]) =>
59+ makeHermesGatewayBroker.pipe(
60+ Effect.provide(
61+ ServerSettings.layerTest({
62+ providerInstances: {
63+ [instanceId]: { driver: "hermes", displayName: "Remote", config: {} },
64+ [otherInstanceId]: { driver: "hermes", displayName: "Other", config: {} },
65+ },
66+ }),
67+ ),
68+ Effect.provideService(ServerSecretStore.ServerSecretStore, secrets),
69+ Effect.provide(NodeServices.layer),
70+ );
71+
72+const hello = (
73+ authentication: HermesGatewayConnectionHello["authentication"],
74+ protocolVersion: number = HERMES_GATEWAY_PROTOCOL_VERSION,
75+): HermesGatewayConnectionHello => ({
76+ type: "connection.hello",
77+ requestId: HermesGatewayRequestId.make(`hello-${protocolVersion}`),
78+ protocolVersion,
79+ pluginVersion: "0.1.0",
80+ hermesVersion: "1.0.0",
81+ capabilities: { ...capabilities, protocolVersion },
82+ authentication,
83+});
84+
85+it.effect("authenticates before applying incompatible connection state", () =>
86+ Effect.gen(function* () {
87+ const secrets = makeSecretStore();
88+ const broker = yield* makeBroker(secrets);
89+ yield* broker.createEnrollment({
90+ instanceId: ProviderInstanceId.make("hermes"),
91+ nickname: "Default Hermes",
92+ connectorUrl: "https://t3.example.test",
93+ });
94+ const enrollment = yield* broker.createEnrollment({
95+ instanceId,
96+ nickname: "Remote Hermes",
97+ connectorUrl: "https://t3.example.test",
98+ });
99+
100+ const firstCloses: Array<number> = [];
101+ const firstTransport: HermesGatewayTransport = {
102+ send: () => Effect.void,
103+ close: (code) => Effect.sync(() => firstCloses.push(code)).pipe(Effect.asVoid),
104+ };
105+ const first = yield* broker.registerConnection(
106+ hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
107+ firstTransport,
108+ );
109+ assert.isTrue(first.accepted.credential !== undefined);
110+ assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected");
111+
112+ const secondCloses: Array<number> = [];
113+ const sent: Array<HermesGatewayT3ToPluginMessage> = [];
114+ const secondTransport: HermesGatewayTransport = {
115+ send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid),
116+ close: (code) => Effect.sync(() => secondCloses.push(code)).pipe(Effect.asVoid),
117+ };
118+ const second = yield* broker.registerConnection(
119+ hello({
120+ type: "instance-credential",
121+ instanceId,
122+ credential: first.accepted.credential!,
123+ }),
124+ secondTransport,
125+ );
126+ assert.deepEqual(firstCloses, [4001]);
127+
128+ const threadId = ThreadId.make("pending-thread");
129+ const pendingRequestId = HermesGatewayRequestId.make("pending-session");
130+ const pendingRequest = yield* broker
131+ .request(instanceId, {
132+ type: "session.ensure",
133+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
134+ requestId: pendingRequestId,
135+ threadId,
136+ })
137+ .pipe(Effect.forkChild({ startImmediately: true }));
138+ yield* Effect.yieldNow;
139+ assert.equal(sent.at(-1)?.type, "session.ensure");
140+
141+ const malicious = yield* Effect.flip(
142+ broker.registerConnection(
143+ hello(
144+ {
145+ type: "instance-credential",
146+ instanceId,
147+ credential: HermesGatewayCredential.make("not-the-real-credential"),
148+ },
149+ 2,
150+ ),
151+ secondTransport,
152+ ),
153+ );
154+ assert.equal(malicious.code, "invalid-authentication");
155+ assert.deepEqual(secondCloses, []);
156+ assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "connected");
157+ assert.isUndefined(pendingRequest.pollUnsafe());
158+
159+ yield* broker.receive(second, {
160+ type: "session.ready",
161+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
162+ requestId: pendingRequestId,
163+ threadId,
164+ sessionId: HermesGatewaySessionId.make("pending-session-id"),
165+ resumed: false,
166+ });
167+ assert.equal((yield* Fiber.join(pendingRequest)).type, "session.ready");
168+
169+ const incompatible = yield* Effect.flip(
170+ broker.registerConnection(
171+ hello(
172+ {
173+ type: "instance-credential",
174+ instanceId,
175+ credential: first.accepted.credential!,
176+ },
177+ 2,
178+ ),
179+ secondTransport,
180+ ),
181+ );
182+ assert.equal(incompatible.code, "version-incompatible");
183+ assert.deepEqual(secondCloses, [4004]);
184+ assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "upgrade-required");
185+
186+ const otherEnrollment = yield* broker.createEnrollment({
187+ instanceId: otherInstanceId,
188+ nickname: "Other Hermes",
189+ connectorUrl: "https://t3.example.test",
190+ });
191+ const incompatibleEnrollment = yield* Effect.flip(
192+ broker.registerConnection(
193+ hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }, 2),
194+ secondTransport,
195+ ),
196+ );
197+ assert.equal(incompatibleEnrollment.code, "version-incompatible");
198+ const enrolledAfterUpgrade = yield* broker.registerConnection(
199+ hello({ type: "enrollment-token", token: otherEnrollment.oneTimeToken }),
200+ secondTransport,
201+ );
202+ assert.equal(enrolledAfterUpgrade.instanceId, otherInstanceId);
203+
204+ const revoked = yield* broker.revokeInstance(instanceId);
205+ assert.equal(revoked.status, "revoked");
206+ }),
207+);
208+
209+it.effect("invalidates the previous credential when replacement enrollment begins", () =>
210+ Effect.gen(function* () {
211+ const broker = yield* makeBroker(makeSecretStore());
212+ const enrollment = yield* broker.createEnrollment({
213+ instanceId,
214+ nickname: "Remote Hermes",
215+ connectorUrl: "https://t3.example.test",
216+ });
217+ const closes: Array<number> = [];
218+ const transport: HermesGatewayTransport = {
219+ send: () => Effect.void,
220+ close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid),
221+ };
222+ const first = yield* broker.registerConnection(
223+ hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
224+ transport,
225+ );
226+ const replacement = yield* broker.createEnrollment({
227+ instanceId,
228+ nickname: "Remote Hermes",
229+ connectorUrl: "https://t3.example.test",
230+ });
231+ assert.deepEqual(closes, [4001]);
232+
233+ const staleCredential = yield* Effect.flip(
234+ broker.registerConnection(
235+ hello({
236+ type: "instance-credential",
237+ instanceId,
238+ credential: first.accepted.credential!,
239+ }),
240+ transport,
241+ ),
242+ );
243+ assert.equal(staleCredential.code, "invalid-authentication");
244+ const replacementConnection = yield* broker.registerConnection(
245+ hello({ type: "enrollment-token", token: replacement.oneTimeToken }),
246+ transport,
247+ );
248+ assert.equal(replacementConnection.instanceId, instanceId);
249+ }),
250+);
251+
252+it.effect("accepts only the newest unconsumed enrollment token for an instance", () =>
253+ Effect.gen(function* () {
254+ const broker = yield* makeBroker(makeSecretStore());
255+ const older = yield* broker.createEnrollment({
256+ instanceId,
257+ nickname: "Remote Hermes",
258+ connectorUrl: "https://t3.example.test",
259+ });
260+ const newest = yield* broker.createEnrollment({
261+ instanceId,
262+ nickname: "Remote Hermes",
263+ connectorUrl: "https://t3.example.test",
264+ });
265+ const transport: HermesGatewayTransport = {
266+ send: () => Effect.void,
267+ close: () => Effect.void,
268+ };
269+
270+ const olderTokenError = yield* Effect.flip(
271+ broker.registerConnection(
272+ hello({ type: "enrollment-token", token: older.oneTimeToken }),
273+ transport,
274+ ),
275+ );
276+ assert.equal(olderTokenError.code, "enrollment-expired");
277+ const newestConnection = yield* broker.registerConnection(
278+ hello({ type: "enrollment-token", token: newest.oneTimeToken }),
279+ transport,
280+ );
281+ assert.equal(newestConnection.instanceId, instanceId);
282+ }),
283+);
284+
285+it.effect("atomically reserves normalized nicknames across concurrent enrollments", () =>
286+ Effect.gen(function* () {
287+ const storedSecrets = makeSecretStore();
288+ const firstPersistStarted = yield* Deferred.make<void>();
289+ const releaseFirstPersist = yield* Deferred.make<void>();
290+ let metadataPersistCount = 0;
291+ const secrets: ServerSecretStore.ServerSecretStore["Service"] = {
292+ ...storedSecrets,
293+ set: (name, value) =>
294+ name.startsWith("hermes-gateway-metadata-")
295+ ? Effect.gen(function* () {
296+ metadataPersistCount += 1;
297+ if (metadataPersistCount === 1) {
298+ yield* Deferred.succeed(firstPersistStarted, undefined);
299+ yield* Deferred.await(releaseFirstPersist);
300+ }
301+ yield* storedSecrets.set(name, value);
302+ })
303+ : storedSecrets.set(name, value),
304+ };
305+ const broker = yield* makeBroker(secrets);
306+ const firstEnrollment = yield* broker
307+ .createEnrollment({
308+ instanceId,
309+ nickname: "Shared Hermes",
310+ connectorUrl: "https://t3.example.test",
311+ })
312+ .pipe(Effect.forkChild({ startImmediately: true }));
313+ yield* Deferred.await(firstPersistStarted);
314+ const secondEnrollment = yield* broker
315+ .createEnrollment({
316+ instanceId: otherInstanceId,
317+ nickname: " Shared Hermes ",
318+ connectorUrl: "https://t3.example.test",
319+ })
320+ .pipe(Effect.forkChild({ startImmediately: true }));
321+ for (let index = 0; index < 10; index += 1) {
322+ yield* Effect.yieldNow;
323+ }
324+ assert.equal(metadataPersistCount, 1);
325+
326+ yield* Deferred.succeed(releaseFirstPersist, undefined);
327+ yield* Fiber.join(firstEnrollment);
328+ const conflict = yield* Effect.flip(Fiber.join(secondEnrollment));
329+ assert.equal(conflict.code, "nickname-conflict");
330+ assert.equal(metadataPersistCount, 1);
331+ }),
332+);
333+
334+it.effect("keeps a revoked instance nickname reserved", () =>
335+ Effect.gen(function* () {
336+ const broker = yield* makeBroker(makeSecretStore());
337+ yield* broker.createEnrollment({
338+ instanceId,
339+ nickname: "Reserved Hermes",
340+ connectorUrl: "https://t3.example.test",
341+ });
342+ assert.equal((yield* broker.revokeInstance(instanceId)).status, "revoked");
343+
344+ const conflict = yield* Effect.flip(
345+ broker.createEnrollment({
346+ instanceId: otherInstanceId,
347+ nickname: " Reserved Hermes ",
348+ connectorUrl: "https://t3.example.test",
349+ }),
350+ );
351+ assert.equal(conflict.code, "nickname-conflict");
352+ }),
353+);
354+
355+it.effect("finalizes revocation when credential deletion fails", () =>
356+ Effect.gen(function* () {
357+ const storedSecrets = makeSecretStore();
358+ let failRemovals = false;
359+ const secrets: ServerSecretStore.ServerSecretStore["Service"] = {
360+ ...storedSecrets,
361+ remove: (name) =>
362+ failRemovals
363+ ? Effect.fail(
364+ new ServerSecretStore.SecretStoreRemoveError({
365+ resource: `secret ${name}`,
366+ cause: new Error("forced credential deletion failure"),
367+ }),
368+ )
369+ : storedSecrets.remove(name),
370+ };
371+ const broker = yield* makeBroker(secrets);
372+ const enrollment = yield* broker.createEnrollment({
373+ instanceId,
374+ nickname: "Remote Hermes",
375+ connectorUrl: "https://t3.example.test",
376+ });
377+ const closes: Array<number> = [];
378+ const sent: Array<HermesGatewayT3ToPluginMessage> = [];
379+ const registration = yield* broker.registerConnection(
380+ hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
381+ {
382+ send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid),
383+ close: (code) => Effect.sync(() => closes.push(code)).pipe(Effect.asVoid),
384+ },
385+ );
386+ const credential = registration.accepted.credential;
387+ if (!credential) {
388+ return yield* Effect.die(new Error("enrollment did not issue a credential"));
389+ }
390+
391+ const threadId = ThreadId.make("revoke-pending-thread");
392+ const pendingRequestId = HermesGatewayRequestId.make("revoke-pending-request");
393+ const pendingRequest = yield* broker
394+ .request(instanceId, {
395+ type: "session.ensure",
396+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
397+ requestId: pendingRequestId,
398+ threadId,
399+ })
400+ .pipe(Effect.forkChild({ startImmediately: true }));
401+ yield* Effect.yieldNow;
402+ assert.equal(sent.at(-1)?.type, "session.ensure");
403+ const statusEvent = yield* Stream.runHead(broker.streamStatuses).pipe(
404+ Effect.forkChild({ startImmediately: true }),
405+ );
406+
407+ failRemovals = true;
408+ const revokeError = yield* Effect.flip(broker.revokeInstance(instanceId));
409+ assert.equal(revokeError.code, "persistence-failed");
410+ assert.deepEqual(closes, [4003]);
411+ assert.equal((yield* broker.getInstanceStatus(instanceId)).status, "revoked");
412+ assert.equal(Option.getOrUndefined(yield* Fiber.join(statusEvent))?.status, "revoked");
413+ const pendingError = yield* Effect.flip(Fiber.join(pendingRequest));
414+ assert.include(pendingError.detail, "revoked");
415+
416+ const staleReconnect = yield* Effect.flip(
417+ broker.registerConnection(hello({ type: "instance-credential", instanceId, credential }), {
418+ send: () => Effect.void,
419+ close: () => Effect.void,
420+ }),
421+ );
422+ assert.equal(staleReconnect.code, "instance-revoked");
423+
424+ const restartedBroker = yield* makeBroker(secrets);
425+ const staleReconnectAfterRestart = yield* Effect.flip(
426+ restartedBroker.registerConnection(
427+ hello({ type: "instance-credential", instanceId, credential }),
428+ {
429+ send: () => Effect.void,
430+ close: () => Effect.void,
431+ },
432+ ),
433+ );
434+ assert.equal(staleReconnectAfterRestart.code, "instance-revoked");
435+ }),
436+);
437+
438+it.effect("shares one live broker between the gateway route and Hermes provider instance", () => {
439+ const secrets = makeSecretStore();
440+ const providerLayer = Layer.effect(
441+ HermesTestInstance,
442+ HermesDriver.create({
443+ instanceId,
444+ displayName: "Remote Hermes",
445+ environment: [],
446+ enabled: true,
447+ config: HermesDriver.defaultConfig(),
448+ }),
449+ ).pipe(
450+ Layer.provideMerge(HermesGatewayBrokerLive),
451+ Layer.provide(
452+ ServerSettings.layerTest({
453+ providerInstances: {
454+ [instanceId]: { driver: "hermes", displayName: "Remote", config: {} },
455+ },
456+ }),
457+ ),
458+ Layer.provide(Layer.succeed(ServerSecretStore.ServerSecretStore, secrets)),
459+ Layer.provide(NodeServices.layer),
460+ );
461+
462+ return Effect.gen(function* () {
463+ const broker = yield* HermesGatewayBroker;
464+ const provider = yield* HermesTestInstance;
465+ const enrollment = yield* broker.createEnrollment({
466+ instanceId,
467+ nickname: "Remote Hermes",
468+ connectorUrl: "https://t3.example.test",
469+ });
470+ const sent: Array<HermesGatewayT3ToPluginMessage> = [];
471+ const registration = yield* broker.registerConnection(
472+ hello({ type: "enrollment-token", token: enrollment.oneTimeToken }),
473+ {
474+ send: (message) => Effect.sync(() => sent.push(message)).pipe(Effect.asVoid),
475+ close: () => Effect.void,
476+ },
477+ );
478+ assert.equal((yield* provider.snapshot.getSnapshot).status, "ready");
479+
480+ const threadId = ThreadId.make("shared-broker-thread");
481+ const startSession = yield* provider.adapter
482+ .startSession({
483+ threadId,
484+ providerInstanceId: instanceId,
485+ runtimeMode: "full-access",
486+ })
487+ .pipe(Effect.forkChild({ startImmediately: true }));
488+ yield* Effect.yieldNow;
489+ const ensure = sent.at(-1);
490+ if (!ensure || ensure.type !== "session.ensure") {
491+ return yield* Effect.die(new Error("session.ensure did not reach the gateway transport"));
492+ }
493+ const sessionId = HermesGatewaySessionId.make("shared-broker-session");
494+ yield* broker.receive(registration, {
495+ type: "session.ready",
496+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
497+ requestId: ensure.requestId,
498+ threadId,
499+ sessionId,
500+ resumed: false,
501+ });
502+ yield* Fiber.join(startSession);
503+
504+ const sendTurn = yield* provider.adapter
505+ .sendTurn({ threadId, input: "hello through the shared broker" })
506+ .pipe(Effect.forkChild({ startImmediately: true }));
507+ yield* Effect.yieldNow;
508+ const turnStart = sent.at(-1);
509+ if (!turnStart || turnStart.type !== "turn.start") {
510+ return yield* Effect.die(new Error("turn.start did not reach the gateway transport"));
511+ }
512+ yield* broker.receive(registration, {
513+ type: "turn.started",
514+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
515+ requestId: turnStart.requestId,
516+ threadId,
517+ sessionId,
518+ turnId: turnStart.turnId,
519+ });
520+ assert.equal((yield* Fiber.join(sendTurn)).turnId, turnStart.turnId);
521+ }).pipe(Effect.provide(providerLayer), Effect.scoped);
522+});
523+
524+it.effect("checks nickname uniqueness from persisted metadata after restart", () =>
525+ Effect.gen(function* () {
526+ const secrets = makeSecretStore();
527+ const firstBroker = yield* makeBroker(secrets);
528+ yield* firstBroker.createEnrollment({
529+ instanceId,
530+ nickname: "Persistent Nickname",
531+ connectorUrl: "https://t3.example.test",
532+ });
533+
534+ const restartedBroker = yield* makeBroker(secrets);
535+ const error = yield* Effect.flip(
536+ restartedBroker.createEnrollment({
537+ instanceId: otherInstanceId,
538+ nickname: "Persistent Nickname",
539+ connectorUrl: "https://t3.example.test",
540+ }),
541+ );
542+ assert.equal(error.code, "nickname-conflict");
543+ }),
544+);
apps/server/src/provider/Layers/HermesGatewayBroker.ts+892−0
@@ -0,0 +1,892 @@
1+import * as NodeCrypto from "node:crypto";
2+import {
3+ HERMES_GATEWAY_PROTOCOL_VERSION,
4+ HERMES_DRIVER_KIND,
5+ defaultInstanceIdForDriver,
6+ HermesGatewayCredential,
7+ HermesGatewayEnrollmentToken,
8+ HermesGatewayCapabilities,
9+ HermesGatewayManagementError,
10+ type HermesGatewayConnectionHello,
11+ type HermesGatewayCreateEnrollmentInput,
12+ type HermesGatewayEnrollmentResult,
13+ type HermesGatewayInstanceStatus,
14+ type HermesGatewayPluginToT3Message,
15+ type HermesGatewayRevokeInstanceResult,
16+ type HermesGatewayT3ToPluginMessage,
17+ type ProviderInstanceId,
18+} from "@t3tools/contracts";
19+import * as Crypto from "effect/Crypto";
20+import * as Clock from "effect/Clock";
21+import * as DateTime from "effect/DateTime";
22+import * as Deferred from "effect/Deferred";
23+import * as Duration from "effect/Duration";
24+import * as Effect from "effect/Effect";
25+import * as Encoding from "effect/Encoding";
26+import * as Layer from "effect/Layer";
27+import * as Option from "effect/Option";
28+import * as PubSub from "effect/PubSub";
29+import * as Ref from "effect/Ref";
30+import * as Schema from "effect/Schema";
31+import * as Semaphore from "effect/Semaphore";
32+import * as Stream from "effect/Stream";
33+
34+import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
35+import { ServerSettingsService } from "../../serverSettings.ts";
36+import { ProviderAdapterRequestError } from "../Errors.ts";
37+import {
38+ HermesGatewayBroker,
39+ type HermesGatewayConnectionRegistration,
40+ type HermesGatewayEnvelope,
41+ type HermesGatewayTransport,
42+ type HermesGatewayBrokerShape,
43+} from "../Services/HermesGatewayBroker.ts";
44+
45+const ENROLLMENT_TTL = Duration.minutes(10);
46+const REQUEST_TIMEOUT = Duration.seconds(30);
47+const textEncoder = new TextEncoder();
48+const textDecoder = new TextDecoder();
49+const isStrictCapabilities = Schema.is(HermesGatewayCapabilities);
50+
51+interface PendingEnrollment {
52+ readonly input: HermesGatewayCreateEnrollmentInput;
53+ readonly expiresAtMillis: number;
54+}
55+
56+interface InstanceMetadata {
57+ readonly nickname: string;
58+ readonly connectorUrl: string;
59+ readonly revoked: boolean;
60+ readonly lastSeen?: {
61+ readonly pluginVersion: string;
62+ readonly hermesVersion: string;
63+ readonly capabilities: NonNullable<HermesGatewayInstanceStatus["capabilities"]>;
64+ readonly connectedAt: string;
65+ readonly activeSessionCount: number;
66+ };
67+}
68+
69+interface ActiveConnection {
70+ readonly generation: number;
71+ readonly transport: HermesGatewayTransport;
72+ readonly pluginVersion: string;
73+ readonly hermesVersion: string;
74+ readonly capabilities: NonNullable<HermesGatewayInstanceStatus["capabilities"]>;
75+ readonly connectedAt: string;
76+ readonly activeSessionCount: number;
77+}
78+
79+interface InstanceState {
80+ readonly metadata: InstanceMetadata;
81+ readonly connection?: ActiveConnection;
82+ readonly lastSeen?: Omit<ActiveConnection, "transport" | "generation" | "activeSessionCount"> & {
83+ readonly activeSessionCount: number;
84+ };
85+ readonly upgradeRequired?: {
86+ readonly pluginVersion: string;
87+ readonly hermesVersion: string;
88+ readonly protocolVersion: number;
89+ };
90+}
91+
92+type PluginMessage = Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>;
93+
94+const credentialSecretName = (instanceId: ProviderInstanceId) =>
95+ `hermes-gateway-credential-${Buffer.from(instanceId, "utf8").toString("base64url")}`;
96+
97+const metadataSecretName = (instanceId: ProviderInstanceId) =>
98+ `hermes-gateway-metadata-${Buffer.from(instanceId, "utf8").toString("base64url")}`;
99+
100+const managementError = (
101+ operation: HermesGatewayManagementError["operation"],
102+ code: HermesGatewayManagementError["code"],
103+ message: string,
104+ instanceId?: ProviderInstanceId,
105+) =>
106+ new HermesGatewayManagementError({
107+ operation,
108+ code,
109+ message,
110+ ...(instanceId ? { instanceId } : {}),
111+ });
112+
113+const rejection = (
114+ requestId: HermesGatewayConnectionHello["requestId"],
115+ code: Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }>["code"],
116+ message: string,
117+): Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }> => ({
118+ type: "connection.rejected",
119+ requestId,
120+ code,
121+ message,
122+ expectedProtocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
123+});
124+
125+const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
126+
127+const credentialsEqual = (left: Uint8Array, right: string) => {
128+ const rightBytes = textEncoder.encode(right);
129+ return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes);
130+};
131+
132+const statusFromState = (
133+ instanceId: ProviderInstanceId,
134+ state: InstanceState,
135+): HermesGatewayInstanceStatus => ({
136+ ...(() => {
137+ const observed = state.connection ?? state.lastSeen ?? state.metadata.lastSeen;
138+ return {
139+ lastConnectedAt: observed?.connectedAt ?? null,
140+ pluginVersion: observed?.pluginVersion ?? state.upgradeRequired?.pluginVersion ?? null,
141+ hermesVersion: observed?.hermesVersion ?? state.upgradeRequired?.hermesVersion ?? null,
142+ activeSessionCount: state.connection?.activeSessionCount ?? 0,
143+ protocolVersion:
144+ observed?.capabilities.protocolVersion ?? state.upgradeRequired?.protocolVersion ?? null,
145+ capabilities: observed?.capabilities ?? null,
146+ };
147+ })(),
148+ instanceId,
149+ nickname: state.metadata.nickname,
150+ status: state.metadata.revoked
151+ ? "revoked"
152+ : state.upgradeRequired
153+ ? "upgrade-required"
154+ : state.connection
155+ ? "connected"
156+ : "offline",
157+ connectorUrl: state.metadata.connectorUrl,
158+});
159+
160+export const makeHermesGatewayBroker = Effect.gen(function* () {
161+ const crypto = yield* Crypto.Crypto;
162+ const secretStore = yield* ServerSecretStore.ServerSecretStore;
163+ const settings = yield* ServerSettingsService;
164+ const states = yield* Ref.make(new Map<ProviderInstanceId, InstanceState>());
165+ const enrollments = yield* Ref.make(new Map<string, PendingEnrollment>());
166+ const pendingRequests = yield* Ref.make(
167+ new Map<
168+ string,
169+ {
170+ readonly instanceId: ProviderInstanceId;
171+ readonly deferred: Deferred.Deferred<PluginMessage, ProviderAdapterRequestError>;
172+ }
173+ >(),
174+ );
175+ const events = yield* PubSub.unbounded<HermesGatewayEnvelope>();
176+ const statusEvents = yield* PubSub.unbounded<HermesGatewayInstanceStatus>();
177+ const generation = yield* Ref.make(0);
178+ const enrollmentSemaphore = yield* Semaphore.make(1);
179+
180+ const publishStatus = (instanceId: ProviderInstanceId, state: InstanceState) =>
181+ PubSub.publish(statusEvents, statusFromState(instanceId, state)).pipe(Effect.asVoid);
182+
183+ const failPendingRequests = (instanceId: ProviderInstanceId, detail: string) =>
184+ Effect.gen(function* () {
185+ const affected = yield* Ref.modify(pendingRequests, (current) => {
186+ const next = new Map(current);
187+ const found = Array.from(current.entries()).filter(
188+ ([, pending]) => pending.instanceId === instanceId,
189+ );
190+ for (const [requestId] of found) next.delete(requestId);
191+ return [found.map(([, pending]) => pending.deferred), next] as const;
192+ });
193+ yield* Effect.forEach(
194+ affected,
195+ (deferred) =>
196+ Deferred.fail(
197+ deferred,
198+ new ProviderAdapterRequestError({
199+ provider: HERMES_DRIVER_KIND,
200+ method: "connection",
201+ detail,
202+ }),
203+ ),
204+ { discard: true },
205+ );
206+ });
207+
208+ const persistMetadata = (instanceId: ProviderInstanceId, metadata: InstanceMetadata) =>
209+ secretStore
210+ .set(metadataSecretName(instanceId), textEncoder.encode(JSON.stringify(metadata)))
211+ .pipe(
212+ Effect.mapError(() =>
213+ managementError(
214+ "create-enrollment",
215+ "persistence-failed",
216+ "Failed to persist Hermes gateway metadata.",
217+ instanceId,
218+ ),
219+ ),
220+ );
221+
222+ const readMetadata = (instanceId: ProviderInstanceId) =>
223+ secretStore.get(metadataSecretName(instanceId)).pipe(
224+ Effect.map((stored) => {
225+ if (Option.isNone(stored)) return undefined;
226+ try {
227+ const parsed: unknown = JSON.parse(textDecoder.decode(stored.value));
228+ if (
229+ typeof parsed === "object" &&
230+ parsed !== null &&
231+ "nickname" in parsed &&
232+ typeof parsed.nickname === "string" &&
233+ "connectorUrl" in parsed &&
234+ typeof parsed.connectorUrl === "string" &&
235+ "revoked" in parsed &&
236+ typeof parsed.revoked === "boolean"
237+ ) {
238+ return {
239+ nickname: parsed.nickname,
240+ connectorUrl: parsed.connectorUrl,
241+ revoked: parsed.revoked,
242+ ...("lastSeen" in parsed &&
243+ typeof parsed.lastSeen === "object" &&
244+ parsed.lastSeen !== null &&
245+ "pluginVersion" in parsed.lastSeen &&
246+ typeof parsed.lastSeen.pluginVersion === "string" &&
247+ "hermesVersion" in parsed.lastSeen &&
248+ typeof parsed.lastSeen.hermesVersion === "string" &&
249+ "connectedAt" in parsed.lastSeen &&
250+ typeof parsed.lastSeen.connectedAt === "string" &&
251+ "activeSessionCount" in parsed.lastSeen &&
252+ typeof parsed.lastSeen.activeSessionCount === "number" &&
253+ "capabilities" in parsed.lastSeen &&
254+ isStrictCapabilities(parsed.lastSeen.capabilities)
255+ ? {
256+ lastSeen: {
257+ pluginVersion: parsed.lastSeen.pluginVersion,
258+ hermesVersion: parsed.lastSeen.hermesVersion,
259+ connectedAt: parsed.lastSeen.connectedAt,
260+ activeSessionCount: parsed.lastSeen.activeSessionCount,
261+ capabilities: parsed.lastSeen.capabilities,
262+ },
263+ }
264+ : {}),
265+ } satisfies InstanceMetadata;
266+ }
267+ } catch {
268+ return undefined;
269+ }
270+ return undefined;
271+ }),
272+ Effect.orElseSucceed(() => undefined),
273+ );
274+
275+ const getState = (instanceId: ProviderInstanceId) =>
276+ Ref.get(states).pipe(
277+ Effect.map((current) => current.get(instanceId)),
278+ Effect.flatMap((state) => {
279+ if (state) return Effect.succeed(state);
280+ return readMetadata(instanceId).pipe(
281+ Effect.flatMap((metadata) => {
282+ if (!metadata) {
283+ return Effect.fail(
284+ managementError(
285+ "get-status",
286+ "instance-not-found",
287+ `Hermes gateway instance '${instanceId}' has not been enrolled.`,
288+ instanceId,
289+ ),
290+ );
291+ }
292+ const loaded: InstanceState = { metadata };
293+ return Ref.update(states, (current) => new Map(current).set(instanceId, loaded)).pipe(
294+ Effect.as(loaded),
295+ );
296+ }),
297+ );
298+ }),
299+ );
300+
301+ const createEnrollment = (input: HermesGatewayCreateEnrollmentInput) =>
302+ enrollmentSemaphore.withPermits(1)(
303+ Effect.gen(function* () {
304+ const normalizedNickname = input.nickname.trim();
305+ const normalizedInput = {
306+ ...input,
307+ nickname: normalizedNickname,
308+ } satisfies HermesGatewayCreateEnrollmentInput;
309+ const currentSettings = yield* settings.getSettings.pipe(
310+ Effect.mapError(() =>
311+ managementError(
312+ "create-enrollment",
313+ "internal-error",
314+ "Failed to read server settings.",
315+ input.instanceId,
316+ ),
317+ ),
318+ );
319+ const configured = currentSettings.providerInstances[input.instanceId];
320+ const isDefaultHermesInstance =
321+ input.instanceId === defaultInstanceIdForDriver(HERMES_DRIVER_KIND);
322+ if ((!configured || configured.driver !== HERMES_DRIVER_KIND) && !isDefaultHermesInstance) {
323+ return yield* managementError(
324+ "create-enrollment",
325+ "instance-not-found",
326+ `Provider instance '${input.instanceId}' is not configured with the Hermes driver.`,
327+ input.instanceId,
328+ );
329+ }
330+
331+ const configuredHermesIds = [
332+ defaultInstanceIdForDriver(HERMES_DRIVER_KIND),
333+ ...Object.entries(currentSettings.providerInstances)
334+ .filter(([, config]) => config.driver === HERMES_DRIVER_KIND)
335+ .map(([instanceId]) => instanceId as ProviderInstanceId),
336+ ];
337+ const existingStates = yield* Ref.get(states);
338+ for (const instanceId of new Set(configuredHermesIds)) {
339+ const persistedMetadata = existingStates.has(instanceId)
340+ ? undefined
341+ : yield* readMetadata(instanceId);
342+ const state =
343+ existingStates.get(instanceId) ??
344+ (persistedMetadata ? { metadata: persistedMetadata } : undefined);
345+ if (!state) continue;
346+ if (instanceId !== input.instanceId && state.metadata.nickname === normalizedNickname) {
347+ return yield* managementError(
348+ "create-enrollment",
349+ "nickname-conflict",
350+ `A Hermes gateway named '${normalizedNickname}' already exists.`,
351+ input.instanceId,
352+ );
353+ }
354+ }
355+
356+ const token = HermesGatewayEnrollmentToken.make(
357+ Encoding.encodeBase64Url(
358+ yield* crypto
359+ .randomBytes(32)
360+ .pipe(
361+ Effect.mapError(() =>
362+ managementError(
363+ "create-enrollment",
364+ "internal-error",
365+ "Failed to generate an enrollment token.",
366+ input.instanceId,
367+ ),
368+ ),
369+ ),
370+ ),
371+ );
372+ const expiresAtMillis =
373+ (yield* Clock.currentTimeMillis) + Duration.toMillis(ENROLLMENT_TTL);
374+ const previousState = existingStates.get(input.instanceId);
375+ const metadata = {
376+ nickname: normalizedNickname,
377+ connectorUrl: input.connectorUrl,
378+ revoked: false,
379+ ...(previousState?.metadata.lastSeen
380+ ? { lastSeen: previousState.metadata.lastSeen }
381+ : {}),
382+ } satisfies InstanceMetadata;
383+ yield* secretStore
384+ .remove(credentialSecretName(input.instanceId))
385+ .pipe(
386+ Effect.mapError(() =>
387+ managementError(
388+ "create-enrollment",
389+ "persistence-failed",
390+ "Failed to invalidate the previous Hermes gateway credential.",
391+ input.instanceId,
392+ ),
393+ ),
394+ );
395+ if (previousState?.connection) {
396+ yield* failPendingRequests(
397+ input.instanceId,
398+ "A new enrollment replaced the active Hermes gateway credential.",
399+ );
400+ yield* previousState.connection.transport.close(
401+ 4001,
402+ "A new enrollment was created for this instance",
403+ );
404+ }
405+ yield* persistMetadata(input.instanceId, metadata);
406+ const enrolledState: InstanceState = {
407+ metadata,
408+ ...(previousState?.lastSeen ? { lastSeen: previousState.lastSeen } : {}),
409+ };
410+ yield* Ref.update(states, (current) =>
411+ new Map(current).set(input.instanceId, enrolledState),
412+ );
413+ yield* publishStatus(input.instanceId, enrolledState);
414+ yield* Ref.update(enrollments, (current) => {
415+ const next = new Map(current);
416+ for (const [pendingToken, pending] of current) {
417+ if (pending.input.instanceId === input.instanceId) {
418+ next.delete(pendingToken);
419+ }
420+ }
421+ return next.set(token, { input: normalizedInput, expiresAtMillis });
422+ });
423+
424+ return {
425+ instanceId: input.instanceId,
426+ expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMillis)),
427+ connectorUrl: input.connectorUrl,
428+ command: `hermes t3 connect --url ${shellQuote(input.connectorUrl)} --token ${shellQuote(token)}`,
429+ oneTimeToken: token,
430+ } satisfies HermesGatewayEnrollmentResult;
431+ }),
432+ );
433+
434+ const getInstanceStatus = (instanceId: ProviderInstanceId) =>
435+ getState(instanceId).pipe(Effect.map((state) => statusFromState(instanceId, state)));
436+
437+ const listInstances = Effect.gen(function* () {
438+ const currentSettings = yield* settings.getSettings.pipe(
439+ Effect.mapError(() =>
440+ managementError("list-instances", "internal-error", "Failed to read server settings."),
441+ ),
442+ );
443+ const ids = Array.from(
444+ new Set([
445+ defaultInstanceIdForDriver(HERMES_DRIVER_KIND),
446+ ...Object.entries(currentSettings.providerInstances)
447+ .filter(([, config]) => config.driver === HERMES_DRIVER_KIND)
448+ .map(([id]) => id as ProviderInstanceId),
449+ ]),
450+ );
451+ return yield* Effect.forEach(
452+ ids,
453+ (instanceId) =>
454+ getInstanceStatus(instanceId).pipe(
455+ Effect.catchTag("HermesGatewayManagementError", () => Effect.succeed(undefined)),
456+ ),
457+ { concurrency: "unbounded" },
458+ ).pipe(Effect.map((values) => values.filter((value) => value !== undefined)));
459+ });
460+
461+ const revokeInstance = (instanceId: ProviderInstanceId) =>
462+ Effect.gen(function* () {
463+ const state = yield* getState(instanceId);
464+ const metadata: InstanceMetadata = { ...state.metadata, revoked: true };
465+ yield* persistMetadata(instanceId, metadata).pipe(
466+ Effect.mapError((error) =>
467+ managementError("revoke-instance", error.code, error.message, instanceId),
468+ ),
469+ );
470+ const next: InstanceState = {
471+ metadata,
472+ ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}),
473+ };
474+ yield* Ref.update(states, (current) => new Map(current).set(instanceId, next));
475+ if (state.connection) {
476+ yield* failPendingRequests(instanceId, "The Hermes gateway credential was revoked.");
477+ yield* state.connection.transport.close(4003, "Hermes gateway credential revoked");
478+ }
479+ yield* publishStatus(instanceId, next);
480+ yield* secretStore
481+ .remove(credentialSecretName(instanceId))
482+ .pipe(
483+ Effect.mapError(() =>
484+ managementError(
485+ "revoke-instance",
486+ "persistence-failed",
487+ "Failed to remove the Hermes gateway credential.",
488+ instanceId,
489+ ),
490+ ),
491+ );
492+ return statusFromState(instanceId, next) satisfies HermesGatewayRevokeInstanceResult;
493+ });
494+
495+ const registerConnectionEffect = (
496+ hello: HermesGatewayConnectionHello,
497+ transport: HermesGatewayTransport,
498+ ) =>
499+ Effect.gen(function* () {
500+ const strictCapabilities = isStrictCapabilities(hello.capabilities)
501+ ? hello.capabilities
502+ : undefined;
503+
504+ let instanceId: ProviderInstanceId;
505+ let credential: HermesGatewayCredential | undefined;
506+ let authenticatedEnrollment: PendingEnrollment | undefined;
507+ let state: InstanceState;
508+ if (hello.authentication.type === "enrollment-token") {
509+ const authentication = hello.authentication;
510+ const enrollment = (yield* Ref.get(enrollments)).get(authentication.token);
511+ if (!enrollment || enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis)) {
512+ return yield* Effect.fail(
513+ rejection(
514+ hello.requestId,
515+ "enrollment-expired",
516+ "The enrollment token is invalid, expired, or already used.",
517+ ),
518+ );
519+ }
520+ instanceId = enrollment.input.instanceId;
521+ authenticatedEnrollment = enrollment;
522+ state = yield* getState(instanceId).pipe(
523+ Effect.mapError(() =>
524+ rejection(
525+ hello.requestId,
526+ "invalid-authentication",
527+ "Unknown Hermes gateway instance.",
528+ ),
529+ ),
530+ );
531+ } else {
532+ const authentication = hello.authentication;
533+ instanceId = authentication.instanceId;
534+ state = yield* getState(instanceId).pipe(
535+ Effect.mapError(() =>
536+ rejection(
537+ hello.requestId,
538+ "invalid-authentication",
539+ "Unknown Hermes gateway instance.",
540+ ),
541+ ),
542+ );
543+ if (state.metadata.revoked) {
544+ return yield* Effect.fail(
545+ rejection(
546+ hello.requestId,
547+ "instance-revoked",
548+ "This Hermes gateway instance has been revoked.",
549+ ),
550+ );
551+ }
552+ const stored = yield* secretStore
553+ .get(credentialSecretName(instanceId))
554+ .pipe(
555+ Effect.mapError(() =>
556+ rejection(
557+ hello.requestId,
558+ "internal-error",
559+ "Failed to read the gateway credential.",
560+ ),
561+ ),
562+ );
563+ if (Option.isNone(stored) || !credentialsEqual(stored.value, authentication.credential)) {
564+ return yield* Effect.fail(
565+ rejection(
566+ hello.requestId,
567+ "invalid-authentication",
568+ "The Hermes gateway credential is invalid.",
569+ ),
570+ );
571+ }
572+ }
573+
574+ if (state.metadata.revoked) {
575+ return yield* Effect.fail(
576+ rejection(
577+ hello.requestId,
578+ "instance-revoked",
579+ "This Hermes gateway instance has been revoked.",
580+ ),
581+ );
582+ }
583+
584+ if (
585+ hello.protocolVersion !== HERMES_GATEWAY_PROTOCOL_VERSION ||
586+ strictCapabilities === undefined
587+ ) {
588+ if (state.connection) {
589+ yield* failPendingRequests(
590+ instanceId,
591+ "The Hermes gateway plugin requires a protocol upgrade.",
592+ );
593+ yield* state.connection.transport.close(
594+ 4004,
595+ "Hermes gateway protocol version is incompatible",
596+ );
597+ }
598+ const upgradeState: InstanceState = {
599+ metadata: state.metadata,
600+ ...(state.lastSeen ? { lastSeen: state.lastSeen } : {}),
601+ upgradeRequired: {
602+ pluginVersion: hello.pluginVersion,
603+ hermesVersion: hello.hermesVersion,
604+ protocolVersion: hello.protocolVersion,
605+ },
606+ };
607+ yield* Ref.update(states, (current) => new Map(current).set(instanceId, upgradeState));
608+ yield* publishStatus(instanceId, upgradeState);
609+ return yield* Effect.fail(
610+ rejection(
611+ hello.requestId,
612+ "version-incompatible",
613+ `Expected protocol version ${HERMES_GATEWAY_PROTOCOL_VERSION}.`,
614+ ),
615+ );
616+ }
617+
618+ if (hello.authentication.type === "enrollment-token" && authenticatedEnrollment) {
619+ const authentication = hello.authentication;
620+ const enrollment = yield* Ref.modify(enrollments, (current) => {
621+ const found = current.get(authentication.token);
622+ const next = new Map(current);
623+ if (found === authenticatedEnrollment) next.delete(authentication.token);
624+ return [found, next] as const;
625+ });
626+ if (
627+ enrollment !== authenticatedEnrollment ||
628+ enrollment.expiresAtMillis < (yield* Clock.currentTimeMillis)
629+ ) {
630+ return yield* Effect.fail(
631+ rejection(
632+ hello.requestId,
633+ "enrollment-expired",
634+ "The enrollment token is invalid, expired, or already used.",
635+ ),
636+ );
637+ }
638+ instanceId = enrollment.input.instanceId;
639+ credential = HermesGatewayCredential.make(
640+ Encoding.encodeBase64Url(
641+ yield* crypto
642+ .randomBytes(32)
643+ .pipe(
644+ Effect.mapError(() =>
645+ rejection(
646+ hello.requestId,
647+ "internal-error",
648+ "Failed to generate the Hermes gateway credential.",
649+ ),
650+ ),
651+ ),
652+ ),
653+ );
654+ yield* secretStore
655+ .set(credentialSecretName(instanceId), textEncoder.encode(credential))
656+ .pipe(
657+ Effect.mapError(() =>
658+ rejection(
659+ hello.requestId,
660+ "internal-error",
661+ "Failed to persist the Hermes gateway credential.",
662+ ),
663+ ),
664+ );
665+ }
666+
667+ const nextGeneration = yield* Ref.getAndUpdate(generation, (value) => value + 1);
668+ const connectedAt = DateTime.formatIso(yield* DateTime.now);
669+ const connection = {
670+ generation: nextGeneration,
671+ transport,
672+ pluginVersion: hello.pluginVersion,
673+ hermesVersion: hello.hermesVersion,
674+ capabilities: strictCapabilities,
675+ connectedAt,
676+ activeSessionCount: 0,
677+ } satisfies ActiveConnection;
678+ const lastSeen = {
679+ pluginVersion: connection.pluginVersion,
680+ hermesVersion: connection.hermesVersion,
681+ capabilities: connection.capabilities,
682+ connectedAt: connection.connectedAt,
683+ activeSessionCount: connection.activeSessionCount,
684+ };
685+ const connectedMetadata: InstanceMetadata = {
686+ ...state.metadata,
687+ lastSeen,
688+ };
689+ yield* persistMetadata(instanceId, connectedMetadata).pipe(
690+ Effect.mapError(() =>
691+ rejection(
692+ hello.requestId,
693+ "internal-error",
694+ "Failed to persist Hermes gateway connection metadata.",
695+ ),
696+ ),
697+ );
698+ if (state.connection) {
699+ yield* failPendingRequests(
700+ instanceId,
701+ "The Hermes gateway connection was replaced by a newer connection.",
702+ );
703+ yield* state.connection.transport.close(4001, "Replaced by a newer connection");
704+ }
705+ yield* Ref.update(states, (current) =>
706+ new Map(current).set(instanceId, {
707+ metadata: connectedMetadata,
708+ connection,
709+ lastSeen,
710+ }),
711+ );
712+ yield* publishStatus(instanceId, {
713+ metadata: connectedMetadata,
714+ connection,
715+ lastSeen,
716+ });
717+
718+ const accepted = {
719+ type: "connection.accepted",
720+ requestId: hello.requestId,
721+ protocolVersion: HERMES_GATEWAY_PROTOCOL_VERSION,
722+ instanceId,
723+ nickname: state.metadata.nickname,
724+ ...(credential ? { credential } : {}),
725+ } as const;
726+ return {
727+ instanceId,
728+ generation: nextGeneration,
729+ accepted,
730+ } satisfies HermesGatewayConnectionRegistration;
731+ });
732+
733+ const registerConnection = (
734+ hello: HermesGatewayConnectionHello,
735+ transport: HermesGatewayTransport,
736+ ) =>
737+ hello.authentication.type === "enrollment-token"
738+ ? enrollmentSemaphore.withPermits(1)(registerConnectionEffect(hello, transport))
739+ : registerConnectionEffect(hello, transport);
740+
741+ const receive = (registration: HermesGatewayConnectionRegistration, message: PluginMessage) =>
742+ Effect.gen(function* () {
743+ const state = (yield* Ref.get(states)).get(registration.instanceId);
744+ if (state?.connection?.generation !== registration.generation) return;
745+ if (message.type === "connection.status") {
746+ yield* Ref.update(states, (current) => {
747+ const found = current.get(registration.instanceId);
748+ if (found?.connection?.generation !== registration.generation) return current;
749+ return new Map(current).set(registration.instanceId, {
750+ ...found,
751+ connection: {
752+ ...found.connection,
753+ activeSessionCount: message.activeSessionCount,
754+ },
755+ });
756+ });
757+ const updated = (yield* Ref.get(states)).get(registration.instanceId);
758+ if (updated) yield* publishStatus(registration.instanceId, updated);
759+ }
760+ if ("requestId" in message && message.requestId) {
761+ const correlatedRequestId = message.requestId;
762+ const pending = yield* Ref.modify(pendingRequests, (current) => {
763+ const found = current.get(correlatedRequestId);
764+ if (!found) return [undefined, current] as const;
765+ const next = new Map(current);
766+ next.delete(correlatedRequestId);
767+ return [found, next] as const;
768+ });
769+ if (pending) {
770+ yield* Deferred.succeed(pending.deferred, message);
771+ }
772+ }
773+ yield* PubSub.publish(events, {
774+ instanceId: registration.instanceId,
775+ message,
776+ });
777+ });
778+
779+ const disconnect = (registration: HermesGatewayConnectionRegistration) =>
780+ Effect.gen(function* () {
781+ let disconnected: InstanceState | undefined;
782+ yield* Ref.update(states, (current) => {
783+ const found = current.get(registration.instanceId);
784+ if (found?.connection?.generation !== registration.generation) return current;
785+ const next = new Map(current);
786+ const lastSeen = {
787+ pluginVersion: found.connection.pluginVersion,
788+ hermesVersion: found.connection.hermesVersion,
789+ capabilities: found.connection.capabilities,
790+ connectedAt: found.connection.connectedAt,
791+ activeSessionCount: found.connection.activeSessionCount,
792+ };
793+ disconnected = {
794+ metadata: { ...found.metadata, lastSeen },
795+ lastSeen,
796+ };
797+ next.set(registration.instanceId, disconnected);
798+ return next;
799+ });
800+ if (disconnected) {
801+ yield* persistMetadata(registration.instanceId, disconnected.metadata).pipe(Effect.ignore);
802+ }
803+ if (disconnected) yield* publishStatus(registration.instanceId, disconnected);
804+ if (disconnected) {
805+ yield* failPendingRequests(
806+ registration.instanceId,
807+ "The Hermes gateway connection disconnected.",
808+ );
809+ }
810+ });
811+
812+ const send = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) =>
813+ Effect.gen(function* () {
814+ const state = (yield* Ref.get(states)).get(instanceId);
815+ if (!state?.connection) {
816+ return yield* new ProviderAdapterRequestError({
817+ provider: "hermes",
818+ method: message.type,
819+ detail: `Hermes gateway instance '${instanceId}' is offline.`,
820+ });
821+ }
822+ yield* state.connection.transport.send(message);
823+ });
824+
825+ const request = (instanceId: ProviderInstanceId, message: HermesGatewayT3ToPluginMessage) =>
826+ Effect.gen(function* () {
827+ if (!("requestId" in message) || !message.requestId) {
828+ return yield* new ProviderAdapterRequestError({
829+ provider: "hermes",
830+ method: message.type,
831+ detail: "A correlated Hermes gateway request requires a request id.",
832+ });
833+ }
834+ const deferred = yield* Deferred.make<PluginMessage, ProviderAdapterRequestError>();
835+ yield* Ref.update(pendingRequests, (current) =>
836+ new Map(current).set(message.requestId as string, {
837+ instanceId,
838+ deferred,
839+ }),
840+ );
841+ yield* send(instanceId, message).pipe(
842+ Effect.tapError(() =>
843+ Ref.update(pendingRequests, (current) => {
844+ const next = new Map(current);
845+ next.delete(message.requestId as string);
846+ return next;
847+ }),
848+ ),
849+ );
850+ return yield* Deferred.await(deferred).pipe(
851+ Effect.timeout(REQUEST_TIMEOUT),
852+ Effect.mapError((error) =>
853+ error._tag === "TimeoutError"
854+ ? new ProviderAdapterRequestError({
855+ provider: "hermes",
856+ method: message.type,
857+ detail: "Hermes gateway request timed out.",
858+ })
859+ : error,
860+ ),
861+ Effect.ensuring(
862+ Ref.update(pendingRequests, (current) => {
863+ const next = new Map(current);
864+ next.delete(message.requestId as string);
865+ return next;
866+ }),
867+ ),
868+ );
869+ });
870+
871+ const isConnected = (instanceId: ProviderInstanceId) =>
872+ Ref.get(states).pipe(
873+ Effect.map((current) => current.get(instanceId)?.connection !== undefined),
874+ );
875+
876+ return {
877+ createEnrollment,
878+ getInstanceStatus,
879+ listInstances,
880+ revokeInstance,
881+ registerConnection,
882+ receive,
883+ disconnect,
884+ request,
885+ send,
886+ isConnected,
887+ stream: Stream.fromPubSub(events),
888+ streamStatuses: Stream.fromPubSub(statusEvents),
889+ } satisfies HermesGatewayBrokerShape;
890+});
891+
892+export const HermesGatewayBrokerLive = Layer.effect(HermesGatewayBroker, makeHermesGatewayBroker);
apps/server/src/provider/Services/HermesGatewayBroker.ts+101−0
@@ -0,0 +1,101 @@
1+import type {
2+ HermesGatewayConnectionHello,
3+ HermesGatewayCreateEnrollmentInput,
4+ HermesGatewayEnrollmentResult,
5+ HermesGatewayInstanceStatus,
6+ HermesGatewayPluginToT3Message,
7+ HermesGatewayRevokeInstanceResult,
8+ HermesGatewayT3ToPluginMessage,
9+ ProviderInstanceId,
10+} from "@t3tools/contracts";
11+import * as Context from "effect/Context";
12+import * as Effect from "effect/Effect";
13+import * as Stream from "effect/Stream";
14+
15+import type { HermesGatewayManagementError } from "@t3tools/contracts";
16+import type { ProviderAdapterRequestError } from "../Errors.ts";
17+
18+export interface HermesGatewayTransport {
19+ readonly send: (
20+ message: HermesGatewayT3ToPluginMessage,
21+ ) => Effect.Effect<void, ProviderAdapterRequestError>;
22+ readonly close: (code: number, reason: string) => Effect.Effect<void>;
23+}
24+
25+export interface HermesGatewayConnectionRegistration {
26+ readonly instanceId: ProviderInstanceId;
27+ readonly generation: number;
28+ readonly accepted: Extract<
29+ HermesGatewayT3ToPluginMessage,
30+ { readonly type: "connection.accepted" }
31+ >;
32+}
33+
34+export interface HermesGatewayEnvelope {
35+ readonly instanceId: ProviderInstanceId;
36+ readonly message: Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>;
37+}
38+
39+export interface HermesGatewayBrokerShape {
40+ readonly createEnrollment: (
41+ input: HermesGatewayCreateEnrollmentInput,
42+ ) => Effect.Effect<HermesGatewayEnrollmentResult, HermesGatewayManagementError>;
43+ readonly getInstanceStatus: (
44+ instanceId: ProviderInstanceId,
45+ ) => Effect.Effect<HermesGatewayInstanceStatus, HermesGatewayManagementError>;
46+ readonly listInstances: Effect.Effect<
47+ ReadonlyArray<HermesGatewayInstanceStatus>,
48+ HermesGatewayManagementError
49+ >;
50+ readonly revokeInstance: (
51+ instanceId: ProviderInstanceId,
52+ ) => Effect.Effect<HermesGatewayRevokeInstanceResult, HermesGatewayManagementError>;
53+ readonly registerConnection: (
54+ hello: HermesGatewayConnectionHello,
55+ transport: HermesGatewayTransport,
56+ ) => Effect.Effect<
57+ HermesGatewayConnectionRegistration,
58+ Extract<HermesGatewayT3ToPluginMessage, { readonly type: "connection.rejected" }>
59+ >;
60+ readonly receive: (
61+ registration: HermesGatewayConnectionRegistration,
62+ message: Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>,
63+ ) => Effect.Effect<void>;
64+ readonly disconnect: (registration: HermesGatewayConnectionRegistration) => Effect.Effect<void>;
65+ readonly request: (
66+ instanceId: ProviderInstanceId,
67+ message: HermesGatewayT3ToPluginMessage,
68+ ) => Effect.Effect<
69+ Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>,
70+ ProviderAdapterRequestError
71+ >;
72+ readonly send: (
73+ instanceId: ProviderInstanceId,
74+ message: HermesGatewayT3ToPluginMessage,
75+ ) => Effect.Effect<void, ProviderAdapterRequestError>;
76+ readonly isConnected: (instanceId: ProviderInstanceId) => Effect.Effect<boolean>;
77+ readonly stream: Stream.Stream<HermesGatewayEnvelope>;
78+ readonly streamStatuses: Stream.Stream<HermesGatewayInstanceStatus>;
79+}
80+
81+const unavailable = () => Effect.die(new Error("HermesGatewayBroker live layer is not installed"));
82+
83+export const HermesGatewayBroker = Context.Reference<HermesGatewayBrokerShape>(
84+ "t3/provider/Services/HermesGatewayBroker",
85+ {
86+ defaultValue: () => ({
87+ createEnrollment: unavailable,
88+ getInstanceStatus: unavailable,
89+ listInstances: unavailable(),
90+ revokeInstance: unavailable,
91+ registerConnection: unavailable,
92+ receive: unavailable,
93+ disconnect: unavailable,
94+ request: unavailable,
95+ send: unavailable,
96+ isConnected: () => Effect.succeed(false),
97+ stream: Stream.empty,
98+ streamStatuses: Stream.empty,
99+ }),
100+ },
101+);
apps/server/src/provider/builtInDrivers.ts+3−0
@@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; · 23 unmodified lines
2424 import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
2525 import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
2626 import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
27+import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts";
2728 import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
2829 import type { AnyProviderDriver } from "./ProviderDriver.ts";
2930
@@ -37,6 +38,7 @@ export type BuiltInDriversEnv = · 7 unmodified lines
3738 | CodexDriverEnv
3839 | CursorDriverEnv
3940 | GrokDriverEnv
41+ | HermesDriverEnv
4042 | OpenCodeDriverEnv;
4143
4244 /**
@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv · 7 unmodified lines
5052 CursorDriver,
5153 GrokDriver,
5254 OpenCodeDriver,
55+ HermesDriver,
5356 ];
apps/server/src/provider/hermesGatewayHttp.ts+116−0
@@ -0,0 +1,116 @@
1+import {
2+ HermesGatewayConnectionHello,
3+ HermesGatewayPluginToT3Message,
4+ HermesGatewayT3ToPluginMessage,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as Layer from "effect/Layer";
8+import * as Option from "effect/Option";
9+import * as Ref from "effect/Ref";
10+import * as Schema from "effect/Schema";
11+import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
12+import * as Socket from "effect/unstable/socket/Socket";
13+
14+import {
15+ HermesGatewayBroker,
16+ type HermesGatewayConnectionRegistration,
17+} from "./Services/HermesGatewayBroker.ts";
18+import { ProviderAdapterRequestError } from "./Errors.ts";
19+
20+export const HERMES_GATEWAY_WEBSOCKET_PATH = "/api/hermes-gateway/ws";
21+
22+const decodePluginFrame = Schema.decodeUnknownEffect(
23+ Schema.fromJsonString(HermesGatewayPluginToT3Message),
24+);
25+const encodeServerFrame = Schema.encodeSync(Schema.fromJsonString(HermesGatewayT3ToPluginMessage));
26+const isConnectionHello = Schema.is(HermesGatewayConnectionHello);
27+
28+export const hermesGatewayWebSocketRouteLayer = Layer.unwrap(
29+ Effect.gen(function* () {
30+ const broker = yield* HermesGatewayBroker;
31+ return HttpRouter.add(
32+ "GET",
33+ HERMES_GATEWAY_WEBSOCKET_PATH,
34+ Effect.gen(function* () {
35+ const request = yield* HttpServerRequest.HttpServerRequest;
36+ const socket = yield* Effect.orDie(request.upgrade);
37+ const write = yield* socket.writer;
38+ const registration = yield* Ref.make<Option.Option<HermesGatewayConnectionRegistration>>(
39+ Option.none(),
40+ );
41+ const transport = {
42+ send: (message: HermesGatewayT3ToPluginMessage) =>
43+ write(encodeServerFrame(message)).pipe(
44+ Effect.mapError(
45+ (cause) =>
46+ new ProviderAdapterRequestError({
47+ provider: "hermes",
48+ method: message.type,
49+ detail: "Failed to write a Hermes gateway WebSocket frame.",
50+ cause,
51+ }),
52+ ),
53+ ),
54+ close: (code: number, reason: string) =>
55+ write(new Socket.CloseEvent(code, reason)).pipe(Effect.ignore),
56+ };
57+
58+ yield* socket
59+ .runString((frame) =>
60+ Effect.gen(function* () {
61+ const message = yield* decodePluginFrame(frame);
62+ const current = yield* Ref.get(registration);
63+
64+ if (Option.isNone(current)) {
65+ if (!isConnectionHello(message)) {
66+ yield* transport.close(4002, "First message must be connection.hello");
67+ return;
68+ }
69+ const registered = yield* broker
70+ .registerConnection(message, transport)
71+ .pipe(
72+ Effect.tapError((rejected) =>
73+ transport
74+ .send(rejected)
75+ .pipe(Effect.andThen(transport.close(4003, rejected.message))),
76+ ),
77+ );
78+ yield* Ref.set(registration, Option.some(registered));
79+ yield* transport.send(registered.accepted);
80+ return;
81+ }
82+
83+ if (isConnectionHello(message)) {
84+ yield* transport.close(4002, "connection.hello may only be sent once");
85+ return;
86+ }
87+ yield* broker.receive(current.value, message);
88+ }).pipe(
89+ Effect.catch((cause) =>
90+ Effect.logWarning("Rejected Hermes gateway WebSocket frame", { cause }).pipe(
91+ Effect.andThen(transport.close(4002, "Invalid Hermes gateway message")),
92+ ),
93+ ),
94+ ),
95+ )
96+ .pipe(
97+ Effect.catch((cause) =>
98+ Effect.logDebug("Hermes gateway WebSocket disconnected", { cause }),
99+ ),
100+ Effect.ensuring(
101+ Ref.get(registration).pipe(
102+ Effect.flatMap(
103+ Option.match({
104+ onNone: () => Effect.void,
105+ onSome: broker.disconnect,
106+ }),
107+ ),
108+ ),
109+ ),
110+ );
111+
112+ return HttpServerResponse.empty();
113+ }),
114+ );
115+ }),
116+);
apps/server/src/server.ts+8−1
@@ -21,6 +21,8 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; · 20 unmodified lines
2121 import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
2222 import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
2323 import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
24+import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts";
25+import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts";
2426 import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
2527 import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
2628 import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
@@ -287,6 +289,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( · 260 unmodified lines
287289 Layer.provideMerge(OrchestrationLayerLive),
288290 );
289291
292+const HermesProviderRuntimeLive = ProviderInstanceRegistryHydrationLive.pipe(
293+ Layer.provideMerge(HermesGatewayBrokerLive),
294+);
295+
290296 const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
291297 // Core Services
292298 Layer.provideMerge(CheckpointingLayerLive),
@@ -303,7 +309,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 10 unmodified lines
303309 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;
304310 // `providerInstances` hydration merges `settings.providers.<kind>`
305311 // with explicit `providerInstances` entries on boot.
306− Layer.provideMerge(ProviderInstanceRegistryHydrationLive),
312+ Layer.provideMerge(HermesProviderRuntimeLive),
307313 // Shared native/canonical NDJSON writers used by both the per-instance
308314 // drivers (native stream, written from inside each `<X>Adapter`) and
309315 // `ProviderService` (canonical stream, written after event normalization).
@@ -362,6 +368,7 @@ export const makeRoutesLayer = Layer.mergeAll( · 52 unmodified lines
362368 assetRouteLayer,
363369 staticAndDevRouteLayer,
364370 websocketRpcRouteLayer,
371+ hermesGatewayWebSocketRouteLayer,
365372 ),
366373 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
367374 ).pipe(
apps/server/src/ws.ts+30−0
@@ -78,6 +78,7 @@ import { · 77 unmodified lines
7878 } from "./observability/RpcInstrumentation.ts";
7979 import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
8080 import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
81+import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts";
8182 import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
8283 import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
8384 import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
@@ -307,6 +308,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 223 unmodified lines
307308 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
308309 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
309310 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
311+ [WS_METHODS.hermesGatewayCreateEnrollment, AuthOrchestrationOperateScope],
312+ [WS_METHODS.hermesGatewayGetInstanceStatus, AuthOrchestrationReadScope],
313+ [WS_METHODS.hermesGatewayListInstances, AuthOrchestrationReadScope],
314+ [WS_METHODS.hermesGatewayRevokeInstance, AuthOrchestrationOperateScope],
310315 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
311316 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
312317 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
@@ -420,6 +425,7 @@ const makeWsRpcLayer = ( · 107 unmodified lines
420425 const portDiscovery = yield* PortScanner.PortDiscovery;
421426 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
422427 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
428+ const hermesGatewayBroker = yield* HermesGatewayBroker;
423429 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
424430 const config = yield* ServerConfig.ServerConfig;
425431 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
@@ -1521,6 +1527,30 @@ const makeWsRpcLayer = ( · 1095 unmodified lines
15211527 "rpc.aggregate": "server",
15221528 },
15231529 ),
1530+ [WS_METHODS.hermesGatewayCreateEnrollment]: (input) =>
1531+ observeRpcEffect(
1532+ WS_METHODS.hermesGatewayCreateEnrollment,
1533+ hermesGatewayBroker.createEnrollment(input),
1534+ { "rpc.aggregate": "hermes-gateway" },
1535+ ),
1536+ [WS_METHODS.hermesGatewayGetInstanceStatus]: ({ instanceId }) =>
1537+ observeRpcEffect(
1538+ WS_METHODS.hermesGatewayGetInstanceStatus,
1539+ hermesGatewayBroker.getInstanceStatus(instanceId),
1540+ { "rpc.aggregate": "hermes-gateway" },
1541+ ),
1542+ [WS_METHODS.hermesGatewayListInstances]: (_input) =>
1543+ observeRpcEffect(
1544+ WS_METHODS.hermesGatewayListInstances,
1545+ hermesGatewayBroker.listInstances,
1546+ { "rpc.aggregate": "hermes-gateway" },
1547+ ),
1548+ [WS_METHODS.hermesGatewayRevokeInstance]: ({ instanceId }) =>
1549+ observeRpcEffect(
1550+ WS_METHODS.hermesGatewayRevokeInstance,
1551+ hermesGatewayBroker.revokeInstance(instanceId),
1552+ { "rpc.aggregate": "hermes-gateway" },
1553+ ),
15241554 [WS_METHODS.serverDiscoverSourceControl]: (_input) =>
15251555 observeRpcEffect(
15261556 WS_METHODS.serverDiscoverSourceControl,
apps/web/src/components/HermesIcon.tsx+18−0
@@ -0,0 +1,18 @@
1+import type { Icon } from "./Icons";
2+
3+/**
4+ * Compact local mark for Hermes provider surfaces. The paired wings suggest
5+ * Hermes' caduceus without depending on a remote image asset.
6+ */
7+export const HermesIcon: Icon = (props) => (
8+ <svg {...props} viewBox="0 0 24 24" fill="none">
9+ <path
10+ d="M12 3v18M8.5 6.5c-2.8-2.2-5-1.1-5.5-.7.8 2.8 2.6 4.2 5.5 4.2h7c2.9 0 4.7-1.4 5.5-4.2-.5-.4-2.7-1.5-5.5.7M8 14h8M9.5 18h5"
11+ stroke="currentColor"
12+ strokeWidth="1.7"
13+ strokeLinecap="round"
14+ strokeLinejoin="round"
15+ />
16+ <circle cx="12" cy="3" r="1.4" fill="currentColor" />
17+ </svg>
18+);
apps/web/src/components/chat/ChatComposer.tsx+99−72
@@ -75,7 +75,10 @@ import { ProviderModelPicker } from "./ProviderModelPicker"; · 74 unmodified lines
7575 import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
7676 import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
7777 import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
78−import { ComposerPrimaryActions } from "./ComposerPrimaryActions";
78+import {
79+ ComposerPrimaryActions,
80+ shouldUseCollapsedHermesRunningActions,
81+} from "./ComposerPrimaryActions";
7982 import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
8083 import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
8184 import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
@@ -171,9 +174,10 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ · 89 unmodified lines
171174 import {
172175 applyProviderInstanceSettings,
173176 deriveProviderInstanceEntries,
174− NO_PROVIDER_MODEL_SELECTION,
177+ getProviderInstanceComposerUnavailableMessage,
178+ isProviderInstanceComposerAvailable,
179+ resolveComposerProviderInstanceId,
175180 resolveProviderDriverKindForInstanceSelection,
176− resolveSelectableProviderInstanceEntry,
177181 sortProviderInstanceEntries,
178182 type ProviderInstanceEntry,
179183 } from "../../providerInstances";
@@ -412,6 +416,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 232 unmodified lines
412416 isComplete: boolean;
413417 } | null;
414418 isRunning: boolean;
419+ allowSendWhileRunning: boolean;
415420 showPlanFollowUpPrompt: boolean;
416421 promptHasText: boolean;
417422 isSendBusy: boolean;
@@ -438,6 +443,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 20 unmodified lines
438443 compact={props.compact}
439444 pendingAction={props.pendingAction}
440445 isRunning={props.isRunning}
446+ allowSendWhileRunning={props.allowSendWhileRunning}
441447 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
442448 promptHasText={props.promptHasText}
443449 isSendBusy={props.isSendBusy}
@@ -776,52 +782,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 332 unmodified lines
776782
777783 // Resolve which configured instance the composer is currently targeting.
778784 // Priority:
779− // 1. The composer draft's `activeProvider` — the user's unsaved pick
785+ // 1. A started Hermes thread's persisted instance binding. Hermes
786+ // sessions cannot be reassigned, including when that instance is
787+ // currently missing or disabled.
788+ // 2. The composer draft's `activeProvider` — the user's unsaved pick
780789 // from the model picker (must win, otherwise the UI appears to
781790 // ignore picker selections).
782− // 2. Thread's persisted instance id (server-side saved selection).
783− // 3. Project default's instance id.
784− // 4. First enabled entry matching the current driver kind.
785− // 5. First enabled entry overall / default instance for the kind.
791+ // 3. Thread's persisted instance id (server-side saved selection).
792+ // 4. Project default's instance id.
793+ // 5. First enabled entry matching the current driver kind.
794+ // 6. First enabled entry overall / default instance for the kind.
786795 //
787796 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
788− const candidates: Array<string | null | undefined> = [
789− composerDraft.activeProvider,
790− activeThread?.session?.providerInstanceId,
791− activeThreadModelSelection?.instanceId,
792− activeProjectDefaultModelSelection?.instanceId,
793− ];
794− for (const candidate of candidates) {
795− if (!candidate) continue;
796− const match = providerInstanceEntries.find(
797− (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
798− );
799− if (match) {
800− // When locked to a specific driver kind, ignore persisted instance
801− // ids from a different kind or continuation group.
802− if (lockedProvider && match.driverKind !== lockedProvider) continue;
803− if (
804− lockedContinuationGroupKey &&
805− match.continuationGroupKey !== lockedContinuationGroupKey
806− ) {
807− continue;
808− }
809− return match.instanceId;
810− }
811− }
812− const compatibleEntries = providerInstanceEntries.filter(
813− (entry) =>
814− (!lockedProvider || entry.driverKind === lockedProvider) &&
815− (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey),
816− );
817− const requestedDriverEntries = compatibleEntries.filter(
818− (entry) => entry.driverKind === requestedDriverKind,
819− );
820− return (
821− resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ??
822− resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ??
823− NO_PROVIDER_MODEL_SELECTION.instanceId
824− );
797+ return resolveComposerProviderInstanceId({
798+ entries: providerInstanceEntries,
799+ draftInstanceId: composerDraft.activeProvider ?? null,
800+ threadInstanceId: activeThread?.session?.providerInstanceId,
801+ threadModelInstanceId: activeThreadModelSelection?.instanceId,
802+ projectInstanceId: activeProjectDefaultModelSelection?.instanceId,
803+ requestedDriverKind,
804+ lockedProvider,
805+ lockedContinuationGroupKey,
806+ });
825807 }, [
826808 activeProjectDefaultModelSelection?.instanceId,
827809 activeThread?.session?.providerInstanceId,
@@ -840,12 +822,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
840822 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
841823 [providerInstanceEntries, selectedInstanceId],
842824 );
843− const noProviderAvailable = selectedProviderEntry === undefined;
825+ const noProviderAvailable =
826+ selectedProviderEntry === undefined ||
827+ !isProviderInstanceComposerAvailable(selectedProviderEntry);
828+ const missingBoundHermesInstanceId =
829+ selectedProviderEntry === undefined && lockedProvider === "hermes"
830+ ? (activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId)
831+ : undefined;
832+ const providerUnavailableMessage = getProviderInstanceComposerUnavailableMessage(
833+ selectedProviderEntry,
834+ missingBoundHermesInstanceId
835+ ? {
836+ driverKind: ProviderDriverKind.make("hermes"),
837+ instanceId: missingBoundHermesInstanceId,
838+ }
839+ : undefined,
840+ );
844841 // The driver kind follows the instance that will actually run the turn,
845842 // which can differ from the persisted selection when that selection is
846843 // disabled.
847844 const selectedProvider: ProviderDriverKind =
848845 selectedProviderEntry?.driverKind ?? requestedDriverKind;
846+ const allowHermesSteering = phase === "running" && selectedProvider === "hermes";
849847
850848 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
851849 threadRef: composerDraftTarget,
@@ -1216,7 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 364 unmodified lines
12161214 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
12171215 );
12181216 const collapsedComposerPrimaryActionDisabled =
1219− phase === "running" ||
1217+ (phase === "running" && !allowHermesSteering) ||
12201218 isSendBusy ||
12211219 isConnecting ||
12221220 noProviderAvailable ||
@@ -1765,7 +1763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 542 unmodified lines
17651763 isConnecting ||
17661764 noProviderAvailable ||
17671765 environmentUnavailable !== null ||
1768− phase === "running"
1766+ (phase === "running" && !allowHermesSteering)
17691767 ) {
17701768 return false;
17711769 }
@@ -1783,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
17831781 isSendBusy,
17841782 noProviderAvailable,
17851783 phase,
1784+ allowHermesSteering,
17861785 showPlanFollowUpPrompt,
17871786 ]);
17881787
@@ -2368,29 +2367,56 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 579 unmodified lines
23682367 ? activePendingProgress.customAnswer ||
23692368 "Type your own answer, or leave this blank to use the selected option"
23702369 : prompt.trim() ||
2371− (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}
2372− </button>
2373− <button
2374− type="button"
2375− className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
2376− disabled={collapsedComposerPrimaryActionDisabled}
2377− aria-label={collapsedComposerPrimaryActionLabel}
2378− onPointerDown={(event) => event.preventDefault()}
2379− onClick={(event) => {
2380− event.stopPropagation();
2381− submitComposer();
2382− }}
2383− >
2384− <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2385− <path
2386− d="M8 3L8 13M8 3L4 7M8 3L12 7"
2387− stroke="currentColor"
2388− strokeWidth="2"
2389− strokeLinecap="round"
2390− strokeLinejoin="round"
2391− />
2392− </svg>
2370+ (noProviderAvailable ? providerUnavailableMessage : "Ask anything...")}
23932371 </button>
2372+ {shouldUseCollapsedHermesRunningActions({
2373+ isRunning: phase === "running",
2374+ allowSendWhileRunning: allowHermesSteering,
2375+ }) ? (
2376+ <ComposerPrimaryActions
2377+ compact
2378+ pendingAction={null}
2379+ isRunning
2380+ allowSendWhileRunning
2381+ showPlanFollowUpPrompt={false}
2382+ promptHasText={prompt.trim().length > 0}
2383+ isSendBusy={isSendBusy}
2384+ isConnecting={isConnecting}
2385+ isEnvironmentUnavailable={
2386+ environmentUnavailable !== null ||
2387+ noProviderAvailable ||
2388+ projectSelectionRequired
2389+ }
2390+ isPreparingWorktree={false}
2391+ hasSendableContent={composerSendState.hasSendableContent}
2392+ preserveComposerFocusOnPointerDown
2393+ onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
2394+ onInterrupt={handleInterruptPrimaryAction}
2395+ onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction}
2396+ />
2397+ ) : (
2398+ <button
2399+ type="button"
2400+ className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/90 text-primary-foreground disabled:opacity-30"
2401+ disabled={collapsedComposerPrimaryActionDisabled}
2402+ aria-label={collapsedComposerPrimaryActionLabel}
2403+ onPointerDown={(event) => event.preventDefault()}
2404+ onClick={(event) => {
2405+ event.stopPropagation();
2406+ submitComposer();
2407+ }}
2408+ >
2409+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
2410+ <path
2411+ d="M8 3L8 13M8 3L4 7M8 3L12 7"
2412+ stroke="currentColor"
2413+ strokeWidth="2"
2414+ strokeLinecap="round"
2415+ strokeLinejoin="round"
2416+ />
2417+ </svg>
2418+ </button>
2419+ )}
23942420 </div>
23952421 ) : null}
23962422
@@ -2575,7 +2601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 178 unmodified lines
25752601 : projectSelectionRequired
25762602 ? "Choose a project above to start a thread"
25772603 : noProviderAvailable
2578− ? "Enable a provider in Settings to send a message"
2604+ ? providerUnavailableMessage
25792605 : phase === "disconnected"
25802606 ? "Ask for follow-up changes or attach images"
25812607 : "Ask anything, @tag files/folders, $use skills, or / for commands"
@@ -2643,7 +2669,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 61 unmodified lines
26432669 className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3"
26442670 >
26452671 <CircleAlertIcon className="size-4" />
2646− No provider available
2672+ {providerUnavailableMessage}
26472673 </Button>
26482674 ) : (
26492675 <ProviderModelPicker
@@ -2721,6 +2747,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 71 unmodified lines
27212747 activeThreadProviderDisplayName={activeThreadProviderDisplayName}
27222748 pendingAction={pendingPrimaryAction}
27232749 isRunning={phase === "running"}
2750+ allowSendWhileRunning={allowHermesSteering}
27242751 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
27252752 promptHasText={prompt.trim().length > 0}
27262753 isSendBusy={isSendBusy}
apps/web/src/components/chat/ComposerPrimaryActions.test.ts+40−1
@@ -1,6 +1,45 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
3+import {
4+ formatPendingPrimaryActionLabel,
5+ resolveRunningPrimaryActionMode,
6+ shouldUseCollapsedHermesRunningActions,
7+} from "./ComposerPrimaryActions";
8+
9+describe("resolveRunningPrimaryActionMode", () => {
10+ it("keeps stop-only behavior for ordinary running providers", () => {
11+ expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: false })).toBe(
12+ "stop",
13+ );
14+ });
15+
16+ it("shows steer and stop actions for a running Hermes provider", () => {
17+ expect(resolveRunningPrimaryActionMode({ isRunning: true, allowSendWhileRunning: true })).toBe(
18+ "steer",
19+ );
20+ expect(
21+ shouldUseCollapsedHermesRunningActions({
22+ isRunning: true,
23+ allowSendWhileRunning: true,
24+ }),
25+ ).toBe(true);
26+ });
27+
28+ it("does not replace collapsed actions for idle or ordinary providers", () => {
29+ expect(
30+ shouldUseCollapsedHermesRunningActions({
31+ isRunning: false,
32+ allowSendWhileRunning: true,
33+ }),
34+ ).toBe(false);
35+ expect(
36+ shouldUseCollapsedHermesRunningActions({
37+ isRunning: true,
38+ allowSendWhileRunning: false,
39+ }),
40+ ).toBe(false);
41+ });
42+});
443
544 describe("formatPendingPrimaryActionLabel", () => {
645 it("returns 'Submitting...' while responding", () => {
apps/web/src/components/chat/ComposerPrimaryActions.tsx+54−1
@@ -18,6 +18,7 @@ interface ComposerPrimaryActionsProps { · 17 unmodified lines
1818 compact: boolean;
1919 pendingAction: PendingActionState | null;
2020 isRunning: boolean;
21+ allowSendWhileRunning?: boolean;
2122 showPlanFollowUpPrompt: boolean;
2223 promptHasText: boolean;
2324 isSendBusy: boolean;
@@ -49,6 +50,21 @@ export const formatPendingPrimaryActionLabel = (input: { · 25 unmodified lines
4950 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
5051 };
5152
53+export function resolveRunningPrimaryActionMode(input: {
54+ readonly isRunning: boolean;
55+ readonly allowSendWhileRunning: boolean;
56+}): "idle" | "stop" | "steer" {
57+ if (!input.isRunning) return "idle";
58+ return input.allowSendWhileRunning ? "steer" : "stop";
59+}
60+
61+export function shouldUseCollapsedHermesRunningActions(input: {
62+ readonly isRunning: boolean;
63+ readonly allowSendWhileRunning: boolean;
64+}): boolean {
65+ return resolveRunningPrimaryActionMode(input) === "steer";
66+}
67+
5268 const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
5369 event.preventDefault();
5470 };
@@ -57,6 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 2 unmodified lines
5773 compact,
5874 pendingAction,
5975 isRunning,
76+ allowSendWhileRunning = false,
6077 showPlanFollowUpPrompt,
6178 promptHasText,
6279 isSendBusy,
@@ -125,7 +142,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 62 unmodified lines
125142 );
126143 }
127144
128− if (isRunning) {
145+ const runningMode = resolveRunningPrimaryActionMode({ isRunning, allowSendWhileRunning });
146+ if (runningMode === "stop") {
129147 return (
130148 <button
131149 type="button"
@@ -141,6 +159,41 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines
141159 );
142160 }
143161
162+ if (runningMode === "steer") {
163+ return (
164+ <div className={cn("flex items-center justify-end", compact ? "gap-1" : "gap-2")}>
165+ <button
166+ type="submit"
167+ className="flex size-8 items-center justify-center rounded-full bg-primary/90 text-primary-foreground shadow-xs transition-all enabled:cursor-pointer hover:bg-primary disabled:pointer-events-none disabled:opacity-30"
168+ {...pointerFocusProps}
169+ disabled={isSendBusy || isConnecting || isEnvironmentUnavailable || !hasSendableContent}
170+ aria-label="Send guidance to running Hermes turn"
171+ >
172+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
173+ <path
174+ d="M7 11.5V2.5M7 2.5L3 6.5M7 2.5L11 6.5"
175+ stroke="currentColor"
176+ strokeWidth="1.8"
177+ strokeLinecap="round"
178+ strokeLinejoin="round"
179+ />
180+ </svg>
181+ </button>
182+ <button
183+ type="button"
184+ className="flex size-8 cursor-pointer items-center justify-center rounded-full bg-destructive/90 text-white shadow-xs shadow-destructive/24 transition-all hover:bg-destructive"
185+ {...pointerFocusProps}
186+ onClick={onInterrupt}
187+ aria-label="Stop generation"
188+ >
189+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true">
190+ <rect x="2" y="2" width="8" height="8" rx="1.5" />
191+ </svg>
192+ </button>
193+ </div>
194+ );
195+ }
196+
144197 if (showPlanFollowUpPrompt) {
145198 if (promptHasText) {
146199 return (
apps/web/src/components/chat/providerIconUtils.ts+2−0
@@ -1,6 +1,7 @@
11 import { ProviderDriverKind } from "@t3tools/contracts";
22 import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
33 import { PROVIDER_OPTIONS } from "../../session-logic";
4+import { HermesIcon } from "../HermesIcon";
45
56 export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
67 [ProviderDriverKind.make("codex")]: OpenAI,
@@ -8,6 +9,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> · 1 unmodified line
89 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
910 [ProviderDriverKind.make("cursor")]: CursorIcon,
1011 [ProviderDriverKind.make("grok")]: GrokIcon,
12+ [ProviderDriverKind.make("hermes")]: HermesIcon,
1113 };
1214
1315 function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts+39−0
@@ -1,11 +1,50 @@
1+import type { ProviderDriverKind } from "@t3tools/contracts";
2+
13 export type WizardNavigation =
24 | { readonly kind: "navigate"; readonly step: number }
35 | { readonly kind: "blocked"; readonly step: number; readonly error: string };
46
57 const IDENTITY_STEP = 1;
8+const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
69
710 export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const;
811
12+export function isOwnedHermesEnrollmentRetry(input: {
13+ readonly driver: ProviderDriverKind;
14+ readonly instanceId: string;
15+ readonly createdHermesInstanceId: string | null;
16+}): boolean {
17+ return (
18+ input.driver === "hermes" &&
19+ input.createdHermesInstanceId !== null &&
20+ input.createdHermesInstanceId === input.instanceId
21+ );
22+}
23+
24+export function validateProviderInstanceIdForWizard(input: {
25+ readonly driver: ProviderDriverKind;
26+ readonly instanceId: string;
27+ readonly existingIds: ReadonlySet<string>;
28+ readonly createdHermesInstanceId: string | null;
29+}): string | null {
30+ if (input.instanceId.length === 0) return "Instance ID is required.";
31+ if (input.instanceId.length > 64) return "Instance ID must be 64 characters or fewer.";
32+ if (!INSTANCE_ID_PATTERN.test(input.instanceId)) {
33+ return "Instance ID must start with a letter and use only letters, digits, '-', or '_'.";
34+ }
35+ if (
36+ input.existingIds.has(input.instanceId) &&
37+ !isOwnedHermesEnrollmentRetry({
38+ driver: input.driver,
39+ instanceId: input.instanceId,
40+ createdHermesInstanceId: input.createdHermesInstanceId,
41+ })
42+ ) {
43+ return `An instance named '${input.instanceId}' already exists.`;
44+ }
45+ return null;
46+}
47+
948 /**
1049 * Resolve navigation within the add-provider wizard.
1150 *
apps/web/src/components/settings/AddProviderInstanceDialog.test.ts+56−1
@@ -1,6 +1,12 @@
11 import { describe, expect, it } from "vite-plus/test";
22
3−import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic";
3+import { ProviderDriverKind } from "@t3tools/contracts";
4+
5+import {
6+ isOwnedHermesEnrollmentRetry,
7+ resolveWizardNavigation,
8+ validateProviderInstanceIdForWizard,
9+} from "./AddProviderInstanceDialog.logic";
410
511 describe("resolveWizardNavigation", () => {
612 const invalidId = { instanceIdError: "Instance ID is required." };
@@ -42,3 +48,52 @@ describe("resolveWizardNavigation", () => { · 35 unmodified lines
4248 expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
4349 });
4450 });
51+
52+describe("Hermes enrollment retry ownership", () => {
53+ const existingIds = new Set(["hermes-research", "hermes-existing"]);
54+
55+ it("allows retrying enrollment for the exact Hermes instance created by this wizard", () => {
56+ expect(
57+ isOwnedHermesEnrollmentRetry({
58+ driver: ProviderDriverKind.make("hermes"),
59+ instanceId: "hermes-research",
60+ createdHermesInstanceId: "hermes-research",
61+ }),
62+ ).toBe(true);
63+ expect(
64+ validateProviderInstanceIdForWizard({
65+ driver: ProviderDriverKind.make("hermes"),
66+ instanceId: "hermes-research",
67+ existingIds,
68+ createdHermesInstanceId: "hermes-research",
69+ }),
70+ ).toBeNull();
71+ });
72+
73+ it("still rejects arbitrary existing instances and non-Hermes reuse", () => {
74+ expect(
75+ validateProviderInstanceIdForWizard({
76+ driver: ProviderDriverKind.make("hermes"),
77+ instanceId: "hermes-existing",
78+ existingIds,
79+ createdHermesInstanceId: "hermes-research",
80+ }),
81+ ).toContain("already exists");
82+ expect(
83+ validateProviderInstanceIdForWizard({
84+ driver: ProviderDriverKind.make("codex"),
85+ instanceId: "hermes-research",
86+ existingIds,
87+ createdHermesInstanceId: "hermes-research",
88+ }),
89+ ).toContain("already exists");
90+ expect(
91+ validateProviderInstanceIdForWizard({
92+ driver: ProviderDriverKind.make("hermes"),
93+ instanceId: "hermes-research",
94+ existingIds,
95+ createdHermesInstanceId: null,
96+ }),
97+ ).toContain("already exists");
98+ });
99+});
apps/web/src/components/settings/AddProviderInstanceDialog.tsx+365−193
@@ -1,14 +1,17 @@
11 "use client";
22
33 import { Radio as RadioPrimitive } from "@base-ui/react/radio";
4−import { CheckIcon } from "lucide-react";
5−import { useMemo, useState } from "react";
4+import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react";
5+import { useEffect, useMemo, useState } from "react";
66 import {
7+ type HermesGatewayEnrollmentResult,
78 ProviderInstanceId,
89 ProviderDriverKind,
910 type ProviderInstanceConfig,
1011 } from "@t3tools/contracts";
12+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1113
14+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
1215 import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
1316 import { cn } from "../../lib/utils";
1417 import { normalizeProviderAccentColor } from "../../providerInstances";
@@ -31,10 +34,20 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe · 16 unmodified lines
3134 import { AnimatedHeight } from "../AnimatedHeight";
3235 import {
3336 ADD_PROVIDER_WIZARD_STEPS,
37+ isOwnedHermesEnrollmentRetry,
3438 resolveWizardNavigation,
3539 type WizardNavigation,
40+ validateProviderInstanceIdForWizard,
3641 } from "./AddProviderInstanceDialog.logic";
3742 import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
43+import { usePrimaryEnvironment } from "../../state/environments";
44+import { serverEnvironment } from "../../state/server";
45+import { useAtomCommand } from "../../state/use-atom-command";
46+import {
47+ defaultHermesConnectorUrl,
48+ formatHermesLastConnected,
49+ messageFromUnknownError,
50+} from "./HermesGatewayInstanceSection.logic";
3851
3952 const PROVIDER_ACCENT_SWATCHES = [
4053 "#2563eb",
@@ -63,10 +76,12 @@ function slugifyLabel(value: string): string { · 22 unmodified lines
6376
6477 function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
6578 const slug = slugifyLabel(label);
79+ if (driver === "hermes") {
80+ return slug ? `hermes-${slug.replaceAll("_", "-")}` : "";
81+ }
6682 return slug ? `${driver}_${slug}` : "";
6783 }
6884
69−const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
7085 const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
7186 const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
7287 const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};
@@ -99,21 +114,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 26 unmodified lines
99114 },
100115 ];
101116
102−/**
103− * Validate an instance id against the same slug rules the server applies in
104− * `ProviderInstanceId` (see `packages/contracts/src/providerInstance.ts`).
105− * Returns a user-facing error string, or `null` if valid.
106− */
107−function validateInstanceId(id: string, existing: ReadonlySet<string>): string | null {
108− if (id.length === 0) return "Instance ID is required.";
109− if (id.length > 64) return "Instance ID must be 64 characters or fewer.";
110− if (!INSTANCE_ID_PATTERN.test(id)) {
111− return "Instance ID must start with a letter and use only letters, digits, '-', or '_'.";
112− }
113− if (existing.has(id)) return `An instance named '${id}' already exists.`;
114− return null;
115−}
116−
117117 interface AddProviderInstanceDialogProps {
118118 open: boolean;
119119 onOpenChange: (open: boolean) => void;
@@ -122,6 +122,13 @@ interface AddProviderInstanceDialogProps { · 2 unmodified lines
122122 export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
123123 const settings = usePrimarySettings();
124124 const updateSettings = useUpdatePrimarySettings();
125+ const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
126+ const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, {
127+ reportFailure: false,
128+ });
129+ const createHermesEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, {
130+ reportFailure: false,
131+ });
125132
126133 const [wizardStep, setWizardStep] = useState(0);
127134 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
@@ -134,6 +141,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines
134141 // Errors are suppressed until the user has tried to submit once. After that
135142 // they update live so fixing the problem clears the message in place.
136143 const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);
144+ const [hermesConnectorUrl, setHermesConnectorUrl] = useState(() =>
145+ typeof window === "undefined"
146+ ? "http://localhost/api/hermes-gateway/ws"
147+ : defaultHermesConnectorUrl(window.location.origin),
148+ );
149+ const [hermesEnrollment, setHermesEnrollment] = useState<HermesGatewayEnrollmentResult | null>(
150+ null,
151+ );
152+ const [saveError, setSaveError] = useState<string | null>(null);
153+ const [isSaving, setIsSaving] = useState(false);
154+ const [createdHermesIdentity, setCreatedHermesIdentity] = useState<{
155+ readonly instanceId: string;
156+ readonly nickname: string;
157+ } | null>(null);
158+ const { copyToClipboard, isCopied } = useCopyToClipboard({
159+ target: "Hermes enrollment command",
160+ onCopy: () =>
161+ toastManager.add({
162+ type: "success",
163+ title: "Hermes command copied",
164+ description: "Run it in the terminal where Hermes is installed.",
165+ }),
166+ });
167+
168+ useEffect(() => {
169+ if (open) return;
170+ setHermesEnrollment(null);
171+ setSaveError(null);
172+ setIsSaving(false);
173+ setCreatedHermesIdentity(null);
174+ }, [open]);
137175
138176 const existingIds = useMemo(
139177 () => new Set(Object.keys(settings.providerInstances ?? {})),
@@ -146,7 +184,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines
146184 () => deriveProviderSettingsFields(driverOption),
147185 [driverOption],
148186 );
149− const instanceIdError = validateInstanceId(instanceId, existingIds);
187+ const instanceIdError =
188+ driver === "hermes" && label.trim().length === 0
189+ ? "Hermes nickname is required."
190+ : validateProviderInstanceIdForWizard({
191+ driver,
192+ instanceId,
193+ existingIds,
194+ createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null,
195+ });
150196 const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
151197 const previewLabel = label.trim() || `${driverOption.label} Workspace`;
152198 const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;
@@ -179,7 +225,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 26 unmodified lines
179225 );
180226 };
181227
182− const handleSave = () => {
228+ const handleSave = async () => {
183229 setHasAttemptedSubmit(true);
184230 if (instanceIdError !== null) return;
185231
@@ -195,15 +241,60 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 9 unmodified lines
195241 ...(hasConfig ? { config } : {}),
196242 };
197243 // `ProviderInstanceId.make` revalidates the slug; we've already checked
198− // it via `validateInstanceId`, but going through the brand constructor
244+ // it via `validateProviderInstanceIdForWizard`, but going through the brand constructor
199245 // keeps the type boundary honest and guards against any future drift in
200246 // the slug rules.
201247 const brandedId = ProviderInstanceId.make(instanceId);
248+ const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({
249+ driver,
250+ instanceId,
251+ createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null,
252+ });
202253 const nextMap = {
203254 ...settings.providerInstances,
204255 [brandedId]: nextInstance,
205256 };
206257 try {
258+ setIsSaving(true);
259+ setSaveError(null);
260+ if (driver === "hermes") {
261+ if (environmentId === null) {
262+ throw new Error("Connect this browser to a T3 server before pairing Hermes.");
263+ }
264+ if (!isOwnedHermesRetry) {
265+ const settingsResult = await updateServerSettings({
266+ environmentId,
267+ input: { patch: { providerInstances: nextMap } },
268+ });
269+ if (settingsResult._tag === "Failure") {
270+ throw squashAtomCommandFailure(settingsResult);
271+ }
272+ setCreatedHermesIdentity({
273+ instanceId,
274+ nickname: label.trim(),
275+ });
276+ }
277+ const enrollmentResult = await createHermesEnrollment({
278+ environmentId,
279+ input: {
280+ instanceId: brandedId,
281+ nickname: createdHermesIdentity?.nickname ?? label.trim(),
282+ connectorUrl: hermesConnectorUrl,
283+ },
284+ });
285+ if (enrollmentResult._tag === "Failure") {
286+ throw squashAtomCommandFailure(enrollmentResult);
287+ }
288+ setHermesEnrollment(enrollmentResult.value);
289+ toastManager.add({
290+ type: "success",
291+ title: "Hermes instance added",
292+ description: "Run the one-time command to connect it.",
293+ });
294+ setIsSaving(false);
295+ return;
296+ }
297+
207298 updateSettings({ providerInstances: nextMap });
208299 toastManager.add({
209300 type: "success",
@@ -212,11 +303,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 2 unmodified lines
212303 });
213304 onOpenChange(false);
214305 } catch (error) {
306+ const message = messageFromUnknownError(error);
307+ setSaveError(message);
215308 toastManager.add({
216309 type: "error",
217310 title: "Could not add provider instance",
218− description: error instanceof Error ? error.message : "Update failed.",
311+ description: message,
219312 });
313+ } finally {
314+ setIsSaving(false);
220315 }
221316 };
222317
@@ -230,203 +325,280 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 7 unmodified lines
230325 Configure an additional provider instance — for example, a second Codex install
231326 pointed at a different workspace.
232327 </DialogDescription>
233− <AddProviderInstanceWizardSteps
234− currentStep={wizardStep}
235− summaries={wizardStepSummaries}
236− instanceIdError={instanceIdError}
237− onNavigation={applyWizardNavigation}
238− />
328+ {hermesEnrollment === null && createdHermesIdentity === null ? (
329+ <AddProviderInstanceWizardSteps
330+ currentStep={wizardStep}
331+ summaries={wizardStepSummaries}
332+ instanceIdError={instanceIdError}
333+ onNavigation={applyWizardNavigation}
334+ />
335+ ) : null}
239336 </DialogHeader>
240337
241338 <div
242339 data-slot="dialog-panel"
243340 className="space-y-4 bg-zinc-25/80 px-6 py-5 ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5"
244341 >
245− <AnimatedHeight>
246− <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
247− <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">
248− Driver
342+ {hermesEnrollment ? (
343+ <div className="grid gap-3">
344+ <div>
345+ <p className="text-sm font-medium text-foreground">Connect Hermes</p>
346+ <p className="text-xs text-muted-foreground">
347+ This one-time command expires{" "}
348+ {formatHermesLastConnected(hermesEnrollment.expiresAt)}.
349+ </p>
249350 </div>
250− <RadioGroup
251− value={driver}
252− onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
253− aria-labelledby="add-instance-driver-label"
254− className="grid grid-cols-1 gap-2 sm:grid-cols-2"
255− >
256− {DRIVER_OPTIONS.map((option) => {
257− const IconComponent = option.icon;
258− return (
259− <RadioPrimitive.Root
260− key={option.value}
261− value={option.value}
262− className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
263− >
264− <IconComponent className="size-4 shrink-0" aria-hidden />
265− <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
266− {option.label}
267− </span>
268− <RadioPrimitive.Indicator
269− className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
270− aria-hidden
351+ <div className="flex min-w-0 items-center gap-2">
352+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-2 text-[11px]">
353+ {hermesEnrollment.command}
354+ </code>
355+ <Button
356+ type="button"
357+ size="icon-sm"
358+ variant="outline"
359+ onClick={() => copyToClipboard(hermesEnrollment.command, undefined)}
360+ aria-label="Copy Hermes enrollment command"
361+ >
362+ {isCopied ? <CheckIcon /> : <CopyIcon />}
363+ </Button>
364+ </div>
365+ <p className="text-[11px] text-muted-foreground">
366+ The persistent gateway credential is delivered directly to the plugin and is never
367+ shown in T3 Code.
368+ </p>
369+ </div>
370+ ) : (
371+ <AnimatedHeight>
372+ <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>
373+ <div
374+ id="add-instance-driver-label"
375+ className="text-sm font-medium text-foreground"
376+ >
377+ Driver
378+ </div>
379+ <RadioGroup
380+ value={driver}
381+ onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}
382+ aria-labelledby="add-instance-driver-label"
383+ className="grid grid-cols-1 gap-2 sm:grid-cols-2"
384+ >
385+ {DRIVER_OPTIONS.map((option) => {
386+ const IconComponent = option.icon;
387+ return (
388+ <RadioPrimitive.Root
389+ key={option.value}
390+ value={option.value}
391+ className="relative flex cursor-pointer items-center gap-3 rounded-lg bg-card px-3 py-3 text-left text-muted-foreground outline-none ring-1 ring-black/5 hover:bg-zinc-50 focus-visible:ring-2 focus-visible:ring-ring data-checked:bg-primary/8 data-checked:text-foreground data-checked:ring-2 data-checked:ring-primary data-checked:hover:bg-primary/8 dark:bg-white/3 dark:ring-white/5 dark:hover:bg-white/5 dark:data-checked:bg-primary/15 dark:data-checked:ring-primary dark:data-checked:hover:bg-primary/15"
271392 >
272− <CheckIcon className="size-3.5 shrink-0" />
273− </RadioPrimitive.Indicator>
274− {option.badgeLabel ? (
393+ <IconComponent className="size-4 shrink-0" aria-hidden />
394+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
395+ {option.label}
396+ </span>
397+ <RadioPrimitive.Indicator
398+ className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"
399+ aria-hidden
400+ >
401+ <CheckIcon className="size-3.5 shrink-0" />
402+ </RadioPrimitive.Indicator>
403+ {option.badgeLabel ? (
404+ <Badge variant="warning" size="sm">
405+ {option.badgeLabel}
406+ </Badge>
407+ ) : null}
408+ </RadioPrimitive.Root>
409+ );
410+ })}
411+ {COMING_SOON_DRIVER_OPTIONS.map((option) => {
412+ const IconComponent = option.icon;
413+ return (
414+ <RadioPrimitive.Root
415+ key={option.value}
416+ value={option.value}
417+ disabled
418+ className={cn(
419+ "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
420+ )}
421+ >
422+ <IconComponent
423+ className="size-4 shrink-0 text-muted-foreground"
424+ aria-hidden
425+ />
426+ <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
427+ {option.label}
428+ </span>
275429 <Badge variant="warning" size="sm">
276− {option.badgeLabel}
430+ Coming Soon
277431 </Badge>
278− ) : null}
279− </RadioPrimitive.Root>
280− );
281− })}
282− {COMING_SOON_DRIVER_OPTIONS.map((option) => {
283− const IconComponent = option.icon;
284− return (
285− <RadioPrimitive.Root
286− key={option.value}
287− value={option.value}
288− disabled
289− className={cn(
290− "relative flex cursor-not-allowed items-center gap-3 rounded-lg bg-card/60 px-3 py-3 text-left opacity-55 outline-none ring-1 ring-black/5 dark:bg-white/2 dark:ring-white/5",
291− )}
292− >
293− <IconComponent
294− className="size-4 shrink-0 text-muted-foreground"
295− aria-hidden
296− />
297− <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
298− {option.label}
299− </span>
300− <Badge variant="warning" size="sm">
301− Coming Soon
302− </Badge>
303− </RadioPrimitive.Root>
304− );
305− })}
306− </RadioGroup>
307− </div>
308−
309− <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
310− <span className="text-xs font-medium text-foreground">Label</span>
311− <Input
312− className="bg-background"
313− placeholder="e.g. Work"
314− value={label}
315− onChange={(event) => setLabel(event.target.value)}
316− />
317− <span className="text-[11px] text-muted-foreground">
318− Shown in the provider list. Optional.
319− </span>
320− </label>
432+ </RadioPrimitive.Root>
433+ );
434+ })}
435+ </RadioGroup>
436+ </div>
321437
322− <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
323− <span className="text-xs font-medium text-foreground">Instance ID</span>
324− <Input
325− className="bg-background"
326− placeholder={`${driver}_work`}
327− value={instanceId}
328− onChange={(event) => {
329− setInstanceIdOverride(event.target.value);
330− }}
331− aria-invalid={showInstanceIdError}
332− />
333− {showInstanceIdError ? (
334− <span className="text-[11px] text-destructive">{instanceIdError}</span>
335− ) : (
438+ <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
439+ <span className="text-xs font-medium text-foreground">Label</span>
440+ <Input
441+ className="bg-background"
442+ placeholder="e.g. Work"
443+ value={label}
444+ onChange={(event) => setLabel(event.target.value)}
445+ />
336446 <span className="text-[11px] text-muted-foreground">
337− Routing key used by threads and sessions. Letters, digits, '-', or '_'.
447+ Shown in the provider list. Optional.
338448 </span>
339− )}
340− </label>
449+ </label>
341450
342− <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
343− <span className="text-xs font-medium text-foreground">Accent color</span>
344− <div className="flex min-w-0 flex-wrap items-center gap-2">
345− <input
346− type="color"
347− value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}
348− onChange={(event) => setAccentColor(event.target.value)}
349− aria-label="Provider instance accent color"
350− className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
451+ <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
452+ <span className="text-xs font-medium text-foreground">Instance ID</span>
453+ <Input
454+ className="bg-background"
455+ placeholder={`${driver}_work`}
456+ value={instanceId}
457+ onChange={(event) => {
458+ setInstanceIdOverride(event.target.value);
459+ }}
460+ aria-invalid={showInstanceIdError}
351461 />
352− <div className="flex flex-wrap gap-1.5">
353− {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
354− const selected = accentColor.toLowerCase() === swatch;
355− return (
356− <button
357− key={swatch}
358− type="button"
359− className={cn(
360− "size-6 cursor-pointer rounded-full border transition",
361− selected
362− ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
363− : "border-black/10 hover:scale-105 dark:border-white/20",
364− )}
365− style={{ backgroundColor: swatch }}
366− onClick={() => setAccentColor(swatch)}
367− aria-label={`Use ${swatch} accent`}
368− />
369− );
370− })}
462+ {showInstanceIdError ? (
463+ <span className="text-[11px] text-destructive">{instanceIdError}</span>
464+ ) : (
465+ <span className="text-[11px] text-muted-foreground">
466+ Routing key used by threads and sessions. Letters, digits, '-', or '_'.
467+ </span>
468+ )}
469+ </label>
470+
471+ <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
472+ <span className="text-xs font-medium text-foreground">Accent color</span>
473+ <div className="flex min-w-0 flex-wrap items-center gap-2">
474+ <input
475+ type="color"
476+ value={
477+ normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]
478+ }
479+ onChange={(event) => setAccentColor(event.target.value)}
480+ aria-label="Provider instance accent color"
481+ className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"
482+ />
483+ <div className="flex flex-wrap gap-1.5">
484+ {PROVIDER_ACCENT_SWATCHES.map((swatch) => {
485+ const selected = accentColor.toLowerCase() === swatch;
486+ return (
487+ <button
488+ key={swatch}
489+ type="button"
490+ className={cn(
491+ "size-6 cursor-pointer rounded-full border transition",
492+ selected
493+ ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
494+ : "border-black/10 hover:scale-105 dark:border-white/20",
495+ )}
496+ style={{ backgroundColor: swatch }}
497+ onClick={() => setAccentColor(swatch)}
498+ aria-label={`Use ${swatch} accent`}
499+ />
500+ );
501+ })}
502+ </div>
503+ {accentColor ? (
504+ <Button
505+ type="button"
506+ size="sm"
507+ variant="ghost"
508+ className="h-7 px-2 text-xs text-muted-foreground"
509+ onClick={() => setAccentColor("")}
510+ >
511+ Clear
512+ </Button>
513+ ) : null}
371514 </div>
372− {accentColor ? (
373− <Button
374− type="button"
375− size="sm"
376− variant="ghost"
377− className="h-7 px-2 text-xs text-muted-foreground"
378− onClick={() => setAccentColor("")}
379− >
380− Clear
381− </Button>
382− ) : null}
515+ <span className="text-[11px] text-muted-foreground">
516+ Optional marker shown in the picker.
517+ </span>
383518 </div>
384− <span className="text-[11px] text-muted-foreground">
385− Optional marker shown in the picker.
386− </span>
387− </div>
388519
389− {driverSettingsFields.length > 0 ? (
390− <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
391− <ProviderSettingsForm
392− definition={driverOption}
393− value={configDraft}
394− idPrefix={`add-provider-${driver}`}
395− variant="dialog"
396− onChange={setConfigDraft}
397− />
398− </div>
399− ) : wizardStep === 2 ? (
400− <div className="grid gap-2">
401− <p className="text-sm text-muted-foreground">
402− This driver has no required configuration. You can add the instance now.
520+ {driver === "hermes" && wizardStep === 2 ? (
521+ <label className="grid gap-2">
522+ <span className="text-xs font-medium text-foreground">Connector URL</span>
523+ <Input
524+ className="bg-background"
525+ value={hermesConnectorUrl}
526+ onChange={(event) => setHermesConnectorUrl(event.target.value)}
527+ placeholder="wss://t3.example.com/api/hermes-gateway/ws"
528+ spellCheck={false}
529+ />
530+ <span className="text-[11px] text-muted-foreground">
531+ Defaults to this browser&apos;s origin and works over Tailscale or any
532+ reachable network.
533+ </span>
534+ </label>
535+ ) : driverSettingsFields.length > 0 ? (
536+ <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>
537+ <ProviderSettingsForm
538+ definition={driverOption}
539+ value={configDraft}
540+ idPrefix={`add-provider-${driver}`}
541+ variant="dialog"
542+ onChange={setConfigDraft}
543+ />
544+ </div>
545+ ) : wizardStep === 2 ? (
546+ <div className="grid gap-2">
547+ <p className="text-sm text-muted-foreground">
548+ This driver has no required configuration. You can add the instance now.
549+ </p>
550+ </div>
551+ ) : null}
552+ {saveError ? <p className="text-xs text-destructive">{saveError}</p> : null}
553+ {createdHermesIdentity ? (
554+ <p className="text-xs text-muted-foreground">
555+ The Hermes instance was added. Retry enrollment for this exact instance, or
556+ close this dialog and finish pairing from its Settings card.
403557 </p>
404− </div>
405− ) : null}
406− </AnimatedHeight>
558+ ) : null}
559+ </AnimatedHeight>
560+ )}
407561 </div>
408562
409563 <DialogFooter variant="bare">
410− <Button
411− variant="outline"
412− size="sm"
413− onClick={() => {
414− if (wizardStep === 0) {
415− onOpenChange(false);
416− return;
417− }
418− setWizardStep((step) => Math.max(0, step - 1));
419− }}
420− >
421− {wizardStep === 0 ? "Cancel" : "Back"}
422− </Button>
423− {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
564+ {hermesEnrollment ? (
565+ <Button size="sm" onClick={() => onOpenChange(false)}>
566+ Done
567+ </Button>
568+ ) : (
569+ <Button
570+ variant="outline"
571+ size="sm"
572+ onClick={() => {
573+ if (createdHermesIdentity) {
574+ onOpenChange(false);
575+ return;
576+ }
577+ if (wizardStep === 0) {
578+ onOpenChange(false);
579+ return;
580+ }
581+ setWizardStep((step) => Math.max(0, step - 1));
582+ }}
583+ disabled={isSaving}
584+ >
585+ {createdHermesIdentity ? "Close" : wizardStep === 0 ? "Cancel" : "Back"}
586+ </Button>
587+ )}
588+ {hermesEnrollment ? null : wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (
424589 <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}>
425590 Next
426591 </Button>
427592 ) : (
428− <Button size="sm" onClick={handleSave}>
429− Add instance
593+ <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}>
594+ {isSaving ? <LoaderIcon className="animate-spin" /> : null}
595+ {isSaving
596+ ? createdHermesIdentity
597+ ? "Retrying"
598+ : "Adding"
599+ : createdHermesIdentity
600+ ? "Retry enrollment"
601+ : "Add instance"}
430602 </Button>
431603 )}
432604 </DialogFooter>
apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts+38−0
@@ -0,0 +1,38 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import {
4+ defaultHermesConnectorUrl,
5+ formatHermesLastConnected,
6+ hermesGatewayStatusLabel,
7+ messageFromUnknownError,
8+ shouldApplyHermesConnectorStatusUrl,
9+} from "./HermesGatewayInstanceSection.logic";
10+
11+describe("Hermes gateway settings logic", () => {
12+ it("uses the current origin with the gateway WebSocket route", () => {
13+ expect(defaultHermesConnectorUrl("https://siva.davis7.space:3774/settings")).toBe(
14+ "https://siva.davis7.space:3774/api/hermes-gateway/ws",
15+ );
16+ });
17+
18+ it("provides useful labels for every connection state", () => {
19+ expect(
20+ ["offline", "connecting", "connected", "upgrade-required", "revoked"].map((status) =>
21+ hermesGatewayStatusLabel(status as never),
22+ ),
23+ ).toEqual(["Offline", "Connecting", "Connected", "Upgrade required", "Revoked"]);
24+ });
25+
26+ it("keeps invalid server timestamps readable and extracts structured messages", () => {
27+ expect(formatHermesLastConnected(null)).toBe("Never");
28+ expect(formatHermesLastConnected("not-a-date")).toBe("not-a-date");
29+ expect(messageFromUnknownError({ message: "Nickname is already used." })).toBe(
30+ "Nickname is already used.",
31+ );
32+ });
33+
34+ it("does not replace an in-progress connector URL edit during status polling", () => {
35+ expect(shouldApplyHermesConnectorStatusUrl(false)).toBe(true);
36+ expect(shouldApplyHermesConnectorStatusUrl(true)).toBe(false);
37+ });
38+});
apps/web/src/components/settings/HermesGatewayInstanceSection.logic.ts+46−0
@@ -0,0 +1,46 @@
1+import type { HermesGatewayConnectionState } from "@t3tools/contracts";
2+
3+export const HERMES_GATEWAY_SOCKET_PATH = "/api/hermes-gateway/ws";
4+
5+export function defaultHermesConnectorUrl(origin: string): string {
6+ return new URL(HERMES_GATEWAY_SOCKET_PATH, origin).toString();
7+}
8+
9+export function shouldApplyHermesConnectorStatusUrl(hasLocalEdits: boolean): boolean {
10+ return !hasLocalEdits;
11+}
12+
13+export function hermesGatewayStatusLabel(status: HermesGatewayConnectionState): string {
14+ switch (status) {
15+ case "connected":
16+ return "Connected";
17+ case "connecting":
18+ return "Connecting";
19+ case "upgrade-required":
20+ return "Upgrade required";
21+ case "revoked":
22+ return "Revoked";
23+ case "offline":
24+ return "Offline";
25+ }
26+}
27+
28+export function formatHermesLastConnected(value: string | null): string {
29+ if (value === null) return "Never";
30+ const date = new Date(value);
31+ return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
32+}
33+
34+export function messageFromUnknownError(error: unknown): string {
35+ if (error instanceof Error && error.message.trim()) return error.message;
36+ if (
37+ typeof error === "object" &&
38+ error !== null &&
39+ "message" in error &&
40+ typeof error.message === "string" &&
41+ error.message.trim()
42+ ) {
43+ return error.message;
44+ }
45+ return "The Hermes gateway request failed.";
46+}
apps/web/src/components/settings/HermesGatewayInstanceSection.tsx+276−0
@@ -0,0 +1,276 @@
1+"use client";
2+
3+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4+import {
5+ type HermesGatewayConnectionState,
6+ type HermesGatewayEnrollmentResult,
7+ type HermesGatewayInstanceStatus,
8+ type ProviderInstanceId,
9+} from "@t3tools/contracts";
10+import { CheckIcon, CopyIcon, LoaderIcon, RefreshCwIcon, UnplugIcon } from "lucide-react";
11+import { useCallback, useEffect, useRef, useState } from "react";
12+
13+import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
14+import { usePrimaryEnvironment } from "../../state/environments";
15+import { serverEnvironment } from "../../state/server";
16+import { useAtomCommand } from "../../state/use-atom-command";
17+import { Badge } from "../ui/badge";
18+import { Button } from "../ui/button";
19+import { Input } from "../ui/input";
20+import { toastManager } from "../ui/toast";
21+import {
22+ defaultHermesConnectorUrl,
23+ formatHermesLastConnected,
24+ hermesGatewayStatusLabel,
25+ messageFromUnknownError,
26+ shouldApplyHermesConnectorStatusUrl,
27+} from "./HermesGatewayInstanceSection.logic";
28+
29+const badgeVariantByStatus: Record<
30+ HermesGatewayConnectionState,
31+ "success" | "warning" | "error" | "secondary"
32+> = {
33+ connected: "success",
34+ connecting: "warning",
35+ offline: "secondary",
36+ "upgrade-required": "warning",
37+ revoked: "error",
38+};
39+
40+function browserDefaultConnectorUrl() {
41+ if (typeof window === "undefined") return "http://localhost/api/hermes-gateway/ws";
42+ return defaultHermesConnectorUrl(window.location.origin);
43+}
44+
45+export function HermesGatewayInstanceSection(props: {
46+ readonly instanceId: ProviderInstanceId;
47+ readonly nickname: string;
48+}) {
49+ const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
50+ const getStatus = useAtomCommand(serverEnvironment.hermesGatewayGetInstanceStatus, {
51+ reportFailure: false,
52+ });
53+ const createEnrollment = useAtomCommand(serverEnvironment.hermesGatewayCreateEnrollment, {
54+ reportFailure: false,
55+ });
56+ const revokeInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, {
57+ reportFailure: false,
58+ });
59+ const [connectorUrl, setConnectorUrl] = useState(browserDefaultConnectorUrl);
60+ const [status, setStatus] = useState<HermesGatewayInstanceStatus | null>(null);
61+ const [enrollment, setEnrollment] = useState<HermesGatewayEnrollmentResult | null>(null);
62+ const [error, setError] = useState<string | null>(null);
63+ const [pendingAction, setPendingAction] = useState<"status" | "enroll" | "revoke" | null>(null);
64+ const connectorUrlHasLocalEditsRef = useRef(false);
65+ const { copyToClipboard, isCopied } = useCopyToClipboard({
66+ target: "Hermes enrollment command",
67+ onCopy: () => {
68+ toastManager.add({
69+ type: "success",
70+ title: "Hermes command copied",
71+ description: "Run it in the terminal where Hermes is installed.",
72+ });
73+ },
74+ });
75+
76+ const refreshStatus = useCallback(
77+ async (quiet = false) => {
78+ if (environmentId === null) return;
79+ if (!quiet) setPendingAction("status");
80+ const result = await getStatus({
81+ environmentId,
82+ input: { instanceId: props.instanceId },
83+ });
84+ if (result._tag === "Success") {
85+ setStatus(result.value);
86+ if (shouldApplyHermesConnectorStatusUrl(connectorUrlHasLocalEditsRef.current)) {
87+ setConnectorUrl(result.value.connectorUrl);
88+ }
89+ setError(null);
90+ } else if (!quiet) {
91+ setError(messageFromUnknownError(squashAtomCommandFailure(result)));
92+ }
93+ if (!quiet) setPendingAction(null);
94+ },
95+ [environmentId, getStatus, props.instanceId],
96+ );
97+
98+ useEffect(() => {
99+ void refreshStatus();
100+ const interval = window.setInterval(() => void refreshStatus(true), 5_000);
101+ return () => window.clearInterval(interval);
102+ }, [refreshStatus]);
103+
104+ const handleCreateEnrollment = async () => {
105+ if (environmentId === null) {
106+ setError("Connect this browser to a T3 server before pairing Hermes.");
107+ return;
108+ }
109+ setPendingAction("enroll");
110+ setError(null);
111+ const result = await createEnrollment({
112+ environmentId,
113+ input: {
114+ instanceId: props.instanceId,
115+ nickname: props.nickname,
116+ connectorUrl,
117+ },
118+ });
119+ if (result._tag === "Success") {
120+ connectorUrlHasLocalEditsRef.current = false;
121+ setConnectorUrl(result.value.connectorUrl);
122+ setEnrollment(result.value);
123+ setStatus((current) =>
124+ current
125+ ? { ...current, connectorUrl: result.value.connectorUrl, status: "offline" }
126+ : current,
127+ );
128+ } else {
129+ setError(messageFromUnknownError(squashAtomCommandFailure(result)));
130+ }
131+ setPendingAction(null);
132+ };
133+
134+ const handleRevoke = async () => {
135+ if (environmentId === null) return;
136+ setPendingAction("revoke");
137+ setError(null);
138+ const result = await revokeInstance({
139+ environmentId,
140+ input: { instanceId: props.instanceId },
141+ });
142+ if (result._tag === "Success") {
143+ setStatus(result.value);
144+ setEnrollment(null);
145+ } else {
146+ setError(messageFromUnknownError(squashAtomCommandFailure(result)));
147+ }
148+ setPendingAction(null);
149+ };
150+
151+ const connectionState = status?.status ?? "offline";
152+ const isBusy = pendingAction !== null;
153+
154+ return (
155+ <section className="grid gap-4 rounded-lg border border-border/70 bg-muted/15 p-3">
156+ <div className="flex flex-wrap items-center justify-between gap-2">
157+ <div>
158+ <p className="text-xs font-medium text-foreground">Hermes gateway</p>
159+ <p className="text-xs text-muted-foreground">
160+ Pair an already-running Hermes process over any reachable network.
161+ </p>
162+ </div>
163+ <div className="flex items-center gap-1.5">
164+ <Badge variant={badgeVariantByStatus[connectionState]} size="sm">
165+ {hermesGatewayStatusLabel(connectionState)}
166+ </Badge>
167+ <Button
168+ type="button"
169+ size="icon-xs"
170+ variant="ghost"
171+ disabled={isBusy || environmentId === null}
172+ onClick={() => void refreshStatus()}
173+ aria-label="Refresh Hermes gateway status"
174+ >
175+ <RefreshCwIcon className={pendingAction === "status" ? "animate-spin" : undefined} />
176+ </Button>
177+ </div>
178+ </div>
179+
180+ <label className="grid gap-1.5">
181+ <span className="text-xs font-medium text-foreground">Connector URL</span>
182+ <Input
183+ value={connectorUrl}
184+ onChange={(event) => {
185+ connectorUrlHasLocalEditsRef.current = true;
186+ setConnectorUrl(event.target.value);
187+ }}
188+ placeholder="wss://t3.example.com/api/hermes-gateway/ws"
189+ spellCheck={false}
190+ disabled={connectionState === "revoked"}
191+ />
192+ <span className="text-[11px] text-muted-foreground">
193+ Defaults to this browser&apos;s origin. Tailscale URLs work without special handling.
194+ </span>
195+ </label>
196+
197+ {status ? (
198+ <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs sm:grid-cols-3">
199+ <div>
200+ <dt className="text-muted-foreground">Last connected</dt>
201+ <dd className="text-foreground">{formatHermesLastConnected(status.lastConnectedAt)}</dd>
202+ </div>
203+ <div>
204+ <dt className="text-muted-foreground">Active sessions</dt>
205+ <dd className="text-foreground">{status.activeSessionCount}</dd>
206+ </div>
207+ <div>
208+ <dt className="text-muted-foreground">Protocol</dt>
209+ <dd className="text-foreground">{status.protocolVersion ?? "Unknown"}</dd>
210+ </div>
211+ <div>
212+ <dt className="text-muted-foreground">Plugin</dt>
213+ <dd className="text-foreground">{status.pluginVersion ?? "Unknown"}</dd>
214+ </div>
215+ <div>
216+ <dt className="text-muted-foreground">Hermes</dt>
217+ <dd className="text-foreground">{status.hermesVersion ?? "Unknown"}</dd>
218+ </div>
219+ </dl>
220+ ) : null}
221+
222+ {enrollment ? (
223+ <div className="grid gap-2 rounded-md border border-border/70 bg-background/80 p-2.5">
224+ <div>
225+ <p className="text-xs font-medium text-foreground">One-time enrollment command</p>
226+ <p className="text-[11px] text-muted-foreground">
227+ Expires {formatHermesLastConnected(enrollment.expiresAt)}. The long-lived credential
228+ is issued directly to the plugin and is never shown here.
229+ </p>
230+ </div>
231+ <div className="flex min-w-0 items-center gap-2">
232+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-1.5 text-[11px]">
233+ {enrollment.command}
234+ </code>
235+ <Button
236+ type="button"
237+ size="icon-sm"
238+ variant="outline"
239+ onClick={() => copyToClipboard(enrollment.command, undefined)}
240+ aria-label="Copy Hermes enrollment command"
241+ >
242+ {isCopied ? <CheckIcon /> : <CopyIcon />}
243+ </Button>
244+ </div>
245+ </div>
246+ ) : null}
247+
248+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
249+
250+ <div className="flex flex-wrap gap-2">
251+ {connectionState !== "revoked" ? (
252+ <Button
253+ type="button"
254+ size="sm"
255+ variant={enrollment ? "outline" : "default"}
256+ disabled={isBusy || connectorUrl.trim().length === 0 || environmentId === null}
257+ onClick={() => void handleCreateEnrollment()}
258+ >
259+ {pendingAction === "enroll" ? <LoaderIcon className="animate-spin" /> : null}
260+ {enrollment ? "Retry enrollment" : "Create enrollment"}
261+ </Button>
262+ ) : null}
263+ <Button
264+ type="button"
265+ size="sm"
266+ variant="destructive-outline"
267+ disabled={isBusy || connectionState === "revoked" || environmentId === null}
268+ onClick={() => void handleRevoke()}
269+ >
270+ {pendingAction === "revoke" ? <LoaderIcon className="animate-spin" /> : <UnplugIcon />}
271+ Revoke
272+ </Button>
273+ </div>
274+ </section>
275+ );
276+}
apps/web/src/components/settings/ProviderInstanceCard.test.ts+12−1
@@ -1,7 +1,10 @@
11 import { describe, expect, it } from "vite-plus/test";
22 import type { ServerProviderModel } from "@t3tools/contracts";
33
4−import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard";
4+import {
5+ deriveProviderModelsForDisplay,
6+ isProviderInstanceDisplayNameEditable,
7+} from "./ProviderInstanceCard";
58
69 describe("deriveProviderModelsForDisplay", () => {
710 it("uses current config custom models instead of stale live custom rows", () => {
@@ -34,3 +37,11 @@ describe("deriveProviderModelsForDisplay", () => { · 26 unmodified lines
3437 ).toEqual(["server-model", "kept-custom"]);
3538 });
3639 });
40+
41+describe("isProviderInstanceDisplayNameEditable", () => {
42+ it("keeps Hermes nicknames immutable without changing other providers", () => {
43+ expect(isProviderInstanceDisplayNameEditable("hermes")).toBe(false);
44+ expect(isProviderInstanceDisplayNameEditable("codex")).toBe(true);
45+ expect(isProviderInstanceDisplayNameEditable("claudeAgent")).toBe(true);
46+ });
47+});
apps/web/src/components/settings/ProviderInstanceCard.tsx+124−28
@@ -13,6 +13,7 @@ import { · 12 unmodified lines
1313 import * as Arr from "effect/Array";
1414 import * as Result from "effect/Result";
1515 import { useState, type ReactNode } from "react";
16+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
1617 import {
1718 isProviderDriverKind,
1819 type ProviderInstanceConfig,
@@ -26,6 +27,9 @@ import { · 7 unmodified lines
2627 import { cn } from "../../lib/utils";
2728 import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
2829 import { normalizeProviderAccentColor } from "../../providerInstances";
30+import { usePrimaryEnvironment } from "../../state/environments";
31+import { serverEnvironment } from "../../state/server";
32+import { useAtomCommand } from "../../state/use-atom-command";
2933 import { Badge } from "../ui/badge";
3034 import { Button } from "../ui/button";
3135 import { Checkbox } from "../ui/checkbox";
@@ -43,6 +47,8 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 11 unmodified lines
4347 import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
4448 import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
4549 import { RedactedSensitiveText } from "./RedactedSensitiveText";
50+import { HermesGatewayInstanceSection } from "./HermesGatewayInstanceSection";
51+import { messageFromUnknownError } from "./HermesGatewayInstanceSection.logic";
4652 import {
4753 getProviderVersionAdvisoryPresentation,
4854 PROVIDER_STATUS_STYLES,
@@ -131,6 +137,10 @@ export function deriveProviderModelsForDisplay(input: { · 82 unmodified lines
131137 return [...serverModels, ...customModels];
132138 }
133139
140+export function isProviderInstanceDisplayNameEditable(driver: string): boolean {
141+ return driver !== "hermes";
142+}
143+
134144 function ProviderAuthEmail(props: {
135145 readonly email: string | undefined;
136146 readonly prefix?: string;
@@ -394,6 +404,11 @@ export function ProviderInstanceCard({ · 257 unmodified lines
394404 onRunUpdate,
395405 isUpdating = false,
396406 }: ProviderInstanceCardProps) {
407+ const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
408+ const revokeHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, {
409+ reportFailure: false,
410+ });
411+ const [isDeleting, setIsDeleting] = useState(false);
397412 const enabled = instance.enabled ?? true;
398413 // The server-reported status wins when present; otherwise fall back to
399414 // "disabled"/"warning" based on the local `enabled` flag so the dot
@@ -413,8 +428,21 @@ export function ProviderInstanceCard({ · 13 unmodified lines
413428 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
414429 const updateCommand = versionAdvisory?.updateCommand ?? null;
415430 const FallbackIconComponent = driverOption?.icon;
416− const displayName =
417− instance.displayName?.trim() || driverOption?.label || String(instance.driver);
431+ const configuredDisplayName = instance.displayName?.trim();
432+ const isHermes = instance.driver === "hermes";
433+ const hermesNickname =
434+ configuredDisplayName?.replace(/^Hermes\s*[·:–—-]?\s*/u, "").trim() ||
435+ (instanceId === "hermes"
436+ ? "Hermes"
437+ : String(instanceId)
438+ .replace(/^hermes[-_]?/u, "")
439+ .replace(/[-_]+/g, " ")
440+ .trim());
441+ const displayName = isHermes
442+ ? instanceId === "hermes" || hermesNickname === "Hermes"
443+ ? "Hermes"
444+ : `Hermes · ${hermesNickname}`
445+ : configuredDisplayName || driverOption?.label || String(instance.driver);
418446 const accentColor = normalizeProviderAccentColor(instance.accentColor);
419447 const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
420448 onCopy: ({ providerName }) => {
@@ -501,6 +529,49 @@ export function ProviderInstanceCard({ · 80 unmodified lines
501529 );
502530 };
503531
532+ const handleDelete = async () => {
533+ if (!onDelete || isDeleting) return;
534+ if (!isHermes) {
535+ onDelete();
536+ return;
537+ }
538+ if (environmentId === null) {
539+ toastManager.add({
540+ type: "error",
541+ title: "Could not delete Hermes instance",
542+ description: "Reconnect this browser to the T3 server so its gateway can be revoked first.",
543+ });
544+ return;
545+ }
546+ setIsDeleting(true);
547+ const result = await revokeHermesInstance({
548+ environmentId,
549+ input: { instanceId },
550+ });
551+ if (result._tag === "Success") {
552+ onDelete();
553+ setIsDeleting(false);
554+ return;
555+ }
556+ const failure = squashAtomCommandFailure(result);
557+ const wasNeverEnrolled =
558+ typeof failure === "object" &&
559+ failure !== null &&
560+ "code" in failure &&
561+ failure.code === "instance-not-found";
562+ if (wasNeverEnrolled) {
563+ onDelete();
564+ setIsDeleting(false);
565+ return;
566+ }
567+ toastManager.add({
568+ type: "error",
569+ title: "Could not delete Hermes instance",
570+ description: `${messageFromUnknownError(failure)} The instance was kept so a live gateway credential is not orphaned.`,
571+ });
572+ setIsDeleting(false);
573+ };
574+
504575 const titleIconNode = driverKind ? (
505576 <ProviderInstanceIcon
506577 driverKind={driverKind}
@@ -563,10 +634,15 @@ export function ProviderInstanceCard({ · 56 unmodified lines
563634 size="icon-xs"
564635 variant="ghost"
565636 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"
566− onClick={onDelete}
637+ disabled={isDeleting}
638+ onClick={() => void handleDelete()}
567639 aria-label={`Delete provider instance ${instanceId}`}
568640 >
569− <Trash2Icon className="size-3" />
641+ {isDeleting ? (
642+ <LoaderIcon className="size-3 animate-spin" />
643+ ) : (
644+ <Trash2Icon className="size-3" />
645+ )}
570646 </Button>
571647 }
572648 />
@@ -730,22 +806,38 @@ export function ProviderInstanceCard({ · 157 unmodified lines
730806 <Collapsible open={isExpanded} onOpenChange={onExpandedChange}>
731807 <CollapsibleContent>
732808 <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4">
733− <div>
734− <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
735− <span className="text-xs font-medium text-foreground">Display name</span>
736− <DraftInput
737− id={`provider-instance-${instanceId}-display-name`}
738− className="mt-1.5"
739− value={instance.displayName ?? ""}
740− onCommit={updateDisplayName}
741− placeholder={driverOption?.label ?? "Instance label"}
742− spellCheck={false}
743− />
809+ {isProviderInstanceDisplayNameEditable(String(instance.driver)) ? (
810+ <div>
811+ <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
812+ <span className="text-xs font-medium text-foreground">Display name</span>
813+ <DraftInput
814+ id={`provider-instance-${instanceId}-display-name`}
815+ className="mt-1.5"
816+ value={instance.displayName ?? ""}
817+ onCommit={updateDisplayName}
818+ placeholder={driverOption?.label ?? "Instance label"}
819+ spellCheck={false}
820+ />
821+ <span className="mt-1 block text-xs text-muted-foreground">
822+ Optional label shown in the provider list.
823+ </span>
824+ </label>
825+ </div>
826+ ) : (
827+ <div>
828+ <span className="text-xs font-medium text-foreground">Hermes nickname</span>
829+ <div
830+ className="mt-1.5 rounded-md border border-border/70 bg-muted/35 px-3 py-2 text-sm text-foreground"
831+ data-hermes-nickname-readonly="true"
832+ >
833+ {displayName}
834+ </div>
744835 <span className="mt-1 block text-xs text-muted-foreground">
745− Optional label shown in the provider list.
836+ Fixed after creation because this nickname identifies the paired Hermes instance.
837+ Delete and re-add the instance to choose a different nickname.
746838 </span>
747− </label>
748− </div>
839+ </div>
840+ )}
749841
750842 <div>
751843 <ProviderAccentColorPicker
@@ -757,14 +849,18 @@ export function ProviderInstanceCard({ · 5 unmodified lines
757849 />
758850 </div>
759851
760− <div>
761− <ProviderEnvironmentSection
762− environment={instance.environment ?? []}
763− onChange={updateEnvironment}
764− />
765− </div>
852+ {isHermes ? (
853+ <HermesGatewayInstanceSection instanceId={instanceId} nickname={hermesNickname} />
854+ ) : (
855+ <div>
856+ <ProviderEnvironmentSection
857+ environment={instance.environment ?? []}
858+ onChange={updateEnvironment}
859+ />
860+ </div>
861+ )}
766862
767− {driverOption ? (
863+ {driverOption && !isHermes ? (
768864 <ProviderSettingsForm
769865 definition={driverOption}
770866 value={instance.config}
@@ -774,7 +870,7 @@ export function ProviderInstanceCard({ · 3 unmodified lines
774870 />
775871 ) : null}
776872
777− {driverOption !== undefined ? (
873+ {driverOption !== undefined && !isHermes ? (
778874 <ProviderModelsSection
779875 instanceId={instanceId}
780876 driverKind={driverKind}
@@ -788,7 +884,7 @@ export function ProviderInstanceCard({ · 7 unmodified lines
788884 onFavoriteModelsChange={onFavoriteModelsChange}
789885 onModelOrderChange={onModelOrderChange}
790886 />
791− ) : (
887+ ) : driverOption === undefined ? (
792888 <div>
793889 <p className="text-xs text-muted-foreground">
794890 This instance uses a driver (
@@ -797,7 +893,7 @@ export function ProviderInstanceCard({ · 2 unmodified lines
797893 edited from this surface.
798894 </p>
799895 </div>
800− )}
896+ ) : null}
801897 </div>
802898 </CollapsibleContent>
803899 </Collapsible>
apps/web/src/components/settings/providerDriverMeta.ts+8−0
@@ -3,11 +3,13 @@ import { · 2 unmodified lines
33 CodexSettings,
44 CursorSettings,
55 GrokSettings,
6+ HermesSettings,
67 OpenCodeSettings,
78 ProviderDriverKind,
89 } from "@t3tools/contracts";
910 import type * as Schema from "effect/Schema";
1011 import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
12+import { HermesIcon } from "../HermesIcon";
1113
1214 type ProviderSettingsSchema = {
1315 readonly fields: Readonly<Record<string, Schema.Top>>;
@@ -67,6 +69,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = · 53 unmodified lines
6769 icon: OpenCodeIcon,
6870 settingsSchema: OpenCodeSettings,
6971 },
72+ {
73+ value: ProviderDriverKind.make("hermes"),
74+ label: "Hermes",
75+ icon: HermesIcon,
76+ settingsSchema: HermesSettings,
77+ },
7078 ];
7179
7280 export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
apps/web/src/modelSelection.ts+1−1
@@ -64,7 +64,7 @@ function readInstanceCustomModels( · 63 unmodified lines
6464 }
6565 const legacyProviders = settings.providers as Record<
6666 string,
67− { readonly customModels: ReadonlyArray<string> } | undefined
67+ { readonly customModels?: ReadonlyArray<string> } | undefined
6868 >;
6969 return legacyProviders[driverKind]?.customModels ?? [];
7070 }
apps/web/src/providerInstances.test.ts+89−0
@@ -5,7 +5,10 @@ import { · 4 unmodified lines
55 deriveProviderInstanceEntries,
66 getDefaultProviderInstanceModel,
77 isProviderInstancePickerReady,
8+ isProviderInstanceComposerAvailable,
9+ getProviderInstanceComposerUnavailableMessage,
810 isProviderInstancePickerVisible,
11+ resolveComposerProviderInstanceId,
912 resolveDefaultProviderModelSelection,
1013 resolveSelectableProviderInstance,
1114 resolveProviderDriverKindForInstanceSelection,
@@ -68,6 +71,66 @@ describe("isProviderInstancePickerReady", () => { · 56 unmodified lines
6871 });
6972 });
7073
74+describe("Hermes composer availability", () => {
75+ it("keeps an offline Hermes instance selected but marks it unsendable", () => {
76+ const [entry] = deriveProviderInstanceEntries([
77+ provider({
78+ provider: ProviderDriverKind.make("hermes"),
79+ instanceId: "hermes-research",
80+ displayName: "Research",
81+ status: "error",
82+ }),
83+ ]);
84+
85+ expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(false);
86+ expect(getProviderInstanceComposerUnavailableMessage(entry)).toContain(
87+ "Hermes · Research is offline",
88+ );
89+ });
90+
91+ it("does not tighten legacy composer availability for other providers", () => {
92+ const [entry] = deriveProviderInstanceEntries([
93+ provider({
94+ provider: ProviderDriverKind.make("codex"),
95+ instanceId: "codex",
96+ status: "error",
97+ }),
98+ ]);
99+
100+ expect(entry && isProviderInstanceComposerAvailable(entry)).toBe(true);
101+ });
102+
103+ it("preserves a missing bound Hermes instance instead of rebinding to another gateway", () => {
104+ const missing = ProviderInstanceId.make("hermes-research");
105+ const connected = ProviderInstanceId.make("hermes-lab");
106+ const entries = deriveProviderInstanceEntries([
107+ provider({
108+ provider: ProviderDriverKind.make("hermes"),
109+ instanceId: connected,
110+ }),
111+ ]);
112+
113+ expect(
114+ resolveComposerProviderInstanceId({
115+ entries,
116+ draftInstanceId: null,
117+ threadInstanceId: missing,
118+ threadModelInstanceId: missing,
119+ projectInstanceId: connected,
120+ requestedDriverKind: ProviderDriverKind.make("hermes"),
121+ lockedProvider: ProviderDriverKind.make("hermes"),
122+ lockedContinuationGroupKey: null,
123+ }),
124+ ).toBe(missing);
125+ expect(
126+ getProviderInstanceComposerUnavailableMessage(undefined, {
127+ driverKind: ProviderDriverKind.make("hermes"),
128+ instanceId: missing,
129+ }),
130+ ).toContain("hermes-research");
131+ });
132+});
133+
71134 describe("isProviderInstancePickerVisible", () => {
72135 it("keeps enabled instances in the rail and removes disabled instances", () => {
73136 const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([
@@ -130,6 +193,32 @@ describe("deriveProviderInstanceEntries", () => { · 56 unmodified lines
130193 expect(entry?.driverKind).toBe("codex");
131194 expect(entry?.isDefault).toBe(false);
132195 });
196+
197+ it("formats default and custom Hermes instance labels consistently", () => {
198+ const entries = deriveProviderInstanceEntries([
199+ provider({
200+ provider: ProviderDriverKind.make("hermes"),
201+ instanceId: "hermes",
202+ displayName: "Hermes",
203+ }),
204+ provider({
205+ provider: ProviderDriverKind.make("hermes"),
206+ instanceId: "hermes-research",
207+ displayName: "Research",
208+ }),
209+ provider({
210+ provider: ProviderDriverKind.make("hermes"),
211+ instanceId: "hermes-lab",
212+ displayName: "Hermes · Lab",
213+ }),
214+ ]);
215+
216+ expect(entries.map((entry) => entry.displayName)).toEqual([
217+ "Hermes",
218+ "Hermes · Research",
219+ "Hermes · Lab",
220+ ]);
221+ });
133222 });
134223
135224 describe("resolveSelectableProviderInstance", () => {
apps/web/src/providerInstances.ts+94−0
@@ -74,6 +74,31 @@ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boo · 73 unmodified lines
7474 return entry.enabled && entry.isAvailable && entry.status === "ready";
7575 }
7676
77+/**
78+ * Existing providers retain their historical composer behavior while Hermes
79+ * requires a live gateway connection. This keeps an offline Hermes thread
80+ * selected and readable without allowing a turn to be routed elsewhere.
81+ */
82+export function isProviderInstanceComposerAvailable(entry: ProviderInstanceEntry): boolean {
83+ return entry.driverKind !== "hermes" || isProviderInstancePickerReady(entry);
84+}
85+
86+export function getProviderInstanceComposerUnavailableMessage(
87+ entry: ProviderInstanceEntry | undefined,
88+ missingSelection?: {
89+ readonly driverKind: ProviderDriverKind;
90+ readonly instanceId: ProviderInstanceId;
91+ },
92+): string {
93+ if (entry?.driverKind === "hermes") {
94+ return `${entry.displayName} is offline. Reconnect it in Settings to continue this thread.`;
95+ }
96+ if (missingSelection?.driverKind === "hermes") {
97+ return `Hermes instance '${missingSelection.instanceId}' is unavailable. Restore it in Settings to continue this thread.`;
98+ }
99+ return "Enable a provider in Settings to send a message.";
100+}
101+
77102 /** Picker rails contain configured, enabled instances only. */
78103 export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean {
79104 return entry.enabled;
@@ -139,6 +164,15 @@ function resolveInstanceDisplayName( · 59 unmodified lines
139164 ): string {
140165 const trimmedSnapshotName = snapshot.displayName?.trim();
141166 const kindLabel = driverKindLabel(driverKind);
167+ if (driverKind === "hermes") {
168+ if (isDefault) return "Hermes";
169+ const candidate =
170+ trimmedSnapshotName && trimmedSnapshotName !== kindLabel
171+ ? trimmedSnapshotName
172+ : humanizeInstanceId(instanceId).replace(/^Hermes(?:\s+|$)/u, "");
173+ const nickname = candidate.replace(/^Hermes\s*[·:–—-]\s*/u, "").trim();
174+ return nickname ? `Hermes · ${nickname}` : "Hermes";
175+ }
142176 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) {
143177 return trimmedSnapshotName;
144178 }
@@ -312,6 +346,66 @@ export function resolveSelectableProviderInstanceEntry( · 167 unmodified lines
312346 );
313347 }
314348
349+/**
350+ * Resolve the instance used by the composer while preserving an existing
351+ * Hermes thread's immutable gateway binding. Hermes sessions cannot be moved
352+ * to another instance, so a missing or disabled bound instance remains the
353+ * exact selection and lets the composer surface its unavailable state instead
354+ * of silently routing the next message to another connected Hermes process.
355+ */
356+export function resolveComposerProviderInstanceId(input: {
357+ readonly entries: ReadonlyArray<ProviderInstanceEntry>;
358+ readonly draftInstanceId: ProviderInstanceId | null;
359+ readonly threadInstanceId: ProviderInstanceId | undefined;
360+ readonly threadModelInstanceId: ProviderInstanceId | undefined;
361+ readonly projectInstanceId: ProviderInstanceId | undefined;
362+ readonly requestedDriverKind: ProviderDriverKind;
363+ readonly lockedProvider: ProviderDriverKind | null;
364+ readonly lockedContinuationGroupKey: string | null;
365+}): ProviderInstanceId {
366+ const boundThreadInstanceId = input.threadInstanceId ?? input.threadModelInstanceId;
367+ if (input.lockedProvider === "hermes" && boundThreadInstanceId) {
368+ return boundThreadInstanceId;
369+ }
370+
371+ const candidates = [
372+ input.draftInstanceId,
373+ input.threadInstanceId,
374+ input.threadModelInstanceId,
375+ input.projectInstanceId,
376+ ];
377+ for (const candidate of candidates) {
378+ if (!candidate) continue;
379+ const match = input.entries.find(
380+ (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,
381+ );
382+ if (!match) continue;
383+ if (input.lockedProvider && match.driverKind !== input.lockedProvider) continue;
384+ if (
385+ input.lockedContinuationGroupKey &&
386+ match.continuationGroupKey !== input.lockedContinuationGroupKey
387+ ) {
388+ continue;
389+ }
390+ return match.instanceId;
391+ }
392+
393+ const compatibleEntries = input.entries.filter(
394+ (entry) =>
395+ (!input.lockedProvider || entry.driverKind === input.lockedProvider) &&
396+ (!input.lockedContinuationGroupKey ||
397+ entry.continuationGroupKey === input.lockedContinuationGroupKey),
398+ );
399+ const requestedDriverEntries = compatibleEntries.filter(
400+ (entry) => entry.driverKind === input.requestedDriverKind,
401+ );
402+ return (
403+ resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ??
404+ resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ??
405+ NO_PROVIDER_MODEL_SELECTION.instanceId
406+ );
407+}
408+
315409 /**
316410 * Resolve the routing key for a selection that may reference an instance
317411 * id that no longer exists (e.g. a persisted thread selection after the
integrations/hermes-t3-gateway/COMPATIBILITY.md+48−0
@@ -0,0 +1,48 @@
1+# Hermes event compatibility inventory
2+
3+The plugin deliberately uses only the public Hermes plugin and platform-adapter
4+surfaces audited at Hermes Agent commit
5+`3910ab28c0892fcf846fc61318d2fd15689eddf1`.
6+
7+## Mapped in V1
8+
9+| Hermes surface | T3 gateway event |
10+| ----------------------------------------- | ---------------------------------------------- |
11+| Cumulative `send` / `edit_message` output | `content.delta` |
12+| Final stream edit | `item.completed`, `turn.completed` |
13+| `pre_tool_call` / `post_tool_call` hooks | Typed `item.started` / `item.completed` |
14+| Live adapter status text | Generic `unknown` activity item |
15+| `send_exec_approval` | `request.opened` / `request.resolved` |
16+| `send_clarify` | `user-input.requested` / `user-input.resolved` |
17+| `/steer` gateway command | `turn.steer` |
18+| Adapter interrupt event | `turn.interrupt` |
19+
20+## Known limitations
21+
22+- The platform adapter receives cumulative rendered text, not the underlying
23+ token stream category. V1 maps it to `assistant_text`; Hermes reasoning,
24+ plan, and command-output stream categories are not publicly exposed here.
25+- The delta contract has no replacement operation. Rare edits that revise
26+ already-emitted text become a meaningful generic activity instead of
27+ corrupting the T3 transcript.
28+- Hermes' documented tool hook surface exposes a `task_id`, tool name,
29+ arguments, string result, and duration. The audited runtime additionally
30+ supplies `session_id` and `tool_call_id`; V1 uses them when present and falls
31+ back to the documented IDs for older versions. It projects only canonical,
32+ whitelisted fields (command/cwd, file path, search query, image path, or MCP
33+ server/operation); arbitrary arguments and raw results never cross the wire.
34+- Approval resolution is session-FIFO in Hermes. T3 request IDs identify the UI
35+ prompt, then resolve the oldest matching Hermes approval for that session.
36+- The public `clarify` hook is a single question. The wire protocol supports an
37+ array so richer structured input can be added without a protocol break.
38+- Hermes session completion has no dedicated platform-adapter callback. The
39+ plugin uses the stream consumer's required `finalize=True` edit as the
40+ authoritative completion boundary.
41+- Active `/steer` dispatch returns a textual Hermes control acknowledgement
42+ through the normal platform `send(..., notify=True)` path. The plugin
43+ captures that response in the originating steering request's async context,
44+ suppresses it from the transcript, and acknowledges T3 only when the audited
45+ Hermes success response begins with `⏩ Steer queued`. Unknown future response
46+ shapes fail closed with `protocol.error` rather than completing the turn.
47+- Attachments are not accepted. They are the first planned post-stability
48+ feature; the capability is reserved and fixed to `false` in protocol V1.
integrations/hermes-t3-gateway/README.md+71−0
@@ -0,0 +1,71 @@
1+# Hermes T3 Code Gateway
2+
3+Experimental Hermes platform plugin for connecting one already-running Hermes
4+process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
5+does not need to listen on a public port.
6+
7+Each T3 thread maps deterministically to one Hermes gateway session. A new T3
8+thread creates a new session identity, later messages use the same identity,
9+active-turn messages use Hermes' native `/steer` path, and stopping a turn does
10+not delete its transcript.
11+
12+## Install from this repository
13+
14+Copy or symlink this directory into the active Hermes profile:
15+
16+```bash
17+mkdir -p ~/.hermes/plugins
18+ln -s /path/to/t3code/integrations/hermes-t3-gateway \
19+ ~/.hermes/plugins/hermes-t3-gateway
20+hermes plugins enable hermes-t3-gateway
21+```
22+
23+In T3 Code, open the Hermes instance settings, choose **Add Hermes**, enter a
24+unique nickname, and copy the generated enrollment command. It has this shape:
25+
26+```bash
27+hermes t3 connect \
28+ --url https://siva.davis7.space:<port> \
29+ --token <one-time-token>
30+```
31+
32+`--url` accepts an HTTP(S) browser origin or an explicit WS(S) URL. The command
33+normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated
34+`connection.hello` frame, and saves these values with Hermes'
35+profile-aware `save_env_value` helper:
36+
37+- `HERMES_T3_GATEWAY_URL`
38+- `HERMES_T3_GATEWAY_INSTANCE_ID`
39+- `HERMES_T3_GATEWAY_CREDENTIAL`
40+- `HERMES_T3_GATEWAY_NICKNAME`
41+
42+The long-lived credential is never printed. Restart `hermes gateway` after
43+enrollment. `hermes t3 status` reports the local enrollment without revealing
44+the credential.
45+
46+## V1 scope
47+
48+- Text input and live assistant streaming
49+- Multiple concurrent Hermes sessions in one process
50+- Active-turn steering and interrupt
51+- Dangerous-command approvals
52+- Structured `clarify` questions
53+- Tool lifecycle activity through Hermes plugin hooks
54+- Reconnect with bounded backoff
55+- Version-incompatible and revoked credentials fail closed
56+
57+Attachments intentionally advertise `false`. Adding bounded image/file input is
58+the first post-stability feature and should not reuse arbitrary raw payloads.
59+
60+See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface
61+limitations.
62+
63+## Tests
64+
65+The pure protocol and transport tests do not require a live Hermes or T3 server:
66+
67+```bash
68+python -m unittest discover \
69+ integrations/hermes-t3-gateway/tests \
70+ -p 'test_*.py'
71+```
integrations/hermes-t3-gateway/__init__.py+77−0
@@ -0,0 +1,77 @@
1+"""T3 Code gateway plugin registration for Hermes Agent."""
2+# ruff: noqa: N999 - Hermes loads hyphenated plugin directories dynamically.
3+
4+from __future__ import annotations
5+
6+from .adapter import (
7+ T3PlatformAdapter,
8+ check_requirements,
9+ env_enablement,
10+ validate_config,
11+)
12+from .cli import register_cli, t3_command
13+
14+
15+def _pre_tool_call(
16+ tool_name: str,
17+ args: dict,
18+ task_id: str,
19+ **kwargs,
20+) -> None:
21+ session_id = str(kwargs.get("session_id") or task_id)
22+ tool_call_id = str(kwargs.get("tool_call_id") or "")
23+ T3PlatformAdapter.route_tool_started(tool_name, args, session_id, tool_call_id)
24+
25+
26+def _post_tool_call(
27+ tool_name: str,
28+ args: dict,
29+ result: str,
30+ task_id: str,
31+ duration_ms: int | None = None,
32+ **kwargs,
33+) -> None:
34+ del args
35+ session_id = str(kwargs.get("session_id") or task_id)
36+ tool_call_id = str(kwargs.get("tool_call_id") or "")
37+ status = str(kwargs.get("status") or "")
38+ T3PlatformAdapter.route_tool_completed(
39+ tool_name, result, session_id, duration_ms, tool_call_id, status
40+ )
41+
42+
43+def register(ctx) -> None:
44+ ctx.register_platform(
45+ name="t3",
46+ label="T3 Code",
47+ adapter_factory=lambda config: T3PlatformAdapter(config),
48+ check_fn=check_requirements,
49+ validate_config=validate_config,
50+ required_env=[
51+ "HERMES_T3_GATEWAY_URL",
52+ "HERMES_T3_GATEWAY_INSTANCE_ID",
53+ "HERMES_T3_GATEWAY_CREDENTIAL",
54+ ],
55+ env_enablement_fn=env_enablement,
56+ max_message_length=120_000,
57+ emoji="🔺",
58+ pii_safe=True,
59+ platform_hint=(
60+ "You are chatting through T3 Code. Preserve normal Hermes behavior; "
61+ "T3 renders streamed text, tool activity, approvals, and questions."
62+ ),
63+ )
64+ ctx.register_cli_command(
65+ name="t3",
66+ help="Pair and inspect the T3 Code gateway",
67+ setup_fn=register_cli,
68+ handler_fn=t3_command,
69+ description=(
70+ "Connect this Hermes process to a named T3 Code provider instance."
71+ ),
72+ )
73+ ctx.register_hook("pre_tool_call", _pre_tool_call)
74+ ctx.register_hook("post_tool_call", _post_tool_call)
75+
76+
77+__all__ = ["register"]
integrations/hermes-t3-gateway/adapter.py+940−0
@@ -0,0 +1,940 @@
1+"""Hermes platform adapter that treats each T3 thread as one Hermes session."""
2+
3+from __future__ import annotations
4+
5+import asyncio
6+import contextvars
7+import logging
8+import os
9+import threading
10+import uuid
11+import weakref
12+from dataclasses import dataclass, field
13+from typing import Any
14+
15+from gateway.config import Platform, PlatformConfig
16+from gateway.platforms.base import (
17+ BasePlatformAdapter,
18+ MessageEvent,
19+ MessageType,
20+ SendResult,
21+)
22+from gateway.session import build_session_key
23+
24+from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV
25+from .connection import T3GatewayConnection, dependency_available
26+from .protocol import (
27+ canonical_tool_data,
28+ canonical_tool_item_type,
29+ frame,
30+ iso_now,
31+ item_id,
32+ protocol_error,
33+ validate_server_frame,
34+)
35+
36+logger = logging.getLogger(__name__)
37+
38+
39+def _hermes_version() -> str:
40+ try:
41+ from hermes_cli import __version__
42+
43+ return str(__version__)
44+ except Exception: # noqa: BLE001 - version discovery must not block loading
45+ return "unknown"
46+
47+
48+@dataclass
49+class _TurnState:
50+ thread_id: str
51+ session_id: str
52+ turn_id: str
53+ request_id: str
54+ message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
55+ visible_text: str = ""
56+ assistant_started: bool = False
57+ tool_items: dict[str, str] = field(default_factory=dict)
58+ generic_activity_id: str | None = None
59+ generic_activity_detail: str | None = None
60+
61+
62+@dataclass
63+class _SteerControlResponse:
64+ thread_id: str
65+ request_id: str
66+ messages: list[str] = field(default_factory=list)
67+
68+
69+_steer_control_response = contextvars.ContextVar[_SteerControlResponse | None](
70+ "hermes_t3_steer_control_response",
71+ default=None,
72+)
73+
74+
75+class T3PlatformAdapter(BasePlatformAdapter):
76+ """One process-level T3 connection serving many isolated thread sessions."""
77+
78+ supports_code_blocks = True
79+ supports_status_text = True
80+ REQUIRES_EDIT_FINALIZE = True
81+ MAX_MESSAGE_LENGTH = 120_000
82+ _instances: weakref.WeakSet[T3PlatformAdapter] = weakref.WeakSet()
83+
84+ def __init__(self, config: PlatformConfig):
85+ super().__init__(config, Platform("t3"))
86+ extra = config.extra or {}
87+ self._url = str(extra.get("url") or os.environ.get(URL_ENV, "")).strip()
88+ self._instance_id = str(
89+ extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, "")
90+ ).strip()
91+ self._credential = str(
92+ extra.get("credential") or os.environ.get(CREDENTIAL_ENV, "")
93+ ).strip()
94+ self._nickname = str(
95+ extra.get("nickname") or os.environ.get(NICKNAME_ENV, "") or "Hermes"
96+ ).strip()
97+ self._connection: T3GatewayConnection | None = None
98+ self._event_loop: asyncio.AbstractEventLoop | None = None
99+ self._sessions: dict[str, str] = {}
100+ self._active_session_threads: set[str] = set()
101+ self._thread_by_session: dict[str, str] = {}
102+ self._active_turns: dict[str, _TurnState] = {}
103+ self._approval_requests: dict[str, tuple[str, str]] = {}
104+ self._user_input_requests: dict[str, tuple[str, str]] = {}
105+ type(self)._instances.add(self)
106+
107+ @property
108+ def name(self) -> str:
109+ return f"T3 Code ({self._nickname})"
110+
111+ @property
112+ def authorization_is_upstream(self) -> bool:
113+ # The only source of inbound messages is T3's instance-authenticated
114+ # socket. There is no separate Hermes-side user allowlist.
115+ return True
116+
117+ async def connect(self, *, is_reconnect: bool = False) -> bool:
118+ del is_reconnect
119+ if not (self._url and self._instance_id and self._credential):
120+ self._set_fatal_error(
121+ "t3_not_enrolled",
122+ "Run `hermes t3 connect --url <url> --token <token>` first.",
123+ retryable=False,
124+ )
125+ return False
126+ self._event_loop = asyncio.get_running_loop()
127+ self._connection = T3GatewayConnection(
128+ url=self._url,
129+ instance_id=self._instance_id,
130+ credential=self._credential,
131+ hermes_version=_hermes_version(),
132+ on_message=self._handle_server_frame,
133+ on_state=self._handle_connection_state,
134+ )
135+ try:
136+ connected = await self._connection.connect()
137+ except Exception as exc: # noqa: BLE001 - transport supplies typed rejection details
138+ self._set_fatal_error("t3_connection_rejected", str(exc), retryable=False)
139+ return False
140+ if connected:
141+ self._mark_connected()
142+ await self._send_status()
143+ return connected
144+
145+ async def disconnect(self) -> None:
146+ if self._connection is not None:
147+ await self._connection.disconnect()
148+ self._connection = None
149+ self._mark_disconnected()
150+
151+ async def send(
152+ self,
153+ chat_id: str,
154+ content: str,
155+ reply_to: str | None = None,
156+ metadata: dict[str, Any] | None = None,
157+ ) -> SendResult:
158+ del reply_to
159+ captured = self._capture_steer_control_response(chat_id, content)
160+ if captured is not None:
161+ return captured
162+ turn = self._active_turns.get(str(chat_id))
163+ if turn is None:
164+ return SendResult(success=False, error="no active T3 turn")
165+ try:
166+ await self._emit_assistant_content(turn, content)
167+ if bool((metadata or {}).get("notify")):
168+ await self._complete_turn(turn)
169+ return SendResult(success=True, message_id=turn.message_id)
170+ except Exception as exc: # noqa: BLE001 - adapter send must return SendResult
171+ return SendResult(success=False, error=str(exc))
172+
173+ async def edit_message(
174+ self,
175+ chat_id: str,
176+ message_id: str,
177+ content: str,
178+ *,
179+ finalize: bool = False,
180+ metadata: dict[str, Any] | None = None,
181+ ) -> SendResult:
182+ del metadata
183+ captured = self._capture_steer_control_response(chat_id, content)
184+ if captured is not None:
185+ return captured
186+ turn = self._active_turns.get(str(chat_id))
187+ if turn is None:
188+ return SendResult(success=False, error="no active T3 turn")
189+ try:
190+ await self._emit_assistant_content(turn, content)
191+ if finalize:
192+ await self._complete_turn(turn)
193+ return SendResult(success=True, message_id=message_id)
194+ except Exception as exc: # noqa: BLE001 - adapter edit must return SendResult
195+ return SendResult(success=False, error=str(exc))
196+
197+ async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
198+ return {"name": f"T3 thread {chat_id}", "type": "dm"}
199+
200+ async def send_typing(
201+ self, chat_id: str, metadata: dict[str, Any] | None = None
202+ ) -> None:
203+ del metadata
204+ turn = self._active_turns.get(str(chat_id))
205+ if turn is None:
206+ return
207+ status = getattr(self, "_status_text", {}).get(str(chat_id))
208+ if status:
209+ await self._emit_generic_activity(turn, status)
210+
211+ def set_status_text(self, chat_id: str, text: str | None) -> None:
212+ super().set_status_text(chat_id, text)
213+ if not text:
214+ return
215+ turn = self._active_turns.get(str(chat_id))
216+ if turn is not None:
217+ self._schedule(self._emit_generic_activity(turn, text))
218+
219+ async def send_exec_approval(
220+ self,
221+ chat_id: str,
222+ command: str,
223+ session_key: str,
224+ description: str,
225+ metadata: dict[str, Any] | None = None,
226+ **kwargs: Any,
227+ ) -> SendResult:
228+ del metadata, kwargs
229+ turn = self._active_turns.get(str(chat_id))
230+ if turn is None:
231+ return SendResult(success=False, error="no active T3 turn")
232+ approval_id = str(uuid.uuid4())
233+ self._approval_requests[approval_id] = (session_key, turn.turn_id)
234+ await self._send_frame(
235+ frame(
236+ "request.opened",
237+ threadId=turn.thread_id,
238+ sessionId=turn.session_id,
239+ turnId=turn.turn_id,
240+ requestId=approval_id,
241+ requestType="command_execution_approval",
242+ detail=description or "Hermes requests permission to run a command",
243+ args={"command": command},
244+ )
245+ )
246+ return SendResult(success=True, message_id=approval_id)
247+
248+ async def send_clarify(
249+ self,
250+ chat_id: str,
251+ question: str,
252+ choices: list[Any] | None,
253+ clarify_id: str,
254+ session_key: str,
255+ metadata: dict[str, Any] | None = None,
256+ ) -> SendResult:
257+ del metadata
258+ turn = self._active_turns.get(str(chat_id))
259+ if turn is None:
260+ return SendResult(success=False, error="no active T3 turn")
261+ options = []
262+ for choice in choices or []:
263+ label = str(choice.get("label") if isinstance(choice, dict) else choice)
264+ description = (
265+ str(choice.get("description") or label)
266+ if isinstance(choice, dict)
267+ else label
268+ )
269+ options.append({"label": label, "description": description})
270+ self._user_input_requests[clarify_id] = (session_key, turn.turn_id)
271+ await self._send_frame(
272+ frame(
273+ "user-input.requested",
274+ threadId=turn.thread_id,
275+ sessionId=turn.session_id,
276+ turnId=turn.turn_id,
277+ requestId=clarify_id,
278+ questions=[
279+ {
280+ "id": clarify_id,
281+ "header": "Hermes",
282+ "question": question,
283+ "options": options,
284+ "multiSelect": False,
285+ }
286+ ],
287+ )
288+ )
289+ return SendResult(success=True, message_id=clarify_id)
290+
291+ async def _handle_server_frame(self, raw: dict[str, Any]) -> None:
292+ request = raw.get("requestId")
293+ try:
294+ message = validate_server_frame(raw)
295+ frame_type = message["type"]
296+ if frame_type == "session.ensure":
297+ await self._ensure_session(message)
298+ elif frame_type == "turn.start":
299+ await self._start_turn(message)
300+ elif frame_type == "turn.steer":
301+ await self._steer_turn(message)
302+ elif frame_type == "turn.interrupt":
303+ await self._interrupt_turn(message)
304+ elif frame_type == "approval.respond":
305+ await self._resolve_approval(message)
306+ elif frame_type == "user-input.respond":
307+ await self._resolve_user_input(message)
308+ elif frame_type == "session.stop":
309+ await self._stop_session(message)
310+ elif frame_type == "ping":
311+ await self._send_frame(
312+ frame(
313+ "pong",
314+ requestId=message["requestId"],
315+ sentAt=message.get("sentAt") or iso_now(),
316+ )
317+ )
318+ except ValueError as exc:
319+ await self._send_frame(
320+ protocol_error(
321+ "unsupported-message",
322+ str(exc),
323+ recoverable=True,
324+ related_request_id=str(request) if request else None,
325+ )
326+ )
327+ except Exception as exc:
328+ logger.exception("T3 gateway command failed")
329+ await self._send_frame(
330+ protocol_error(
331+ "internal-error",
332+ str(exc) or type(exc).__name__,
333+ recoverable=True,
334+ related_request_id=str(request) if request else None,
335+ )
336+ )
337+
338+ async def _ensure_session(self, message: dict[str, Any]) -> None:
339+ thread_id = str(message["threadId"])
340+ source = self._source(thread_id, str(message["requestId"]))
341+ session_id = build_session_key(source)
342+ resume_id = str(message.get("resumeSessionId") or "")
343+ self._sessions[thread_id] = session_id
344+ self._active_session_threads.add(thread_id)
345+ self._thread_by_session[session_id] = thread_id
346+ await self._send_frame(
347+ frame(
348+ "session.ready",
349+ requestId=message["requestId"],
350+ threadId=thread_id,
351+ sessionId=session_id,
352+ resumed=bool(resume_id and resume_id == session_id),
353+ )
354+ )
355+ await self._send_status()
356+
357+ async def _start_turn(self, message: dict[str, Any]) -> None:
358+ thread_id = str(message["threadId"])
359+ session_id = self._sessions.get(thread_id)
360+ if not session_id or session_id != str(message["sessionId"]):
361+ await self._send_frame(
362+ protocol_error(
363+ "session-not-found",
364+ "Call session.ensure before starting a turn.",
365+ recoverable=True,
366+ related_request_id=str(message["requestId"]),
367+ )
368+ )
369+ return
370+ if thread_id in self._active_turns:
371+ await self._send_frame(
372+ protocol_error(
373+ "invalid-message",
374+ "This Hermes session already has an active turn; use turn.steer.",
375+ recoverable=True,
376+ related_request_id=str(message["requestId"]),
377+ )
378+ )
379+ return
380+ turn = _TurnState(
381+ thread_id=thread_id,
382+ session_id=session_id,
383+ turn_id=str(message["turnId"]),
384+ request_id=str(message["requestId"]),
385+ )
386+ self._active_turns[thread_id] = turn
387+ await self._send_frame(
388+ frame(
389+ "turn.started",
390+ requestId=turn.request_id,
391+ threadId=thread_id,
392+ sessionId=session_id,
393+ turnId=turn.turn_id,
394+ )
395+ )
396+ await self._send_status()
397+ await self.handle_message(
398+ MessageEvent(
399+ text=str(message["text"]),
400+ message_type=(
401+ MessageType.COMMAND
402+ if str(message["text"]).lstrip().startswith("/")
403+ else MessageType.TEXT
404+ ),
405+ source=self._source(thread_id, turn.request_id),
406+ message_id=turn.request_id,
407+ metadata={"t3_turn_id": turn.turn_id},
408+ )
409+ )
410+
411+ async def _steer_turn(self, message: dict[str, Any]) -> None:
412+ turn = self._active_turns.get(str(message["threadId"]))
413+ if turn is None or turn.turn_id != str(message["turnId"]):
414+ await self._send_frame(
415+ protocol_error(
416+ "turn-not-active",
417+ "The requested Hermes turn is no longer active.",
418+ recoverable=True,
419+ related_request_id=str(message["requestId"]),
420+ )
421+ )
422+ return
423+ # `/steer` is Hermes' official active-run injection surface. The base
424+ # adapter dispatches active slash commands inline, then sends the
425+ # command's textual acknowledgement back through this adapter with
426+ # `notify=True`. Capture that one command response by request context:
427+ # it is control traffic, not assistant output and not a turn boundary.
428+ control = _SteerControlResponse(
429+ thread_id=turn.thread_id,
430+ request_id=str(message["requestId"]),
431+ )
432+ context_token = _steer_control_response.set(control)
433+ command_error: Exception | None = None
434+ try:
435+ await self.handle_message(
436+ MessageEvent(
437+ text=f"/steer {message['text']}",
438+ message_type=MessageType.COMMAND,
439+ source=self._source(turn.thread_id, control.request_id),
440+ message_id=control.request_id,
441+ metadata={"t3_turn_id": turn.turn_id, "t3_steer": True},
442+ )
443+ )
444+ except Exception as exc: # noqa: BLE001 - translate command failures to the wire
445+ command_error = exc
446+ finally:
447+ _steer_control_response.reset(context_token)
448+
449+ if command_error is not None:
450+ await self._send_frame(
451+ protocol_error(
452+ "internal-error",
453+ str(command_error) or "Hermes steering failed.",
454+ recoverable=True,
455+ related_request_id=control.request_id,
456+ )
457+ )
458+ return
459+
460+ response = control.messages[-1] if control.messages else ""
461+ if not response.startswith("⏩ Steer queued"):
462+ if response.startswith(("Agent still starting", "No active agent")):
463+ error_code = "turn-not-active"
464+ elif response.startswith("⚠️ Steer failed"):
465+ error_code = "internal-error"
466+ else:
467+ error_code = "invalid-message"
468+ await self._send_frame(
469+ protocol_error(
470+ error_code,
471+ response or "Hermes did not acknowledge the steering request.",
472+ recoverable=True,
473+ related_request_id=control.request_id,
474+ )
475+ )
476+ return
477+
478+ # Correlated command acknowledgement. It intentionally reuses the
479+ # existing turnId: this is not a second runtime turn. T3 consumes the
480+ # steering requestId as its broker acknowledgement and suppresses the
481+ # duplicate turn-start lifecycle projection.
482+ await self._send_frame(
483+ frame(
484+ "turn.started",
485+ requestId=control.request_id,
486+ threadId=turn.thread_id,
487+ sessionId=turn.session_id,
488+ turnId=turn.turn_id,
489+ )
490+ )
491+
492+ def _capture_steer_control_response(
493+ self,
494+ chat_id: str,
495+ content: str,
496+ ) -> SendResult | None:
497+ control = _steer_control_response.get()
498+ if control is None or control.thread_id != str(chat_id):
499+ return None
500+ control.messages.append(str(content))
501+ return SendResult(
502+ success=True,
503+ message_id=f"t3-steer-control-{control.request_id}",
504+ )
505+
506+ async def _interrupt_turn(self, message: dict[str, Any]) -> None:
507+ thread_id = str(message["threadId"])
508+ turn = self._active_turns.get(thread_id)
509+ if turn is None or turn.turn_id != str(message["turnId"]):
510+ await self._send_frame(
511+ protocol_error(
512+ "turn-not-active",
513+ "The requested Hermes turn is no longer active.",
514+ recoverable=True,
515+ related_request_id=str(message["requestId"]),
516+ )
517+ )
518+ return
519+ await self.interrupt_session_activity(turn.session_id, thread_id)
520+ await self._send_frame(
521+ frame(
522+ "turn.aborted",
523+ threadId=thread_id,
524+ sessionId=turn.session_id,
525+ turnId=turn.turn_id,
526+ reason="Interrupted by T3 Code",
527+ )
528+ )
529+ self._active_turns.pop(thread_id, None)
530+ await self._send_status()
531+
532+ async def _resolve_approval(self, message: dict[str, Any]) -> None:
533+ request_id = str(message["requestId"])
534+ pending = self._approval_requests.pop(request_id, None)
535+ if pending is None:
536+ await self._send_frame(
537+ protocol_error(
538+ "request-not-found",
539+ "The Hermes approval request is no longer pending.",
540+ recoverable=True,
541+ related_request_id=request_id,
542+ )
543+ )
544+ return
545+ session_key, _turn_id = pending
546+ decision = str(message["decision"])
547+ choice = {
548+ "accept": "once",
549+ "acceptForSession": "session",
550+ "decline": "deny",
551+ "cancel": "deny",
552+ }.get(decision, "deny")
553+ from tools.approval import resolve_gateway_approval
554+
555+ resolved = resolve_gateway_approval(session_key, choice)
556+ await self._send_frame(
557+ frame(
558+ "request.resolved",
559+ threadId=message["threadId"],
560+ sessionId=message["sessionId"],
561+ turnId=message["turnId"],
562+ requestId=request_id,
563+ requestType="command_execution_approval",
564+ decision=decision,
565+ resolution={"resolvedCount": resolved},
566+ )
567+ )
568+
569+ async def _resolve_user_input(self, message: dict[str, Any]) -> None:
570+ request_id = str(message["requestId"])
571+ pending = self._user_input_requests.pop(request_id, None)
572+ if pending is None:
573+ await self._send_frame(
574+ protocol_error(
575+ "request-not-found",
576+ "The Hermes user-input request is no longer pending.",
577+ recoverable=True,
578+ related_request_id=request_id,
579+ )
580+ )
581+ return
582+ answers = message.get("answers") or {}
583+ answer = answers.get(request_id) if isinstance(answers, dict) else None
584+ if answer is None and isinstance(answers, dict) and answers:
585+ answer = next(iter(answers.values()))
586+ if isinstance(answer, list):
587+ response = ", ".join(str(value) for value in answer)
588+ else:
589+ response = str(answer or "")
590+ from tools.clarify_gateway import resolve_gateway_clarify
591+
592+ resolved = resolve_gateway_clarify(request_id, response)
593+ await self._send_frame(
594+ frame(
595+ "user-input.resolved",
596+ threadId=message["threadId"],
597+ sessionId=message["sessionId"],
598+ turnId=message["turnId"],
599+ requestId=request_id,
600+ answers=answers,
601+ )
602+ )
603+ if not resolved:
604+ logger.warning(
605+ "Hermes clarify request %s was no longer pending", request_id
606+ )
607+
608+ async def _stop_session(self, message: dict[str, Any]) -> None:
609+ thread_id = str(message["threadId"])
610+ session_id = self._sessions.get(thread_id)
611+ if session_id is None:
612+ await self._send_frame(
613+ protocol_error(
614+ "session-not-found",
615+ "The requested Hermes session is not active in this connection.",
616+ recoverable=True,
617+ related_request_id=str(message["requestId"]),
618+ )
619+ )
620+ return
621+ turn = self._active_turns.pop(thread_id, None)
622+ if turn is not None:
623+ await self.interrupt_session_activity(session_id, thread_id)
624+ await self._send_frame(
625+ frame(
626+ "turn.aborted",
627+ threadId=thread_id,
628+ sessionId=session_id,
629+ turnId=turn.turn_id,
630+ reason="Hermes session stopped by T3 Code",
631+ )
632+ )
633+ await self._send_frame(
634+ frame(
635+ "session.exited",
636+ threadId=thread_id,
637+ sessionId=session_id,
638+ reason="Stopped by T3 Code",
639+ recoverable=True,
640+ )
641+ )
642+ # Deliberately retain the deterministic mapping and Hermes transcript.
643+ # A later session.ensure resumes this same thread/session identity.
644+ self._active_session_threads.discard(thread_id)
645+ await self._send_status()
646+
647+ async def _emit_assistant_content(self, turn: _TurnState, content: str) -> None:
648+ visible = str(content or "").replace(" ▉", "").replace("▉", "")
649+ if not turn.assistant_started:
650+ turn.assistant_started = True
651+ await self._send_frame(
652+ frame(
653+ "item.started",
654+ threadId=turn.thread_id,
655+ sessionId=turn.session_id,
656+ turnId=turn.turn_id,
657+ itemId=turn.message_id,
658+ itemType="assistant_message",
659+ status="inProgress",
660+ title="Hermes response",
661+ )
662+ )
663+ if visible.startswith(turn.visible_text):
664+ delta = visible[len(turn.visible_text) :]
665+ turn.visible_text = visible
666+ if delta:
667+ await self._send_frame(
668+ frame(
669+ "content.delta",
670+ threadId=turn.thread_id,
671+ sessionId=turn.session_id,
672+ turnId=turn.turn_id,
673+ itemId=turn.message_id,
674+ streamKind="assistant_text",
675+ delta=delta,
676+ contentIndex=0,
677+ )
678+ )
679+ elif visible != turn.visible_text:
680+ # Hermes' public platform edit hook exposes cumulative rendered text,
681+ # but the v1 T3 delta contract has no replacement operation. Preserve
682+ # the valid prefix and report the rare rewrite as generic activity.
683+ await self._emit_generic_activity(
684+ turn, "Hermes revised already-streamed text; replacement is deferred."
685+ )
686+
687+ async def _complete_turn(self, turn: _TurnState) -> None:
688+ if self._active_turns.get(turn.thread_id) is not turn:
689+ return
690+ if turn.assistant_started:
691+ await self._send_frame(
692+ frame(
693+ "item.completed",
694+ threadId=turn.thread_id,
695+ sessionId=turn.session_id,
696+ turnId=turn.turn_id,
697+ itemId=turn.message_id,
698+ itemType="assistant_message",
699+ status="completed",
700+ title="Hermes response",
701+ )
702+ )
703+ if turn.generic_activity_id is not None:
704+ await self._send_frame(
705+ frame(
706+ "item.completed",
707+ threadId=turn.thread_id,
708+ sessionId=turn.session_id,
709+ turnId=turn.turn_id,
710+ itemId=turn.generic_activity_id,
711+ itemType="unknown",
712+ status="completed",
713+ title="Hermes activity",
714+ **(
715+ {"detail": turn.generic_activity_detail}
716+ if turn.generic_activity_detail
717+ else {}
718+ ),
719+ )
720+ )
721+ await self._send_frame(
722+ frame(
723+ "turn.completed",
724+ threadId=turn.thread_id,
725+ sessionId=turn.session_id,
726+ turnId=turn.turn_id,
727+ state="completed",
728+ stopReason=None,
729+ )
730+ )
731+ self._active_turns.pop(turn.thread_id, None)
732+ await self._send_status()
733+
734+ async def _emit_generic_activity(self, turn: _TurnState, detail: str) -> None:
735+ if not detail:
736+ return
737+ normalized_detail = str(detail)[:2_000]
738+ if turn.generic_activity_detail == normalized_detail:
739+ return
740+ turn.generic_activity_detail = normalized_detail
741+ if turn.generic_activity_id is None:
742+ turn.generic_activity_id = item_id()
743+ event_type = "item.started"
744+ else:
745+ event_type = "item.updated"
746+ await self._send_frame(
747+ frame(
748+ event_type,
749+ threadId=turn.thread_id,
750+ sessionId=turn.session_id,
751+ turnId=turn.turn_id,
752+ itemId=turn.generic_activity_id,
753+ itemType="unknown",
754+ status="inProgress",
755+ title="Hermes activity",
756+ detail=normalized_detail,
757+ )
758+ )
759+
760+ def emit_tool_started(
761+ self,
762+ session_id: str,
763+ tool_name: str,
764+ args: dict[str, Any],
765+ tool_call_id: str = "",
766+ ) -> None:
767+ thread_id = self._thread_by_session.get(str(session_id))
768+ turn = self._active_turns.get(thread_id or "")
769+ if turn is None:
770+ return
771+ tool_item_id = item_id()
772+ correlation_key = tool_call_id or tool_name
773+ turn.tool_items[correlation_key] = tool_item_id
774+ data = canonical_tool_data(tool_name, args)
775+ payload: dict[str, Any] = {
776+ "threadId": turn.thread_id,
777+ "sessionId": turn.session_id,
778+ "turnId": turn.turn_id,
779+ "itemId": tool_item_id,
780+ "itemType": canonical_tool_item_type(tool_name),
781+ "status": "inProgress",
782+ "title": tool_name,
783+ }
784+ if data is not None:
785+ payload["data"] = data
786+ self._schedule(
787+ self._send_frame(
788+ frame(
789+ "item.started",
790+ **payload,
791+ )
792+ )
793+ )
794+
795+ def emit_tool_completed(
796+ self,
797+ session_id: str,
798+ tool_name: str,
799+ result: str,
800+ duration_ms: int | None,
801+ tool_call_id: str = "",
802+ status: str = "",
803+ ) -> None:
804+ thread_id = self._thread_by_session.get(str(session_id))
805+ turn = self._active_turns.get(thread_id or "")
806+ if turn is None:
807+ return
808+ correlation_key = tool_call_id or tool_name
809+ tool_item_id = turn.tool_items.pop(correlation_key, None) or item_id()
810+ del result
811+ payload: dict[str, Any] = {
812+ "threadId": turn.thread_id,
813+ "sessionId": turn.session_id,
814+ "turnId": turn.turn_id,
815+ "itemId": tool_item_id,
816+ "itemType": canonical_tool_item_type(tool_name),
817+ "status": "failed" if status == "error" else "completed",
818+ "title": tool_name,
819+ }
820+ if duration_ms is not None:
821+ payload["detail"] = f"Completed in {duration_ms} ms"
822+ payload["data"] = {"durationMs": duration_ms}
823+ self._schedule(
824+ self._send_frame(
825+ frame(
826+ "item.completed",
827+ **payload,
828+ )
829+ )
830+ )
831+
832+ @classmethod
833+ def route_tool_started(
834+ cls,
835+ tool_name: str,
836+ args: dict[str, Any],
837+ session_id: str,
838+ tool_call_id: str = "",
839+ ) -> None:
840+ for instance in list(cls._instances):
841+ instance.emit_tool_started(session_id, tool_name, args, tool_call_id)
842+
843+ @classmethod
844+ def route_tool_completed(
845+ cls,
846+ tool_name: str,
847+ result: str,
848+ session_id: str,
849+ duration_ms: int | None,
850+ tool_call_id: str = "",
851+ status: str = "",
852+ ) -> None:
853+ for instance in list(cls._instances):
854+ instance.emit_tool_completed(
855+ session_id,
856+ tool_name,
857+ result,
858+ duration_ms,
859+ tool_call_id,
860+ status,
861+ )
862+
863+ def _source(self, thread_id: str, message_id: str):
864+ return self.build_source(
865+ chat_id=thread_id,
866+ chat_name=f"T3 thread {thread_id}",
867+ chat_type="dm",
868+ user_id="t3-code",
869+ user_name="T3 Code",
870+ message_id=message_id,
871+ )
872+
873+ async def _send_frame(self, message: dict[str, Any]) -> None:
874+ connection = self._connection
875+ if connection is None:
876+ raise ConnectionError("T3 Code gateway is offline")
877+ await connection.send(message)
878+
879+ async def _send_status(self) -> None:
880+ if self._connection is None or not self._connection.connected:
881+ return
882+ await self._send_frame(
883+ frame(
884+ "connection.status",
885+ activeSessionCount=len(self._active_session_threads),
886+ )
887+ )
888+
889+ async def _handle_connection_state(
890+ self, connected: bool, reason: str | None
891+ ) -> None:
892+ if connected:
893+ self._mark_connected()
894+ await self._send_status()
895+ return
896+ self._mark_disconnected()
897+ if reason:
898+ logger.warning("T3 gateway offline: %s", reason)
899+
900+ def _schedule(self, coroutine) -> None:
901+ loop = self._event_loop
902+ if loop is None or loop.is_closed():
903+ coroutine.close()
904+ return
905+ if threading.current_thread() is threading.main_thread():
906+ try:
907+ asyncio.get_running_loop()
908+ except RuntimeError:
909+ asyncio.run_coroutine_threadsafe(coroutine, loop)
910+ else:
911+ loop.create_task(coroutine)
912+ else:
913+ asyncio.run_coroutine_threadsafe(coroutine, loop)
914+
915+
916+def check_requirements() -> bool:
917+ return dependency_available()
918+
919+
920+def validate_config(config: PlatformConfig) -> bool:
921+ extra = getattr(config, "extra", {}) or {}
922+ return (
923+ bool(extra.get("url") or os.environ.get(URL_ENV, ""))
924+ and bool(extra.get("instance_id") or os.environ.get(INSTANCE_ID_ENV, ""))
925+ and bool(extra.get("credential") or os.environ.get(CREDENTIAL_ENV, ""))
926+ )
927+
928+
929+def env_enablement() -> dict[str, str] | None:
930+ url = os.environ.get(URL_ENV, "").strip()
931+ instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
932+ credential = os.environ.get(CREDENTIAL_ENV, "").strip()
933+ if not (url and instance_id and credential):
934+ return None
935+ return {
936+ "url": url,
937+ "instance_id": instance_id,
938+ "credential": credential,
939+ "nickname": os.environ.get(NICKNAME_ENV, "").strip() or "Hermes",
940+ }
integrations/hermes-t3-gateway/cli.py+125−0
@@ -0,0 +1,125 @@
1+"""`hermes t3 connect` enrollment command."""
2+
3+from __future__ import annotations
4+
5+import argparse
6+import asyncio
7+import os
8+import sys
9+
10+from .connection import ConnectionRejected, enroll_once, websocket_url
11+
12+URL_ENV = "HERMES_T3_GATEWAY_URL"
13+INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID"
14+CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL"
15+NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME"
16+
17+
18+def _hermes_version() -> str:
19+ try:
20+ from hermes_cli import __version__
21+
22+ return str(__version__)
23+ except Exception: # noqa: BLE001 - version discovery must not block enrollment
24+ return "unknown"
25+
26+
27+def register_cli(parser: argparse.ArgumentParser) -> None:
28+ commands = parser.add_subparsers(dest="t3_command")
29+ connect = commands.add_parser(
30+ "connect",
31+ help="Pair this Hermes process with a named T3 Code provider instance",
32+ )
33+ connect.add_argument(
34+ "--url",
35+ required=True,
36+ help="T3 browser origin or explicit ws(s) gateway URL",
37+ )
38+ connect.add_argument(
39+ "--token",
40+ required=True,
41+ help="Short-lived, one-time enrollment token generated by T3 Code",
42+ )
43+ connect.set_defaults(func=t3_command)
44+
45+ status = commands.add_parser("status", help="Show local T3 enrollment state")
46+ status.set_defaults(func=t3_command)
47+
48+
49+def t3_command(args) -> None:
50+ command = getattr(args, "t3_command", None)
51+ if command == "status":
52+ _print_status()
53+ return
54+ if command != "connect":
55+ print("Usage: hermes t3 connect --url <t3-url> --token <one-time-token>")
56+ return
57+
58+ url = str(getattr(args, "url", "") or "").strip()
59+ token = str(getattr(args, "token", "") or "").strip()
60+ try:
61+ normalized_url = websocket_url(url)
62+ accepted = asyncio.run(
63+ enroll_once(
64+ url=normalized_url,
65+ token=token,
66+ hermes_version=_hermes_version(),
67+ )
68+ )
69+ except ConnectionRejected as exc:
70+ print(f"✗ T3 enrollment rejected ({exc.code}): {exc}")
71+ raise SystemExit(1) from exc
72+ except Exception as exc:
73+ print(f"✗ Could not enroll with T3 Code: {exc}")
74+ raise SystemExit(1) from exc
75+
76+ values = {
77+ URL_ENV: normalized_url,
78+ INSTANCE_ID_ENV: str(accepted["instanceId"]),
79+ CREDENTIAL_ENV: str(accepted["credential"]),
80+ NICKNAME_ENV: str(accepted.get("nickname") or "Hermes"),
81+ }
82+ try:
83+ from hermes_cli.config import get_env_path, save_env_value
84+
85+ for key, value in values.items():
86+ save_env_value(key, value)
87+ env_path = get_env_path()
88+ except Exception as exc:
89+ print(f"✗ Enrollment succeeded, but credentials could not be saved: {exc}")
90+ print(" The credential was not printed. Revoke and re-enroll this instance.")
91+ raise SystemExit(1) from exc
92+
93+ # Mirror the newly written values into this process for status output and
94+ # tests. A running gateway still needs a restart to construct the adapter.
95+ os.environ.update(values)
96+ print(f'✓ Connected Hermes to T3 Code as "{values[NICKNAME_ENV]}"')
97+ print(f" Instance: {values[INSTANCE_ID_ENV]}")
98+ print(f" Gateway: {normalized_url}")
99+ print(f" Saved the credential securely in {env_path} (value hidden).")
100+ print(" Restart `hermes gateway` to activate the connection.")
101+
102+
103+def _print_status() -> None:
104+ url = os.environ.get(URL_ENV, "").strip()
105+ instance_id = os.environ.get(INSTANCE_ID_ENV, "").strip()
106+ credential = os.environ.get(CREDENTIAL_ENV, "").strip()
107+ nickname = os.environ.get(NICKNAME_ENV, "").strip() or "Hermes"
108+ if not (url and instance_id and credential):
109+ print("T3 Code: not enrolled")
110+ print("Run: hermes t3 connect --url <t3-url> --token <one-time-token>")
111+ return
112+ print(f"T3 Code: enrolled as {nickname}")
113+ print(f" Instance: {instance_id}")
114+ print(f" Gateway: {url}")
115+ print(" Credential: configured (hidden)")
116+
117+
118+if __name__ == "__main__": # pragma: no cover - executable fallback
119+ parser = argparse.ArgumentParser(prog="python -m hermes_t3_gateway.cli")
120+ register_cli(parser)
121+ parsed = parser.parse_args()
122+ if not hasattr(parsed, "func"):
123+ parser.print_help()
124+ sys.exit(2)
125+ parsed.func(parsed)
integrations/hermes-t3-gateway/connection.py+247−0
@@ -0,0 +1,247 @@
1+"""Outbound authenticated WebSocket connection to a T3 Code server."""
2+
3+from __future__ import annotations
4+
5+import asyncio
6+import json
7+import logging
8+from collections.abc import Awaitable, Callable
9+from contextlib import suppress
10+from typing import Any
11+from urllib.parse import urlsplit, urlunsplit
12+
13+from .protocol import WEBSOCKET_PATH, connection_hello
14+
15+logger = logging.getLogger(__name__)
16+
17+try:
18+ import websockets
19+except ImportError: # pragma: no cover - Hermes currently installs websockets
20+ websockets = None
21+
22+MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
23+StateHandler = Callable[[bool, str | None], Awaitable[None] | None]
24+
25+
26+class ConnectionRejected(RuntimeError):
27+ def __init__(self, code: str, message: str):
28+ super().__init__(message)
29+ self.code = code
30+
31+
32+def websocket_url(url: str) -> str:
33+ """Normalize an HTTP(S) browser origin or WS(S) URL to the gateway route."""
34+ raw = (url or "").strip()
35+ parsed = urlsplit(raw)
36+ scheme = parsed.scheme.lower()
37+ if scheme == "https":
38+ scheme = "wss"
39+ elif scheme == "http":
40+ scheme = "ws"
41+ if scheme not in {"ws", "wss"} or not parsed.netloc:
42+ raise ValueError("URL must use http://, https://, ws://, or wss://")
43+ path = parsed.path.rstrip("/")
44+ if path != WEBSOCKET_PATH:
45+ path = WEBSOCKET_PATH
46+ return urlunsplit((scheme, parsed.netloc, path, "", ""))
47+
48+
49+def dependency_available() -> bool:
50+ return websockets is not None
51+
52+
53+async def _open_socket(url: str):
54+ if websockets is None:
55+ raise RuntimeError(
56+ "The `websockets` package is unavailable. Install the standard "
57+ "Hermes Agent dependencies and retry."
58+ )
59+ return await websockets.connect( # type: ignore[union-attr]
60+ websocket_url(url),
61+ open_timeout=20,
62+ ping_interval=20,
63+ ping_timeout=20,
64+ close_timeout=5,
65+ max_size=2 * 1024 * 1024,
66+ )
67+
68+
69+async def authenticate_socket(
70+ socket: Any,
71+ *,
72+ authentication: dict[str, str],
73+ hermes_version: str,
74+ timeout: float = 20,
75+) -> dict[str, Any]:
76+ hello = connection_hello(
77+ hermes_version=hermes_version,
78+ authentication=authentication,
79+ )
80+ await socket.send(json.dumps(hello, separators=(",", ":"), ensure_ascii=False))
81+ raw = await asyncio.wait_for(socket.recv(), timeout=timeout)
82+ message = json.loads(raw)
83+ if not isinstance(message, dict):
84+ raise TypeError("T3 returned a non-object handshake frame")
85+ if message.get("requestId") != hello["requestId"]:
86+ raise RuntimeError("T3 returned a handshake with an unexpected requestId")
87+ if message.get("type") == "connection.rejected":
88+ raise ConnectionRejected(
89+ str(message.get("code") or "internal-error"),
90+ str(message.get("message") or "T3 rejected the gateway connection"),
91+ )
92+ if message.get("type") != "connection.accepted":
93+ raise RuntimeError(
94+ f"expected connection.accepted, received {message.get('type')!r}"
95+ )
96+ if message.get("protocolVersion") != 1:
97+ raise RuntimeError("T3 accepted the connection with an incompatible version")
98+ return message
99+
100+
101+async def enroll_once(
102+ *,
103+ url: str,
104+ token: str,
105+ hermes_version: str,
106+) -> dict[str, Any]:
107+ socket = await _open_socket(url)
108+ try:
109+ accepted = await authenticate_socket(
110+ socket,
111+ authentication={"type": "enrollment-token", "token": token},
112+ hermes_version=hermes_version,
113+ )
114+ if not accepted.get("instanceId") or not accepted.get("credential"):
115+ raise RuntimeError(
116+ "T3 accepted enrollment without returning an instance credential"
117+ )
118+ return accepted
119+ finally:
120+ await socket.close()
121+
122+
123+class T3GatewayConnection:
124+ """Reconnectable runtime connection authenticated by an instance credential."""
125+
126+ def __init__(
127+ self,
128+ *,
129+ url: str,
130+ instance_id: str,
131+ credential: str,
132+ hermes_version: str,
133+ on_message: MessageHandler,
134+ on_state: StateHandler | None = None,
135+ ):
136+ self.url = websocket_url(url)
137+ self.instance_id = instance_id
138+ self.credential = credential
139+ self.hermes_version = hermes_version
140+ self._on_message = on_message
141+ self._on_state = on_state
142+ self._socket: Any = None
143+ self._supervisor: asyncio.Task[None] | None = None
144+ self._send_lock = asyncio.Lock()
145+ self._connected = asyncio.Event()
146+ self._first_result: asyncio.Future[bool] | None = None
147+ self._stopping = False
148+
149+ @property
150+ def connected(self) -> bool:
151+ return self._connected.is_set()
152+
153+ async def connect(self, timeout: float = 30) -> bool:
154+ if self._supervisor is not None and not self._supervisor.done():
155+ return self.connected
156+ self._stopping = False
157+ self._first_result = asyncio.get_running_loop().create_future()
158+ self._supervisor = asyncio.create_task(
159+ self._supervise(), name="hermes-t3-gateway"
160+ )
161+ try:
162+ return await asyncio.wait_for(asyncio.shield(self._first_result), timeout)
163+ except TimeoutError:
164+ await self.disconnect()
165+ return False
166+
167+ async def disconnect(self) -> None:
168+ self._stopping = True
169+ self._connected.clear()
170+ if self._socket is not None:
171+ with suppress(Exception):
172+ await self._socket.close()
173+ self._socket = None
174+ if self._supervisor is not None:
175+ self._supervisor.cancel()
176+ with suppress(asyncio.CancelledError):
177+ await self._supervisor
178+ self._supervisor = None
179+ await self._notify_state(False, None)
180+
181+ async def send(self, message: dict[str, Any]) -> None:
182+ if not self.connected or self._socket is None:
183+ raise ConnectionError("T3 Code gateway is offline")
184+ encoded = json.dumps(message, separators=(",", ":"), ensure_ascii=False)
185+ async with self._send_lock:
186+ await self._socket.send(encoded)
187+
188+ async def _supervise(self) -> None:
189+ delay = 1.0
190+ while not self._stopping:
191+ reason: str | None = None
192+ try:
193+ socket = await _open_socket(self.url)
194+ self._socket = socket
195+ await authenticate_socket(
196+ socket,
197+ authentication={
198+ "type": "instance-credential",
199+ "instanceId": self.instance_id,
200+ "credential": self.credential,
201+ },
202+ hermes_version=self.hermes_version,
203+ )
204+ self._connected.set()
205+ if self._first_result is not None and not self._first_result.done():
206+ self._first_result.set_result(True)
207+ await self._notify_state(True, None)
208+ delay = 1.0
209+ async for raw in socket:
210+ message = json.loads(raw)
211+ if isinstance(message, dict):
212+ await self._on_message(message)
213+ except asyncio.CancelledError:
214+ raise
215+ except ConnectionRejected as exc:
216+ reason = f"{exc.code}: {exc}"
217+ if self._first_result is not None and not self._first_result.done():
218+ self._first_result.set_exception(exc)
219+ # Revoked credentials and version mismatches need operator
220+ # action; reconnecting the same secret can never recover.
221+ if exc.code in {
222+ "instance-revoked",
223+ "invalid-authentication",
224+ "version-incompatible",
225+ }:
226+ self._stopping = True
227+ except Exception as exc: # noqa: BLE001 - reconnect every transient transport failure
228+ reason = str(exc)
229+ logger.warning("T3 gateway connection dropped: %s", exc)
230+ finally:
231+ self._connected.clear()
232+ self._socket = None
233+ await self._notify_state(False, reason)
234+ if self._stopping:
235+ break
236+ await asyncio.sleep(delay)
237+ delay = min(delay * 2, 30.0)
238+
239+ async def _notify_state(self, connected: bool, reason: str | None) -> None:
240+ if self._on_state is None:
241+ return
242+ try:
243+ result = self._on_state(connected, reason)
244+ if asyncio.iscoroutine(result):
245+ await result
246+ except Exception:
247+ logger.debug("T3 connection state callback failed", exc_info=True)
integrations/hermes-t3-gateway/plugin.yaml+24−0
@@ -0,0 +1,24 @@
1+name: hermes-t3-gateway
2+label: T3 Code
3+kind: platform
4+version: 0.1.0
5+description: >
6+ Experimental outbound gateway that exposes one Hermes Agent process as a
7+ named T3 Code provider instance over an authenticated WebSocket.
8+author: T3 Tools
9+provides_hooks:
10+ - pre_tool_call
11+ - post_tool_call
12+optional_env:
13+ - name: HERMES_T3_GATEWAY_URL
14+ description: "T3 Code server URL used by the outbound gateway connection"
15+ prompt: "T3 Code URL"
16+ password: false
17+ - name: HERMES_T3_GATEWAY_INSTANCE_ID
18+ description: "Opaque T3 Code Hermes instance identifier issued at enrollment"
19+ prompt: "T3 Code Hermes instance ID"
20+ password: false
21+ - name: HERMES_T3_GATEWAY_CREDENTIAL
22+ description: "Long-lived per-instance credential issued at enrollment"
23+ prompt: "T3 Code Hermes credential"
24+ password: true
integrations/hermes-t3-gateway/protocol.py+168−0
@@ -0,0 +1,168 @@
1+"""Pure-Python helpers for the T3 Code ↔ Hermes gateway wire contract."""
2+
3+from __future__ import annotations
4+
5+import uuid
6+from datetime import datetime, timezone
7+from typing import Any
8+
9+PROTOCOL_VERSION = 1
10+PLUGIN_VERSION = "0.1.0"
11+WEBSOCKET_PATH = "/api/hermes-gateway/ws"
12+
13+CAPABILITIES = {
14+ "protocolVersion": PROTOCOL_VERSION,
15+ "streaming": True,
16+ "activity": True,
17+ "approvals": True,
18+ "userInput": True,
19+ # First post-stability feature: advertise false until binary/media framing
20+ # and bounded attachment transfer are implemented end to end.
21+ "attachments": False,
22+}
23+
24+SERVER_COMMANDS = frozenset(
25+ {
26+ "session.ensure",
27+ "turn.start",
28+ "turn.steer",
29+ "turn.interrupt",
30+ "approval.respond",
31+ "user-input.respond",
32+ "session.stop",
33+ "ping",
34+ }
35+)
36+
37+
38+def request_id() -> str:
39+ return str(uuid.uuid4())
40+
41+
42+def item_id() -> str:
43+ return str(uuid.uuid4())
44+
45+
46+def iso_now() -> str:
47+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
48+
49+
50+def frame(frame_type: str, **payload: Any) -> dict[str, Any]:
51+ return {
52+ "type": frame_type,
53+ "protocolVersion": PROTOCOL_VERSION,
54+ **payload,
55+ }
56+
57+
58+def connection_hello(
59+ *,
60+ hermes_version: str,
61+ authentication: dict[str, str],
62+ hello_request_id: str | None = None,
63+) -> dict[str, Any]:
64+ return {
65+ "type": "connection.hello",
66+ "requestId": hello_request_id or request_id(),
67+ "protocolVersion": PROTOCOL_VERSION,
68+ "pluginVersion": PLUGIN_VERSION,
69+ "hermesVersion": hermes_version,
70+ "capabilities": dict(CAPABILITIES),
71+ "authentication": authentication,
72+ }
73+
74+
75+def protocol_error(
76+ code: str,
77+ message: str,
78+ *,
79+ recoverable: bool,
80+ related_request_id: str | None = None,
81+) -> dict[str, Any]:
82+ payload: dict[str, Any] = {
83+ "code": code,
84+ "message": message,
85+ "recoverable": recoverable,
86+ }
87+ if related_request_id:
88+ payload["requestId"] = related_request_id
89+ return frame("protocol.error", **payload)
90+
91+
92+def validate_server_frame(message: Any) -> dict[str, Any]:
93+ if not isinstance(message, dict):
94+ raise TypeError("gateway frame must be a JSON object")
95+ frame_type = message.get("type")
96+ if frame_type not in SERVER_COMMANDS:
97+ raise ValueError(f"unsupported T3 gateway frame: {frame_type!r}")
98+ if message.get("protocolVersion") != PROTOCOL_VERSION:
99+ raise ValueError(
100+ f"unsupported protocol version: {message.get('protocolVersion')!r}"
101+ )
102+ return message
103+
104+
105+def canonical_tool_item_type(tool_name: str) -> str:
106+ normalized = (tool_name or "").strip().lower()
107+ if normalized in {"terminal", "execute_code", "shell", "bash"}:
108+ return "command_execution"
109+ if normalized in {
110+ "apply_patch",
111+ "write_file",
112+ "edit_file",
113+ "delete_file",
114+ "move_file",
115+ }:
116+ return "file_change"
117+ if normalized.startswith(("mcp", "mcp__")):
118+ return "mcp_tool_call"
119+ if normalized in {"delegate_task", "spawn_agent", "send_message"}:
120+ return "collab_agent_tool_call"
121+ if "search" in normalized or normalized in {"web_fetch", "fetch_url"}:
122+ return "web_search"
123+ if normalized in {"view_image", "open_image"}:
124+ return "image_view"
125+ return "dynamic_tool_call"
126+
127+
128+def canonical_tool_data(tool_name: str, args: Any) -> dict[str, Any] | None:
129+ """Project known-safe, canonical fields; never forward arbitrary tool args."""
130+ if not isinstance(args, dict):
131+ return None
132+ item_type = canonical_tool_item_type(tool_name)
133+ if item_type == "command_execution":
134+ command = args.get("command")
135+ cwd = args.get("cwd") or args.get("workdir")
136+ projected = {}
137+ if isinstance(command, str) and command.strip():
138+ projected["command"] = command[:4_000]
139+ if isinstance(cwd, str) and cwd.strip():
140+ projected["cwd"] = cwd[:1_000]
141+ return projected or None
142+ if item_type == "file_change":
143+ path = args.get("path") or args.get("file_path") or args.get("filename")
144+ return (
145+ {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None
146+ )
147+ if item_type == "web_search":
148+ query = args.get("query") or args.get("q") or args.get("url")
149+ return (
150+ {"query": query[:2_000]}
151+ if isinstance(query, str) and query.strip()
152+ else None
153+ )
154+ if item_type == "image_view":
155+ path = args.get("path") or args.get("image_path")
156+ return (
157+ {"path": path[:1_000]} if isinstance(path, str) and path.strip() else None
158+ )
159+ if item_type == "mcp_tool_call":
160+ server = args.get("server")
161+ operation = args.get("tool") or args.get("operation")
162+ projected = {}
163+ if isinstance(server, str) and server.strip():
164+ projected["server"] = server[:200]
165+ if isinstance(operation, str) and operation.strip():
166+ projected["operation"] = operation[:200]
167+ return projected or None
168+ return None
integrations/hermes-t3-gateway/tests/test_adapter.py+411−0
@@ -0,0 +1,411 @@
1+from __future__ import annotations
2+
3+import dataclasses
4+import enum
5+import importlib.util
6+import pathlib
7+import sys
8+import types
9+import unittest
10+
11+ROOT = pathlib.Path(__file__).resolve().parents[1]
12+PACKAGE = "hermes_t3_gateway_adapter_test"
13+
14+
15+class Platform(str, enum.Enum):
16+ T3 = "t3"
17+
18+ @classmethod
19+ def _missing_(cls, value):
20+ if value == "t3":
21+ return cls.T3
22+ return None
23+
24+
25+@dataclasses.dataclass
26+class PlatformConfig:
27+ enabled: bool = True
28+ extra: dict = dataclasses.field(default_factory=dict)
29+
30+
31+class MessageType(enum.Enum):
32+ TEXT = "text"
33+ COMMAND = "command"
34+
35+
36+@dataclasses.dataclass
37+class MessageEvent:
38+ text: str
39+ message_type: MessageType
40+ source: object
41+ message_id: str
42+ metadata: dict
43+
44+
45+@dataclasses.dataclass
46+class SendResult:
47+ success: bool
48+ message_id: str | None = None
49+ error: str | None = None
50+
51+
52+@dataclasses.dataclass
53+class Source:
54+ platform: Platform
55+ chat_id: str
56+ message_id: str
57+
58+
59+class BasePlatformAdapter:
60+ def __init__(self, config, platform):
61+ self.config = config
62+ self.platform = platform
63+ self._status_text = {}
64+ self.messages = []
65+ self._running = False
66+ self._message_handler = None
67+
68+ def build_source(self, *, chat_id, message_id, **kwargs):
69+ return Source(self.platform, str(chat_id), str(message_id))
70+
71+ async def handle_message(self, event):
72+ self.messages.append(event)
73+ if (
74+ self._message_handler is not None
75+ and event.message_type == MessageType.COMMAND
76+ and event.text.startswith("/steer ")
77+ ):
78+ # Faithful model of Hermes BasePlatformAdapter's active-command
79+ # path: the gateway handler returns a control acknowledgement,
80+ # then BasePlatformAdapter sends it through the platform adapter
81+ # with notify=True.
82+ response = await self._message_handler(event)
83+ if response:
84+ await self.send(
85+ event.source.chat_id,
86+ response,
87+ metadata={"notify": True},
88+ )
89+
90+ async def interrupt_session_activity(self, session_key, chat_id):
91+ self.interrupted = (session_key, chat_id)
92+
93+ def set_status_text(self, chat_id, text):
94+ if text:
95+ self._status_text[str(chat_id)] = text
96+ else:
97+ self._status_text.pop(str(chat_id), None)
98+
99+ def _mark_connected(self):
100+ self._running = True
101+
102+ def _mark_disconnected(self):
103+ self._running = False
104+
105+ def _set_fatal_error(self, *args, **kwargs):
106+ self.fatal_error = (args, kwargs)
107+
108+
109+def build_session_key(source):
110+ return f"agent:main:t3:dm:{source.chat_id}"
111+
112+
113+def install_fake_hermes_modules():
114+ gateway = types.ModuleType("gateway")
115+ config = types.ModuleType("gateway.config")
116+ config.Platform = Platform
117+ config.PlatformConfig = PlatformConfig
118+ platforms = types.ModuleType("gateway.platforms")
119+ base = types.ModuleType("gateway.platforms.base")
120+ base.BasePlatformAdapter = BasePlatformAdapter
121+ base.MessageEvent = MessageEvent
122+ base.MessageType = MessageType
123+ base.SendResult = SendResult
124+ session = types.ModuleType("gateway.session")
125+ session.build_session_key = build_session_key
126+ sys.modules.update(
127+ {
128+ "gateway": gateway,
129+ "gateway.config": config,
130+ "gateway.platforms": platforms,
131+ "gateway.platforms.base": base,
132+ "gateway.session": session,
133+ }
134+ )
135+
136+
137+def load_plugin_modules():
138+ install_fake_hermes_modules()
139+ package = types.ModuleType(PACKAGE)
140+ package.__path__ = [str(ROOT)]
141+ sys.modules[PACKAGE] = package
142+ for name in ("protocol", "connection", "cli", "adapter"):
143+ spec = importlib.util.spec_from_file_location(
144+ f"{PACKAGE}.{name}", ROOT / f"{name}.py"
145+ )
146+ assert spec and spec.loader
147+ module = importlib.util.module_from_spec(spec)
148+ sys.modules[f"{PACKAGE}.{name}"] = module
149+ spec.loader.exec_module(module)
150+ return sys.modules[f"{PACKAGE}.adapter"]
151+
152+
153+adapter_module = load_plugin_modules()
154+
155+
156+class FakeConnection:
157+ def __init__(self):
158+ self.connected = True
159+ self.messages = []
160+
161+ async def send(self, message):
162+ self.messages.append(message)
163+
164+
165+class AdapterTests(unittest.IsolatedAsyncioTestCase):
166+ async def asyncSetUp(self):
167+ self.adapter = adapter_module.T3PlatformAdapter(
168+ PlatformConfig(
169+ extra={
170+ "url": "wss://t3.example/api/hermes-gateway/ws",
171+ "instance_id": "instance",
172+ "credential": "credential",
173+ }
174+ )
175+ )
176+ self.connection = FakeConnection()
177+ self.adapter._connection = self.connection
178+
179+ async def test_thread_ensure_start_stream_and_complete(self):
180+ await self.adapter._handle_server_frame(
181+ {
182+ "type": "session.ensure",
183+ "protocolVersion": 1,
184+ "requestId": "ensure-1",
185+ "threadId": "thread-1",
186+ }
187+ )
188+ ready = self.connection.messages[-2]
189+ self.assertEqual(ready["type"], "session.ready")
190+ self.assertEqual(ready["sessionId"], "agent:main:t3:dm:thread-1")
191+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1)
192+
193+ await self.adapter._handle_server_frame(
194+ {
195+ "type": "turn.start",
196+ "protocolVersion": 1,
197+ "requestId": "start-1",
198+ "threadId": "thread-1",
199+ "sessionId": ready["sessionId"],
200+ "turnId": "turn-1",
201+ "text": "Hello Hermes",
202+ }
203+ )
204+ self.assertEqual(self.adapter.messages[-1].text, "Hello Hermes")
205+ await self.adapter.send("thread-1", "Hello", metadata={"expect_edits": True})
206+ await self.adapter.edit_message(
207+ "thread-1", "message", "Hello world", finalize=True
208+ )
209+ types_seen = [message["type"] for message in self.connection.messages]
210+ self.assertIn("content.delta", types_seen)
211+ self.assertIn("turn.completed", types_seen)
212+ deltas = [
213+ message["delta"]
214+ for message in self.connection.messages
215+ if message["type"] == "content.delta"
216+ ]
217+ self.assertEqual(deltas, ["Hello", " world"])
218+
219+ async def test_steer_uses_official_hermes_command(self):
220+ await self.adapter._handle_server_frame(
221+ {
222+ "type": "session.ensure",
223+ "protocolVersion": 1,
224+ "requestId": "ensure-2",
225+ "threadId": "thread-2",
226+ }
227+ )
228+ session_id = self.adapter._sessions["thread-2"]
229+ await self.adapter._handle_server_frame(
230+ {
231+ "type": "turn.start",
232+ "protocolVersion": 1,
233+ "requestId": "start-2",
234+ "threadId": "thread-2",
235+ "sessionId": session_id,
236+ "turnId": "turn-2",
237+ "text": "Start",
238+ }
239+ )
240+ messages_before_steer = len(self.connection.messages)
241+
242+ async def accept_steer(_event):
243+ return (
244+ "⏩ Steer queued — arrives after the next tool call: 'Focus on tests'"
245+ )
246+
247+ self.adapter._message_handler = accept_steer
248+ await self.adapter._handle_server_frame(
249+ {
250+ "type": "turn.steer",
251+ "protocolVersion": 1,
252+ "requestId": "steer-2",
253+ "threadId": "thread-2",
254+ "sessionId": session_id,
255+ "turnId": "turn-2",
256+ "text": "Focus on tests",
257+ }
258+ )
259+ self.assertEqual(self.adapter.messages[-1].text, "/steer Focus on tests")
260+ self.assertEqual(self.adapter.messages[-1].message_type, MessageType.COMMAND)
261+ steer_messages = self.connection.messages[messages_before_steer:]
262+ self.assertEqual(
263+ [message["type"] for message in steer_messages], ["turn.started"]
264+ )
265+ self.assertEqual(steer_messages[0]["requestId"], "steer-2")
266+ self.assertIn("thread-2", self.adapter._active_turns)
267+
268+ await self.adapter.edit_message(
269+ "thread-2",
270+ "message",
271+ "Actual response after steering",
272+ finalize=True,
273+ )
274+ deltas = [
275+ message["delta"]
276+ for message in self.connection.messages
277+ if message["type"] == "content.delta"
278+ ]
279+ self.assertEqual(deltas, ["Actual response after steering"])
280+ self.assertNotIn("thread-2", self.adapter._active_turns)
281+
282+ async def test_rejected_steer_emits_error_without_completing_active_turn(self):
283+ await self.adapter._handle_server_frame(
284+ {
285+ "type": "session.ensure",
286+ "protocolVersion": 1,
287+ "requestId": "ensure-rejected-steer",
288+ "threadId": "thread-rejected-steer",
289+ }
290+ )
291+ session_id = self.adapter._sessions["thread-rejected-steer"]
292+ await self.adapter._handle_server_frame(
293+ {
294+ "type": "turn.start",
295+ "protocolVersion": 1,
296+ "requestId": "start-rejected-steer",
297+ "threadId": "thread-rejected-steer",
298+ "sessionId": session_id,
299+ "turnId": "turn-rejected-steer",
300+ "text": "Start",
301+ }
302+ )
303+ messages_before_steer = len(self.connection.messages)
304+
305+ async def reject_steer(_event):
306+ return "Steer rejected (empty payload)."
307+
308+ self.adapter._message_handler = reject_steer
309+ await self.adapter._handle_server_frame(
310+ {
311+ "type": "turn.steer",
312+ "protocolVersion": 1,
313+ "requestId": "steer-rejected",
314+ "threadId": "thread-rejected-steer",
315+ "sessionId": session_id,
316+ "turnId": "turn-rejected-steer",
317+ "text": "Focus on tests",
318+ }
319+ )
320+
321+ steer_messages = self.connection.messages[messages_before_steer:]
322+ self.assertEqual(
323+ [message["type"] for message in steer_messages], ["protocol.error"]
324+ )
325+ self.assertEqual(steer_messages[0]["requestId"], "steer-rejected")
326+ self.assertEqual(steer_messages[0]["code"], "invalid-message")
327+ self.assertIn("thread-rejected-steer", self.adapter._active_turns)
328+
329+ await self.adapter.edit_message(
330+ "thread-rejected-steer",
331+ "message",
332+ "Actual response after rejected steering",
333+ finalize=True,
334+ )
335+ self.assertEqual(self.connection.messages[-1]["type"], "connection.status")
336+ self.assertNotIn("thread-rejected-steer", self.adapter._active_turns)
337+
338+ async def test_failed_steer_emits_correlated_internal_error(self):
339+ await self.adapter._handle_server_frame(
340+ {
341+ "type": "session.ensure",
342+ "protocolVersion": 1,
343+ "requestId": "ensure-failed-steer",
344+ "threadId": "thread-failed-steer",
345+ }
346+ )
347+ session_id = self.adapter._sessions["thread-failed-steer"]
348+ await self.adapter._handle_server_frame(
349+ {
350+ "type": "turn.start",
351+ "protocolVersion": 1,
352+ "requestId": "start-failed-steer",
353+ "threadId": "thread-failed-steer",
354+ "sessionId": session_id,
355+ "turnId": "turn-failed-steer",
356+ "text": "Start",
357+ }
358+ )
359+ messages_before_steer = len(self.connection.messages)
360+
361+ async def fail_steer(_event):
362+ raise RuntimeError("running agent rejected steering")
363+
364+ self.adapter._message_handler = fail_steer
365+ await self.adapter._handle_server_frame(
366+ {
367+ "type": "turn.steer",
368+ "protocolVersion": 1,
369+ "requestId": "steer-failed",
370+ "threadId": "thread-failed-steer",
371+ "sessionId": session_id,
372+ "turnId": "turn-failed-steer",
373+ "text": "Focus on tests",
374+ }
375+ )
376+
377+ steer_messages = self.connection.messages[messages_before_steer:]
378+ self.assertEqual(
379+ [message["type"] for message in steer_messages], ["protocol.error"]
380+ )
381+ self.assertEqual(steer_messages[0]["requestId"], "steer-failed")
382+ self.assertEqual(steer_messages[0]["code"], "internal-error")
383+ self.assertIn("thread-failed-steer", self.adapter._active_turns)
384+
385+ async def test_session_status_counts_ready_sessions_and_stop_decrements(self):
386+ await self.adapter._handle_server_frame(
387+ {
388+ "type": "session.ensure",
389+ "protocolVersion": 1,
390+ "requestId": "ensure-3",
391+ "threadId": "thread-3",
392+ }
393+ )
394+ session_id = self.adapter._sessions["thread-3"]
395+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 1)
396+ await self.adapter._handle_server_frame(
397+ {
398+ "type": "session.stop",
399+ "protocolVersion": 1,
400+ "requestId": "stop-3",
401+ "threadId": "thread-3",
402+ "sessionId": session_id,
403+ }
404+ )
405+ self.assertEqual(self.connection.messages[-1]["type"], "connection.status")
406+ self.assertEqual(self.connection.messages[-1]["activeSessionCount"], 0)
407+ self.assertEqual(self.adapter._sessions["thread-3"], session_id)
408+
409+
410+if __name__ == "__main__":
411+ unittest.main()
integrations/hermes-t3-gateway/tests/test_connection.py+103−0
@@ -0,0 +1,103 @@
1+from __future__ import annotations
2+
3+import importlib.util
4+import json
5+import pathlib
6+import sys
7+import types
8+import unittest
9+
10+ROOT = pathlib.Path(__file__).resolve().parents[1]
11+PACKAGE = "hermes_t3_gateway_test"
12+
13+package = types.ModuleType(PACKAGE)
14+package.__path__ = [str(ROOT)]
15+sys.modules.setdefault(PACKAGE, package)
16+
17+for name in ("protocol", "connection"):
18+ spec = importlib.util.spec_from_file_location(
19+ f"{PACKAGE}.{name}", ROOT / f"{name}.py"
20+ )
21+ assert spec and spec.loader
22+ module = importlib.util.module_from_spec(spec)
23+ sys.modules[f"{PACKAGE}.{name}"] = module
24+ spec.loader.exec_module(module)
25+
26+connection = sys.modules[f"{PACKAGE}.connection"]
27+
28+
29+class FakeSocket:
30+ def __init__(self, response):
31+ self.response = response
32+ self.sent = []
33+ self.closed = False
34+
35+ async def send(self, value):
36+ self.sent.append(json.loads(value))
37+
38+ async def recv(self):
39+ request_id = self.sent[0]["requestId"]
40+ return json.dumps({**self.response, "requestId": request_id})
41+
42+ async def close(self):
43+ self.closed = True
44+
45+
46+class ConnectionTests(unittest.IsolatedAsyncioTestCase):
47+ def test_url_normalization(self):
48+ self.assertEqual(
49+ connection.websocket_url("https://t3.example"),
50+ "wss://t3.example/api/hermes-gateway/ws",
51+ )
52+ self.assertEqual(
53+ connection.websocket_url("http://siva.davis7.space:8484/"),
54+ "ws://siva.davis7.space:8484/api/hermes-gateway/ws",
55+ )
56+ with self.assertRaises(ValueError):
57+ connection.websocket_url("ftp://invalid.example")
58+
59+ async def test_enrollment_handshake_returns_credential(self):
60+ socket = FakeSocket(
61+ {
62+ "type": "connection.accepted",
63+ "protocolVersion": 1,
64+ "instanceId": "provider-instance",
65+ "nickname": "Research",
66+ "credential": "persistent-secret",
67+ }
68+ )
69+ accepted = await connection.authenticate_socket(
70+ socket,
71+ authentication={"type": "enrollment-token", "token": "once"},
72+ hermes_version="0.19.0",
73+ )
74+ self.assertEqual(accepted["credential"], "persistent-secret")
75+ self.assertEqual(
76+ socket.sent[0]["authentication"],
77+ {"type": "enrollment-token", "token": "once"},
78+ )
79+
80+ async def test_rejected_handshake_fails_closed(self):
81+ socket = FakeSocket(
82+ {
83+ "type": "connection.rejected",
84+ "code": "version-incompatible",
85+ "message": "upgrade required",
86+ "expectedProtocolVersion": 1,
87+ }
88+ )
89+ with self.assertRaises(connection.ConnectionRejected) as raised:
90+ await connection.authenticate_socket(
91+ socket,
92+ authentication={
93+ "type": "instance-credential",
94+ "instanceId": "provider-instance",
95+ "credential": "secret",
96+ },
97+ hermes_version="0.19.0",
98+ )
99+ self.assertEqual(raised.exception.code, "version-incompatible")
100+
101+
102+if __name__ == "__main__":
103+ unittest.main()
integrations/hermes-t3-gateway/tests/test_protocol.py+63−0
@@ -0,0 +1,63 @@
1+from __future__ import annotations
2+
3+import importlib.util
4+import pathlib
5+import unittest
6+
7+ROOT = pathlib.Path(__file__).resolve().parents[1]
8+SPEC = importlib.util.spec_from_file_location(
9+ "t3_gateway_protocol", ROOT / "protocol.py"
10+)
11+assert SPEC and SPEC.loader
12+protocol = importlib.util.module_from_spec(SPEC)
13+SPEC.loader.exec_module(protocol)
14+
15+
16+class ProtocolTests(unittest.TestCase):
17+ def test_hello_matches_v1_contract(self):
18+ hello = protocol.connection_hello(
19+ hermes_version="0.19.0",
20+ authentication={"type": "enrollment-token", "token": "once"},
21+ hello_request_id="request-1",
22+ )
23+ self.assertEqual(hello["type"], "connection.hello")
24+ self.assertEqual(hello["requestId"], "request-1")
25+ self.assertEqual(hello["protocolVersion"], 1)
26+ self.assertFalse(hello["capabilities"]["attachments"])
27+ self.assertTrue(hello["capabilities"]["streaming"])
28+
29+ def test_server_frame_validation_is_closed(self):
30+ with self.assertRaisesRegex(ValueError, "unsupported"):
31+ protocol.validate_server_frame({"type": "made.up", "protocolVersion": 1})
32+ with self.assertRaisesRegex(ValueError, "version"):
33+ protocol.validate_server_frame({"type": "ping", "protocolVersion": 2})
34+
35+ def test_tool_types_map_to_canonical_items(self):
36+ self.assertEqual(
37+ protocol.canonical_tool_item_type("terminal"), "command_execution"
38+ )
39+ self.assertEqual(
40+ protocol.canonical_tool_item_type("apply_patch"), "file_change"
41+ )
42+ self.assertEqual(
43+ protocol.canonical_tool_item_type("custom_vendor_tool"),
44+ "dynamic_tool_call",
45+ )
46+
47+ def test_tool_data_never_forwards_arbitrary_args(self):
48+ self.assertEqual(
49+ protocol.canonical_tool_data(
50+ "terminal",
51+ {"command": "pytest", "cwd": "/repo", "credential": "secret"},
52+ ),
53+ {"command": "pytest", "cwd": "/repo"},
54+ )
55+ self.assertIsNone(
56+ protocol.canonical_tool_data(
57+ "custom_vendor_tool", {"credential": "must-not-cross"}
58+ )
59+ )
60+
61+
62+if __name__ == "__main__":
63+ unittest.main()
packages/client-runtime/src/state/server.ts+16−0
@@ -315,6 +315,22 @@ export function createServerEnvironmentAtoms<R, E>( · 314 unmodified lines
315315 key: ({ environmentId }) => environmentId,
316316 },
317317 }),
318+ hermesGatewayCreateEnrollment: createEnvironmentRpcCommand(runtime, {
319+ label: "environment-data:server:hermes-gateway-create-enrollment",
320+ tag: WS_METHODS.hermesGatewayCreateEnrollment,
321+ }),
322+ hermesGatewayGetInstanceStatus: createEnvironmentRpcCommand(runtime, {
323+ label: "environment-data:server:hermes-gateway-get-instance-status",
324+ tag: WS_METHODS.hermesGatewayGetInstanceStatus,
325+ }),
326+ hermesGatewayListInstances: createEnvironmentRpcCommand(runtime, {
327+ label: "environment-data:server:hermes-gateway-list-instances",
328+ tag: WS_METHODS.hermesGatewayListInstances,
329+ }),
330+ hermesGatewayRevokeInstance: createEnvironmentRpcCommand(runtime, {
331+ label: "environment-data:server:hermes-gateway-revoke-instance",
332+ tag: WS_METHODS.hermesGatewayRevokeInstance,
333+ }),
318334 updateProvider: createEnvironmentRpcCommand(runtime, {
319335 label: "environment-data:server:update-provider",
320336 tag: WS_METHODS.serverUpdateProvider,
packages/contracts/src/hermesGateway.test.ts+414−0
@@ -0,0 +1,414 @@
1+import * as Schema from "effect/Schema";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import {
5+ DEFAULT_HERMES_MODEL,
6+ DEFAULT_MODEL_BY_PROVIDER,
7+ HERMES_DRIVER_KIND,
8+ PROVIDER_DISPLAY_NAMES,
9+} from "./model.ts";
10+import {
11+ HermesGatewayCapabilities,
12+ HermesGatewayConnectionHello,
13+ HermesGatewayCreateEnrollmentInput,
14+ HermesGatewayInstanceStatus,
15+ HermesGatewayPluginToT3Message,
16+ HermesGatewayResumeCursor,
17+ HermesGatewayT3ToPluginMessage,
18+} from "./hermesGateway.ts";
19+import { WS_METHODS } from "./rpc.ts";
20+import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts";
21+
22+const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput);
23+const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities);
24+const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus);
25+const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello);
26+const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor);
27+const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage);
28+const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message);
29+const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings);
30+
31+describe("Hermes gateway management contracts", () => {
32+ it("decodes an enrollment request without deriving identity from the nickname", () => {
33+ expect(
34+ decodeCreateEnrollment({
35+ instanceId: "hermes-research",
36+ nickname: " Research ",
37+ connectorUrl: " https://siva.davis7.space:3774/hermes ",
38+ }),
39+ ).toEqual({
40+ instanceId: "hermes-research",
41+ nickname: "Research",
42+ connectorUrl: "https://siva.davis7.space:3774/hermes",
43+ });
44+ });
45+
46+ it("rejects invalid provider ids and non-connector URL schemes", () => {
47+ expect(() =>
48+ decodeCreateEnrollment({
49+ instanceId: "1-hermes",
50+ nickname: "Research",
51+ connectorUrl: "wss://siva.davis7.space/hermes",
52+ }),
53+ ).toThrow();
54+ expect(() =>
55+ decodeCreateEnrollment({
56+ instanceId: "hermes-research",
57+ nickname: "Research",
58+ connectorUrl: "ftp://siva.davis7.space/hermes",
59+ }),
60+ ).toThrow();
61+ });
62+
63+ it("represents connected and upgrade-required instances for the web UI", () => {
64+ const connected = decodeInstanceStatus({
65+ instanceId: "hermes-research",
66+ nickname: "Research",
67+ status: "connected",
68+ connectorUrl: "wss://siva.davis7.space/hermes",
69+ lastConnectedAt: "2026-07-23T12:00:00.000Z",
70+ pluginVersion: "0.1.0",
71+ hermesVersion: "1.2.3",
72+ activeSessionCount: 2,
73+ protocolVersion: 1,
74+ capabilities: {
75+ protocolVersion: 1,
76+ streaming: true,
77+ activity: true,
78+ approvals: true,
79+ userInput: true,
80+ attachments: false,
81+ },
82+ });
83+ expect(connected.status).toBe("connected");
84+ expect(connected.activeSessionCount).toBe(2);
85+
86+ const upgradeRequired = decodeInstanceStatus({
87+ ...connected,
88+ status: "upgrade-required",
89+ protocolVersion: 2,
90+ capabilities: null,
91+ });
92+ expect(upgradeRequired.protocolVersion).toBe(2);
93+ expect(upgradeRequired.capabilities).toBeNull();
94+ });
95+});
96+
97+describe("Hermes gateway handshake", () => {
98+ it("accepts one-time enrollment authentication", () => {
99+ const hello = decodeHello({
100+ type: "connection.hello",
101+ requestId: "hello-1",
102+ protocolVersion: 1,
103+ pluginVersion: "0.1.0",
104+ hermesVersion: "1.2.3",
105+ capabilities: {
106+ protocolVersion: 1,
107+ streaming: true,
108+ activity: true,
109+ approvals: true,
110+ userInput: true,
111+ attachments: false,
112+ },
113+ authentication: {
114+ type: "enrollment-token",
115+ token: "enroll-secret",
116+ },
117+ });
118+
119+ expect(hello.authentication.type).toBe("enrollment-token");
120+ });
121+
122+ it("accepts persistent instance authentication", () => {
123+ const hello = decodeHello({
124+ type: "connection.hello",
125+ requestId: "hello-2",
126+ protocolVersion: 1,
127+ pluginVersion: "0.1.0",
128+ hermesVersion: "1.2.3",
129+ capabilities: {
130+ protocolVersion: 1,
131+ streaming: true,
132+ activity: true,
133+ approvals: true,
134+ userInput: true,
135+ attachments: false,
136+ },
137+ authentication: {
138+ type: "instance-credential",
139+ instanceId: "hermes-research",
140+ credential: "persistent-secret",
141+ },
142+ });
143+
144+ expect(hello.authentication.type).toBe("instance-credential");
145+ });
146+
147+ it("decodes a future-version hello so the broker can reject it explicitly", () => {
148+ const hello = decodeHello({
149+ type: "connection.hello",
150+ requestId: "hello-future",
151+ protocolVersion: 2,
152+ pluginVersion: "0.2.0",
153+ hermesVersion: "2.0.0",
154+ capabilities: {
155+ protocolVersion: 2,
156+ streaming: true,
157+ activity: true,
158+ approvals: true,
159+ userInput: true,
160+ attachments: true,
161+ },
162+ authentication: {
163+ type: "enrollment-token",
164+ token: "enroll-secret",
165+ },
166+ });
167+
168+ expect(hello.protocolVersion).toBe(2);
169+ expect(hello.capabilities.protocolVersion).toBe(2);
170+ });
171+
172+ it("reserves attachments for a future protocol version", () => {
173+ expect(() =>
174+ decodeCapabilities({
175+ protocolVersion: 1,
176+ streaming: true,
177+ activity: true,
178+ approvals: true,
179+ userInput: true,
180+ attachments: true,
181+ }),
182+ ).toThrow();
183+ });
184+});
185+
186+describe("T3 to Hermes v1 messages", () => {
187+ it("decodes session creation and opaque resume cursors", () => {
188+ expect(
189+ decodeT3Message({
190+ type: "session.ensure",
191+ protocolVersion: 1,
192+ requestId: "ensure-1",
193+ threadId: "thread-1",
194+ resumeSessionId: "opaque/hermes/session/value",
195+ }).type,
196+ ).toBe("session.ensure");
197+
198+ expect(
199+ decodeResumeCursor({
200+ protocolVersion: 1,
201+ sessionId: "opaque/hermes/session/value",
202+ }).sessionId,
203+ ).toBe("opaque/hermes/session/value");
204+ });
205+
206+ it("decodes start and steering as distinct turn operations", () => {
207+ const context = {
208+ protocolVersion: 1,
209+ requestId: "turn-command-1",
210+ threadId: "thread-1",
211+ sessionId: "session-1",
212+ turnId: "turn-1",
213+ text: "Keep the current turn running, but use this guidance.",
214+ };
215+
216+ expect(decodeT3Message({ type: "turn.start", ...context }).type).toBe("turn.start");
217+ expect(decodeT3Message({ type: "turn.steer", ...context }).type).toBe("turn.steer");
218+ });
219+
220+ it("decodes interrupt, approval, structured input, stop, and ping", () => {
221+ const turnContext = {
222+ protocolVersion: 1,
223+ threadId: "thread-1",
224+ sessionId: "session-1",
225+ turnId: "turn-1",
226+ };
227+
228+ expect(
229+ decodeT3Message({
230+ type: "turn.interrupt",
231+ requestId: "interrupt-1",
232+ ...turnContext,
233+ }).type,
234+ ).toBe("turn.interrupt");
235+ expect(
236+ decodeT3Message({
237+ type: "approval.respond",
238+ requestId: "approval-1",
239+ decision: "acceptForSession",
240+ ...turnContext,
241+ }).type,
242+ ).toBe("approval.respond");
243+ expect(
244+ decodeT3Message({
245+ type: "user-input.respond",
246+ requestId: "question-1",
247+ answers: { environment: "production" },
248+ ...turnContext,
249+ }).type,
250+ ).toBe("user-input.respond");
251+ expect(
252+ decodeT3Message({
253+ type: "session.stop",
254+ protocolVersion: 1,
255+ requestId: "stop-1",
256+ threadId: "thread-1",
257+ sessionId: "session-1",
258+ }).type,
259+ ).toBe("session.stop");
260+ expect(
261+ decodeT3Message({
262+ type: "ping",
263+ protocolVersion: 1,
264+ requestId: "ping-1",
265+ sentAt: "2026-07-23T12:00:00.000Z",
266+ }).type,
267+ ).toBe("ping");
268+ });
269+
270+ it("rejects post-handshake frames from another protocol version", () => {
271+ expect(() =>
272+ decodeT3Message({
273+ type: "ping",
274+ protocolVersion: 2,
275+ requestId: "ping-1",
276+ sentAt: "2026-07-23T12:00:00.000Z",
277+ }),
278+ ).toThrow();
279+ });
280+});
281+
282+describe("Hermes to T3 v1 events", () => {
283+ const turnContext = {
284+ protocolVersion: 1,
285+ threadId: "thread-1",
286+ sessionId: "session-1",
287+ turnId: "turn-1",
288+ };
289+
290+ it("decodes session readiness, turn start, streaming text, and completion", () => {
291+ expect(
292+ decodePluginMessage({
293+ type: "session.ready",
294+ protocolVersion: 1,
295+ requestId: "ensure-1",
296+ threadId: "thread-1",
297+ sessionId: "session-1",
298+ resumed: false,
299+ }).type,
300+ ).toBe("session.ready");
301+ expect(
302+ decodePluginMessage({
303+ type: "turn.started",
304+ requestId: "turn-command-1",
305+ ...turnContext,
306+ }).type,
307+ ).toBe("turn.started");
308+ expect(
309+ decodePluginMessage({
310+ type: "content.delta",
311+ streamKind: "assistant_text",
312+ delta: "Hello",
313+ ...turnContext,
314+ }).type,
315+ ).toBe("content.delta");
316+ expect(
317+ decodePluginMessage({
318+ type: "turn.completed",
319+ state: "completed",
320+ ...turnContext,
321+ }).type,
322+ ).toBe("turn.completed");
323+ });
324+
325+ it("decodes activity lifecycle events with normalized and generic data", () => {
326+ for (const type of ["item.started", "item.updated", "item.completed"] as const) {
327+ expect(
328+ decodePluginMessage({
329+ type,
330+ itemId: "tool-1",
331+ itemType: "mcp_tool_call",
332+ status: type === "item.completed" ? "completed" : "inProgress",
333+ title: "Search",
334+ detail: "Looking up the requested information",
335+ data: { providerKind: "hermes-native-event" },
336+ ...turnContext,
337+ }).type,
338+ ).toBe(type);
339+ }
340+ });
341+
342+ it("decodes approvals and structured user-input lifecycle events", () => {
343+ expect(
344+ decodePluginMessage({
345+ type: "request.opened",
346+ requestId: "approval-1",
347+ requestType: "command_execution_approval",
348+ detail: "Run the command?",
349+ args: { command: "git status" },
350+ ...turnContext,
351+ }).type,
352+ ).toBe("request.opened");
353+ expect(
354+ decodePluginMessage({
355+ type: "request.resolved",
356+ requestId: "approval-1",
357+ requestType: "command_execution_approval",
358+ decision: "accept",
359+ ...turnContext,
360+ }).type,
361+ ).toBe("request.resolved");
362+ expect(
363+ decodePluginMessage({
364+ type: "user-input.requested",
365+ requestId: "question-1",
366+ questions: [
367+ {
368+ id: "environment",
369+ header: "Target",
370+ question: "Which environment?",
371+ options: [
372+ {
373+ label: "Staging",
374+ description: "Deploy to the staging environment.",
375+ },
376+ ],
377+ },
378+ ],
379+ ...turnContext,
380+ }).type,
381+ ).toBe("user-input.requested");
382+ expect(
383+ decodePluginMessage({
384+ type: "user-input.resolved",
385+ requestId: "question-1",
386+ answers: { environment: "Staging" },
387+ ...turnContext,
388+ }).type,
389+ ).toBe("user-input.resolved");
390+ });
391+});
392+
393+describe("Hermes provider integration constants", () => {
394+ it("exposes Hermes as a single opaque model in the normal provider picker", () => {
395+ expect(DEFAULT_MODEL_BY_PROVIDER[HERMES_DRIVER_KIND]).toBe(DEFAULT_HERMES_MODEL);
396+ expect(PROVIDER_DISPLAY_NAMES[HERMES_DRIVER_KIND]).toBe("Hermes");
397+ });
398+
399+ it("keeps Hermes server settings remote-only", () => {
400+ expect(decodeHermesSettings({})).toEqual({
401+ enabled: true,
402+ });
403+ expect(DEFAULT_SERVER_SETTINGS.providers.hermes).toEqual({
404+ enabled: true,
405+ });
406+ });
407+
408+ it("registers the web-management RPC method names", () => {
409+ expect(WS_METHODS.hermesGatewayCreateEnrollment).toBe("hermesGateway.createEnrollment");
410+ expect(WS_METHODS.hermesGatewayGetInstanceStatus).toBe("hermesGateway.getInstanceStatus");
411+ expect(WS_METHODS.hermesGatewayListInstances).toBe("hermesGateway.listInstances");
412+ expect(WS_METHODS.hermesGatewayRevokeInstance).toBe("hermesGateway.revokeInstance");
413+ });
414+});
packages/contracts/src/hermesGateway.ts+574−0
@@ -0,0 +1,574 @@
1+/**
2+ * Versioned contracts for the T3 Code gateway plugin hosted by Hermes.
3+ *
4+ * The web-management schemas are intentionally separate from the plugin wire
5+ * protocol. Browser clients may receive one-time enrollment tokens, but never
6+ * the persistent credential issued directly to the plugin after enrollment.
7+ *
8+ * @module hermesGateway
9+ */
10+import * as Schema from "effect/Schema";
11+
12+import {
13+ IsoDateTime,
14+ NonNegativeInt,
15+ PositiveInt,
16+ ThreadId,
17+ TrimmedNonEmptyString,
18+ TurnId,
19+} from "./baseSchemas.ts";
20+import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts";
21+import { ProviderInstanceId } from "./providerInstance.ts";
22+import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
23+
24+export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const;
25+
26+export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
27+export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
28+
29+export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe(
30+ Schema.brand("HermesGatewayRequestId"),
31+);
32+export type HermesGatewayRequestId = typeof HermesGatewayRequestId.Type;
33+
34+/**
35+ * An opaque identifier owned entirely by Hermes. T3 persists and echoes it,
36+ * but must not derive routing or other semantics from its contents.
37+ */
38+export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe(
39+ Schema.brand("HermesGatewaySessionId"),
40+);
41+export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type;
42+
43+export const HermesGatewayResumeCursor = Schema.Struct({
44+ protocolVersion: HermesGatewayProtocolVersion,
45+ sessionId: HermesGatewaySessionId,
46+});
47+export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type;
48+
49+export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId"));
50+export type HermesGatewayItemId = typeof HermesGatewayItemId.Type;
51+
52+export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe(
53+ Schema.brand("HermesGatewayEnrollmentToken"),
54+);
55+export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type;
56+
57+export const HermesGatewayCredential = TrimmedNonEmptyString.pipe(
58+ Schema.brand("HermesGatewayCredential"),
59+);
60+export type HermesGatewayCredential = typeof HermesGatewayCredential.Type;
61+
62+export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
63+export type HermesGatewayNickname = typeof HermesGatewayNickname.Type;
64+
65+/**
66+ * T3 accepts ordinary HTTP(S) URLs because the plugin command may normalize
67+ * them to WebSocket URLs, as well as explicit WS(S) connector URLs.
68+ */
69+export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check(
70+ Schema.isMaxLength(2_048),
71+ Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i),
72+);
73+export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type;
74+
75+export const HermesGatewayCapabilities = Schema.Struct({
76+ protocolVersion: HermesGatewayProtocolVersion,
77+ streaming: Schema.Boolean,
78+ activity: Schema.Boolean,
79+ approvals: Schema.Boolean,
80+ userInput: Schema.Boolean,
81+ attachments: Schema.Literal(false),
82+});
83+export type HermesGatewayCapabilities = typeof HermesGatewayCapabilities.Type;
84+
85+/**
86+ * Capability advertisement accepted at the initial handshake boundary.
87+ *
88+ * This deliberately permits capability shapes from a newer protocol so T3 can
89+ * return a structured `version-incompatible` rejection instead of failing the
90+ * WebSocket frame decoder. Accepted v1 connections must subsequently validate
91+ * this advertisement with `HermesGatewayCapabilities`.
92+ */
93+export const HermesGatewayHelloCapabilities = Schema.Struct({
94+ protocolVersion: PositiveInt,
95+ streaming: Schema.Boolean,
96+ activity: Schema.Boolean,
97+ approvals: Schema.Boolean,
98+ userInput: Schema.Boolean,
99+ attachments: Schema.Boolean,
100+});
101+export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type;
102+
103+export const HermesGatewayConnectionState = Schema.Literals([
104+ "offline",
105+ "connecting",
106+ "connected",
107+ "upgrade-required",
108+ "revoked",
109+]);
110+export type HermesGatewayConnectionState = typeof HermesGatewayConnectionState.Type;
111+
112+/**
113+ * Public instance state used by settings and provider-picker surfaces.
114+ *
115+ * `protocolVersion` is not restricted to v1 here so the UI can report the
116+ * unsupported version observed from a plugin that needs an upgrade.
117+ */
118+export const HermesGatewayInstanceStatus = Schema.Struct({
119+ instanceId: ProviderInstanceId,
120+ nickname: HermesGatewayNickname,
121+ status: HermesGatewayConnectionState,
122+ connectorUrl: HermesGatewayConnectorUrl,
123+ lastConnectedAt: Schema.NullOr(IsoDateTime),
124+ pluginVersion: Schema.NullOr(TrimmedNonEmptyString),
125+ hermesVersion: Schema.NullOr(TrimmedNonEmptyString),
126+ activeSessionCount: NonNegativeInt,
127+ protocolVersion: Schema.NullOr(PositiveInt),
128+ capabilities: Schema.NullOr(HermesGatewayCapabilities),
129+});
130+export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type;
131+
132+export const HermesGatewayCreateEnrollmentInput = Schema.Struct({
133+ instanceId: ProviderInstanceId,
134+ nickname: HermesGatewayNickname,
135+ connectorUrl: HermesGatewayConnectorUrl,
136+});
137+export type HermesGatewayCreateEnrollmentInput = typeof HermesGatewayCreateEnrollmentInput.Type;
138+
139+/**
140+ * Returned exactly once to the web client. The long-lived plugin credential
141+ * is intentionally absent and is delivered only over the authenticated
142+ * enrollment socket.
143+ */
144+export const HermesGatewayEnrollmentResult = Schema.Struct({
145+ instanceId: ProviderInstanceId,
146+ expiresAt: IsoDateTime,
147+ connectorUrl: HermesGatewayConnectorUrl,
148+ command: TrimmedNonEmptyString,
149+ oneTimeToken: HermesGatewayEnrollmentToken,
150+});
151+export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type;
152+
153+export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus);
154+export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type;
155+
156+export const HermesGatewayGetInstanceStatusInput = Schema.Struct({
157+ instanceId: ProviderInstanceId,
158+});
159+export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type;
160+
161+export const HermesGatewayRenameInstanceInput = Schema.Struct({
162+ instanceId: ProviderInstanceId,
163+ nickname: HermesGatewayNickname,
164+});
165+export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type;
166+
167+export const HermesGatewayRevokeInstanceInput = Schema.Struct({
168+ instanceId: ProviderInstanceId,
169+});
170+export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type;
171+
172+export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus;
173+export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type;
174+
175+export const HermesGatewayManagementOperation = Schema.Literals([
176+ "create-enrollment",
177+ "get-status",
178+ "list-instances",
179+ "revoke-instance",
180+]);
181+export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type;
182+
183+export const HermesGatewayManagementErrorCode = Schema.Literals([
184+ "instance-not-found",
185+ "nickname-conflict",
186+ "invalid-connector-url",
187+ "instance-revoked",
188+ "persistence-failed",
189+ "internal-error",
190+]);
191+export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type;
192+
193+export class HermesGatewayManagementError extends Schema.TaggedErrorClass<HermesGatewayManagementError>()(
194+ "HermesGatewayManagementError",
195+ {
196+ operation: HermesGatewayManagementOperation,
197+ code: HermesGatewayManagementErrorCode,
198+ message: TrimmedNonEmptyString,
199+ instanceId: Schema.optional(ProviderInstanceId),
200+ },
201+) {}
202+
203+const HermesGatewayEnrollmentAuthentication = Schema.Struct({
204+ type: Schema.Literal("enrollment-token"),
205+ token: HermesGatewayEnrollmentToken,
206+});
207+export type HermesGatewayEnrollmentAuthentication =
208+ typeof HermesGatewayEnrollmentAuthentication.Type;
209+
210+const HermesGatewayCredentialAuthentication = Schema.Struct({
211+ type: Schema.Literal("instance-credential"),
212+ instanceId: ProviderInstanceId,
213+ credential: HermesGatewayCredential,
214+});
215+export type HermesGatewayCredentialAuthentication =
216+ typeof HermesGatewayCredentialAuthentication.Type;
217+
218+export const HermesGatewayAuthentication = Schema.Union([
219+ HermesGatewayEnrollmentAuthentication,
220+ HermesGatewayCredentialAuthentication,
221+]);
222+export type HermesGatewayAuthentication = typeof HermesGatewayAuthentication.Type;
223+
224+/**
225+ * `protocolVersion` accepts any positive integer at the initial boundary so
226+ * T3 can reject incompatible plugins with a structured upgrade response.
227+ * Once accepted, all remaining v1 frames use the literal v1 schema.
228+ */
229+export const HermesGatewayConnectionHello = Schema.Struct({
230+ type: Schema.Literal("connection.hello"),
231+ requestId: HermesGatewayRequestId,
232+ protocolVersion: PositiveInt,
233+ pluginVersion: TrimmedNonEmptyString,
234+ hermesVersion: TrimmedNonEmptyString,
235+ capabilities: HermesGatewayHelloCapabilities,
236+ authentication: HermesGatewayAuthentication,
237+});
238+export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;
239+
240+export const HermesGatewayConnectionAccepted = Schema.Struct({
241+ type: Schema.Literal("connection.accepted"),
242+ requestId: HermesGatewayRequestId,
243+ protocolVersion: HermesGatewayProtocolVersion,
244+ instanceId: ProviderInstanceId,
245+ nickname: HermesGatewayNickname,
246+ credential: Schema.optional(HermesGatewayCredential),
247+});
248+export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;
249+
250+export const HermesGatewayConnectionRejectionCode = Schema.Literals([
251+ "invalid-authentication",
252+ "enrollment-expired",
253+ "instance-revoked",
254+ "version-incompatible",
255+ "internal-error",
256+]);
257+export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type;
258+
259+export const HermesGatewayConnectionRejected = Schema.Struct({
260+ type: Schema.Literal("connection.rejected"),
261+ requestId: HermesGatewayRequestId,
262+ code: HermesGatewayConnectionRejectionCode,
263+ message: TrimmedNonEmptyString,
264+ expectedProtocolVersion: HermesGatewayProtocolVersion,
265+});
266+export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type;
267+
268+export const HermesGatewayConnectionStatus = Schema.Struct({
269+ type: Schema.Literal("connection.status"),
270+ protocolVersion: HermesGatewayProtocolVersion,
271+ activeSessionCount: NonNegativeInt,
272+});
273+export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type;
274+
275+const HermesGatewaySessionContext = Schema.Struct({
276+ threadId: ThreadId,
277+ sessionId: HermesGatewaySessionId,
278+});
279+
280+const HermesGatewayTurnContext = Schema.Struct({
281+ ...HermesGatewaySessionContext.fields,
282+ turnId: TurnId,
283+});
284+
285+const HermesGatewayTurnText = Schema.String.check(
286+ Schema.isMinLength(1),
287+ Schema.isMaxLength(120_000),
288+);
289+
290+export const HermesGatewaySessionEnsure = Schema.Struct({
291+ type: Schema.Literal("session.ensure"),
292+ protocolVersion: HermesGatewayProtocolVersion,
293+ requestId: HermesGatewayRequestId,
294+ threadId: ThreadId,
295+ resumeSessionId: Schema.optional(HermesGatewaySessionId),
296+});
297+export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type;
298+
299+export const HermesGatewayTurnStart = Schema.Struct({
300+ type: Schema.Literal("turn.start"),
301+ protocolVersion: HermesGatewayProtocolVersion,
302+ requestId: HermesGatewayRequestId,
303+ ...HermesGatewayTurnContext.fields,
304+ text: HermesGatewayTurnText,
305+});
306+export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type;
307+
308+export const HermesGatewayTurnSteer = Schema.Struct({
309+ type: Schema.Literal("turn.steer"),
310+ protocolVersion: HermesGatewayProtocolVersion,
311+ requestId: HermesGatewayRequestId,
312+ ...HermesGatewayTurnContext.fields,
313+ text: HermesGatewayTurnText,
314+});
315+export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type;
316+
317+export const HermesGatewayTurnInterrupt = Schema.Struct({
318+ type: Schema.Literal("turn.interrupt"),
319+ protocolVersion: HermesGatewayProtocolVersion,
320+ requestId: HermesGatewayRequestId,
321+ ...HermesGatewayTurnContext.fields,
322+});
323+export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type;
324+
325+export const HermesGatewayApprovalResponse = Schema.Struct({
326+ type: Schema.Literal("approval.respond"),
327+ protocolVersion: HermesGatewayProtocolVersion,
328+ ...HermesGatewayTurnContext.fields,
329+ requestId: HermesGatewayRequestId,
330+ decision: ProviderApprovalDecision,
331+});
332+export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type;
333+
334+export const HermesGatewayUserInputResponse = Schema.Struct({
335+ type: Schema.Literal("user-input.respond"),
336+ protocolVersion: HermesGatewayProtocolVersion,
337+ ...HermesGatewayTurnContext.fields,
338+ requestId: HermesGatewayRequestId,
339+ answers: ProviderUserInputAnswers,
340+});
341+export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type;
342+
343+export const HermesGatewaySessionStop = Schema.Struct({
344+ type: Schema.Literal("session.stop"),
345+ protocolVersion: HermesGatewayProtocolVersion,
346+ requestId: HermesGatewayRequestId,
347+ ...HermesGatewaySessionContext.fields,
348+});
349+export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type;
350+
351+export const HermesGatewayPing = Schema.Struct({
352+ type: Schema.Literal("ping"),
353+ protocolVersion: HermesGatewayProtocolVersion,
354+ requestId: HermesGatewayRequestId,
355+ sentAt: IsoDateTime,
356+});
357+export type HermesGatewayPing = typeof HermesGatewayPing.Type;
358+
359+export const HermesGatewaySessionReady = Schema.Struct({
360+ type: Schema.Literal("session.ready"),
361+ protocolVersion: HermesGatewayProtocolVersion,
362+ requestId: HermesGatewayRequestId,
363+ threadId: ThreadId,
364+ sessionId: HermesGatewaySessionId,
365+ resumed: Schema.Boolean,
366+});
367+export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;
368+
369+export const HermesGatewayTurnStarted = Schema.Struct({
370+ type: Schema.Literal("turn.started"),
371+ protocolVersion: HermesGatewayProtocolVersion,
372+ requestId: HermesGatewayRequestId,
373+ ...HermesGatewayTurnContext.fields,
374+});
375+export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type;
376+
377+export const HermesGatewayContentStreamKind = Schema.Literals([
378+ "assistant_text",
379+ "reasoning_text",
380+ "reasoning_summary_text",
381+ "plan_text",
382+ "command_output",
383+ "unknown",
384+]);
385+export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type;
386+
387+export const HermesGatewayContentDelta = Schema.Struct({
388+ type: Schema.Literal("content.delta"),
389+ protocolVersion: HermesGatewayProtocolVersion,
390+ ...HermesGatewayTurnContext.fields,
391+ itemId: Schema.optional(HermesGatewayItemId),
392+ streamKind: HermesGatewayContentStreamKind,
393+ delta: Schema.String,
394+ contentIndex: Schema.optional(NonNegativeInt),
395+});
396+export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;
397+
398+export const HermesGatewayItemStatus = Schema.Literals([
399+ "inProgress",
400+ "completed",
401+ "failed",
402+ "declined",
403+]);
404+export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type;
405+
406+const HermesGatewayItemLifecycleFields = {
407+ protocolVersion: HermesGatewayProtocolVersion,
408+ ...HermesGatewayTurnContext.fields,
409+ itemId: HermesGatewayItemId,
410+ itemType: CanonicalItemType,
411+ status: Schema.optional(HermesGatewayItemStatus),
412+ title: Schema.optional(TrimmedNonEmptyString),
413+ detail: Schema.optional(TrimmedNonEmptyString),
414+ data: Schema.optional(Schema.Unknown),
415+};
416+
417+export const HermesGatewayItemStarted = Schema.Struct({
418+ type: Schema.Literal("item.started"),
419+ ...HermesGatewayItemLifecycleFields,
420+});
421+export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type;
422+
423+export const HermesGatewayItemUpdated = Schema.Struct({
424+ type: Schema.Literal("item.updated"),
425+ ...HermesGatewayItemLifecycleFields,
426+});
427+export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type;
428+
429+export const HermesGatewayItemCompleted = Schema.Struct({
430+ type: Schema.Literal("item.completed"),
431+ ...HermesGatewayItemLifecycleFields,
432+});
433+export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type;
434+
435+const HermesGatewayInteractionContext = Schema.Struct({
436+ ...HermesGatewayTurnContext.fields,
437+ requestId: HermesGatewayRequestId,
438+});
439+
440+export const HermesGatewayRequestOpened = Schema.Struct({
441+ type: Schema.Literal("request.opened"),
442+ protocolVersion: HermesGatewayProtocolVersion,
443+ ...HermesGatewayInteractionContext.fields,
444+ requestType: CanonicalRequestType,
445+ detail: Schema.optional(TrimmedNonEmptyString),
446+ args: Schema.optional(Schema.Unknown),
447+});
448+export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type;
449+
450+export const HermesGatewayRequestResolved = Schema.Struct({
451+ type: Schema.Literal("request.resolved"),
452+ protocolVersion: HermesGatewayProtocolVersion,
453+ ...HermesGatewayInteractionContext.fields,
454+ requestType: CanonicalRequestType,
455+ decision: Schema.optional(TrimmedNonEmptyString),
456+ resolution: Schema.optional(Schema.Unknown),
457+});
458+export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type;
459+
460+export const HermesGatewayUserInputRequested = Schema.Struct({
461+ type: Schema.Literal("user-input.requested"),
462+ protocolVersion: HermesGatewayProtocolVersion,
463+ ...HermesGatewayInteractionContext.fields,
464+ questions: Schema.Array(UserInputQuestion),
465+});
466+export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type;
467+
468+export const HermesGatewayUserInputResolved = Schema.Struct({
469+ type: Schema.Literal("user-input.resolved"),
470+ protocolVersion: HermesGatewayProtocolVersion,
471+ ...HermesGatewayInteractionContext.fields,
472+ answers: ProviderUserInputAnswers,
473+});
474+export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type;
475+
476+export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]);
477+export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type;
478+
479+export const HermesGatewayTurnCompleted = Schema.Struct({
480+ type: Schema.Literal("turn.completed"),
481+ protocolVersion: HermesGatewayProtocolVersion,
482+ ...HermesGatewayTurnContext.fields,
483+ state: HermesGatewayTurnCompletionState,
484+ stopReason: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
485+ usage: Schema.optional(Schema.Unknown),
486+ errorMessage: Schema.optional(TrimmedNonEmptyString),
487+});
488+export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type;
489+
490+export const HermesGatewayTurnAborted = Schema.Struct({
491+ type: Schema.Literal("turn.aborted"),
492+ protocolVersion: HermesGatewayProtocolVersion,
493+ ...HermesGatewayTurnContext.fields,
494+ reason: TrimmedNonEmptyString,
495+});
496+export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type;
497+
498+export const HermesGatewaySessionExited = Schema.Struct({
499+ type: Schema.Literal("session.exited"),
500+ protocolVersion: HermesGatewayProtocolVersion,
501+ ...HermesGatewaySessionContext.fields,
502+ reason: Schema.optional(TrimmedNonEmptyString),
503+ recoverable: Schema.Boolean,
504+});
505+export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type;
506+
507+export const HermesGatewayPong = Schema.Struct({
508+ type: Schema.Literal("pong"),
509+ protocolVersion: HermesGatewayProtocolVersion,
510+ requestId: HermesGatewayRequestId,
511+ sentAt: IsoDateTime,
512+});
513+export type HermesGatewayPong = typeof HermesGatewayPong.Type;
514+
515+export const HermesGatewayProtocolErrorCode = Schema.Literals([
516+ "invalid-message",
517+ "unsupported-message",
518+ "session-not-found",
519+ "turn-not-active",
520+ "request-not-found",
521+ "internal-error",
522+]);
523+export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type;
524+
525+export const HermesGatewayProtocolError = Schema.Struct({
526+ type: Schema.Literal("protocol.error"),
527+ protocolVersion: HermesGatewayProtocolVersion,
528+ requestId: Schema.optional(HermesGatewayRequestId),
529+ code: HermesGatewayProtocolErrorCode,
530+ message: TrimmedNonEmptyString,
531+ recoverable: Schema.Boolean,
532+});
533+export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type;
534+
535+export const HermesGatewayT3ToPluginMessage = Schema.Union([
536+ HermesGatewayConnectionAccepted,
537+ HermesGatewayConnectionRejected,
538+ HermesGatewaySessionEnsure,
539+ HermesGatewayTurnStart,
540+ HermesGatewayTurnSteer,
541+ HermesGatewayTurnInterrupt,
542+ HermesGatewayApprovalResponse,
543+ HermesGatewayUserInputResponse,
544+ HermesGatewaySessionStop,
545+ HermesGatewayPing,
546+]);
547+export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;
548+
549+export const HermesGatewayPluginToT3Message = Schema.Union([
550+ HermesGatewayConnectionHello,
551+ HermesGatewayConnectionStatus,
552+ HermesGatewaySessionReady,
553+ HermesGatewayTurnStarted,
554+ HermesGatewayContentDelta,
555+ HermesGatewayItemStarted,
556+ HermesGatewayItemUpdated,
557+ HermesGatewayItemCompleted,
558+ HermesGatewayRequestOpened,
559+ HermesGatewayRequestResolved,
560+ HermesGatewayUserInputRequested,
561+ HermesGatewayUserInputResolved,
562+ HermesGatewayTurnCompleted,
563+ HermesGatewayTurnAborted,
564+ HermesGatewaySessionExited,
565+ HermesGatewayPong,
566+ HermesGatewayProtocolError,
567+]);
568+export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;
569+
570+export const HermesGatewayWireMessage = Schema.Union([
571+ HermesGatewayT3ToPluginMessage,
572+ HermesGatewayPluginToT3Message,
573+]);
574+export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type;
packages/contracts/src/index.ts+1−0
@@ -27,3 +27,4 @@ export * from "./review.ts"; · 26 unmodified lines
2727 export * from "./preview.ts";
2828 export * from "./previewAutomation.ts";
2929 export * from "./rpc.ts";
30+export * from "./hermesGateway.ts";
packages/contracts/src/model.ts+4−0
@@ -132,8 +132,10 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); · 131 unmodified lines
132132 const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133133 const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
134134 const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
135+export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes");
135136
136137 export const DEFAULT_MODEL = "gpt-5.6-sol";
138+export const DEFAULT_HERMES_MODEL = "hermes";
137139
138140 /**
139141 * Codex default-model preference, most preferred first. The provider snapshot
@@ -152,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial<Record<ProviderDriverKind, strin · 12 unmodified lines
152154 [CURSOR_DRIVER_KIND]: "auto",
153155 [GROK_DRIVER_KIND]: "grok-build",
154156 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
157+ [HERMES_DRIVER_KIND]: DEFAULT_HERMES_MODEL,
155158 };
156159
157160 /** Per-provider text generation model defaults. */
@@ -218,4 +221,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 60 unmodified lines
218221 [CURSOR_DRIVER_KIND]: "Cursor",
219222 [GROK_DRIVER_KIND]: "Grok",
220223 [OPENCODE_DRIVER_KIND]: "OpenCode",
224+ [HERMES_DRIVER_KIND]: "Hermes",
221225 };
packages/contracts/src/rpc.ts+50−0
@@ -59,6 +59,16 @@ import { · 58 unmodified lines
5959 OrchestrationRpcSchemas,
6060 } from "./orchestration.ts";
6161 import { ProviderInstanceId } from "./providerInstance.ts";
62+import {
63+ HermesGatewayCreateEnrollmentInput,
64+ HermesGatewayEnrollmentResult,
65+ HermesGatewayGetInstanceStatusInput,
66+ HermesGatewayInstanceStatus,
67+ HermesGatewayListInstancesResult,
68+ HermesGatewayManagementError,
69+ HermesGatewayRevokeInstanceInput,
70+ HermesGatewayRevokeInstanceResult,
71+} from "./hermesGateway.ts";
6272 import {
6373 RelayClientInstallFailedError,
6474 RelayClientInstallProgressEventSchema,
@@ -219,6 +229,12 @@ export const WS_METHODS = { · 154 unmodified lines
219229 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
220230 serverSignalProcess: "server.signalProcess",
221231
232+ // Hermes gateway instance management
233+ hermesGatewayCreateEnrollment: "hermesGateway.createEnrollment",
234+ hermesGatewayGetInstanceStatus: "hermesGateway.getInstanceStatus",
235+ hermesGatewayListInstances: "hermesGateway.listInstances",
236+ hermesGatewayRevokeInstance: "hermesGateway.revokeInstance",
237+
222238 // Cloud environment methods
223239 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
224240 cloudInstallRelayClient: "cloud.installRelayClient",
@@ -334,6 +350,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, · 109 unmodified lines
334350 error: EnvironmentAuthorizationError,
335351 });
336352
353+export const WsHermesGatewayCreateEnrollmentRpc = Rpc.make(
354+ WS_METHODS.hermesGatewayCreateEnrollment,
355+ {
356+ payload: HermesGatewayCreateEnrollmentInput,
357+ success: HermesGatewayEnrollmentResult,
358+ error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
359+ },
360+);
361+
362+export const WsHermesGatewayGetInstanceStatusRpc = Rpc.make(
363+ WS_METHODS.hermesGatewayGetInstanceStatus,
364+ {
365+ payload: HermesGatewayGetInstanceStatusInput,
366+ success: HermesGatewayInstanceStatus,
367+ error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
368+ },
369+);
370+
371+export const WsHermesGatewayListInstancesRpc = Rpc.make(WS_METHODS.hermesGatewayListInstances, {
372+ payload: Schema.Struct({}),
373+ success: HermesGatewayListInstancesResult,
374+ error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
375+});
376+
377+export const WsHermesGatewayRevokeInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRevokeInstance, {
378+ payload: HermesGatewayRevokeInstanceInput,
379+ success: HermesGatewayRevokeInstanceResult,
380+ error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
381+});
382+
337383 export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, {
338384 payload: Schema.Struct({}),
339385 success: RelayClientStatusSchema,
@@ -713,6 +759,10 @@ export const WsRpcGroup = RpcGroup.make( · 373 unmodified lines
713759 WsServerGetProcessDiagnosticsRpc,
714760 WsServerGetProcessResourceHistoryRpc,
715761 WsServerSignalProcessRpc,
762+ WsHermesGatewayCreateEnrollmentRpc,
763+ WsHermesGatewayGetInstanceStatusRpc,
764+ WsHermesGatewayListInstancesRpc,
765+ WsHermesGatewayRevokeInstanceRpc,
716766 WsCloudGetRelayClientStatusRpc,
717767 WsCloudInstallRelayClientRpc,
718768 WsSourceControlLookupRepositoryRpc,
packages/contracts/src/settings.ts+19−0
@@ -389,6 +389,19 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 388 unmodified lines
389389 );
390390 export type OpenCodeSettings = typeof OpenCodeSettings.Type;
391391
392+/**
393+ * Hermes is a remotely managed runtime. Its model, process, and session
394+ * configuration remain on the paired Hermes instance, so T3 exposes no
395+ * binary, home, working-directory, or model settings.
396+ */
397+export const HermesSettings = makeProviderSettingsSchema({
398+ enabled: Schema.Boolean.pipe(
399+ Schema.withDecodingDefault(Effect.succeed(true)),
400+ Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
401+ ),
402+});
403+export type HermesSettings = typeof HermesSettings.Type;
404+
392405 export const ObservabilitySettings = Schema.Struct({
393406 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
394407 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -433,6 +446,7 @@ export const ServerSettings = Schema.Struct({ · 38 unmodified lines
433446 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
434447 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
435448 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
449+ hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
436450 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
437451 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
438452 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -536,6 +550,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 97 unmodified lines
536550 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
537551 });
538552
553+const HermesSettingsPatch = Schema.Struct({
554+ enabled: Schema.optionalKey(Schema.Boolean),
555+});
556+
539557 export const ServerSettingsPatch = Schema.Struct({
540558 // Server settings
541559 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
@@ -558,6 +576,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 16 unmodified lines
558576 cursor: Schema.optionalKey(CursorSettingsPatch),
559577 grok: Schema.optionalKey(GrokSettingsPatch),
560578 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
579+ hermes: Schema.optionalKey(HermesSettingsPatch),
561580 }),
562581 ),
563582 // Whole-map replacement for the new instance config. Patching individual