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
1181 expect(harness.stopSession.mock.calls.length).toBe(0);1181 expect(harness.stopSession.mock.calls.length).toBe(0);
1182 });1182 });
1183 1183
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
1184 it("restarts an existing Codex thread on a compatible requested instance", async () => {1240 it("restarts an existing Codex thread on a compatible requested instance", async () => {
1185 const harness = await createHarness();1241 const harness = await createHarness();
1186 const now = "2026-01-01T00:00:00.000Z";1242 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
1873 expect(message?.streaming).toBe(false);1873 expect(message?.streaming).toBe(false);
1874 });1874 });
1875 1875
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
1876 it("flushes and completes buffered assistant text when an approval request opens", async () => {1967 it("flushes and completes buffered assistant text when an approval request opens", async () => {
1877 const harness = await createHarness();1968 const harness = await createHarness();
1878 const now = "2026-01-01T00:00:00.000Z";1969 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
1343 case "turn.started":1343 case "turn.started":
1344 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;1344 return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart;
1345 case "turn.completed":1345 case "turn.completed":
1346 case "turn.aborted":
1346 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {1347 if (conflictsWithActiveTurn || missingTurnForActiveTurn) {
1347 return false;1348 return false;
1348 }1349 }
@@ -1367,7 +1368,8 @@ const make = Effect.gen(function* () { · 18 unmodified lines
1367 event.type === "session.exited" ||1368 event.type === "session.exited" ||
1368 event.type === "thread.started" ||1369 event.type === "thread.started" ||
1369 event.type === "turn.started" ||1370 event.type === "turn.started" ||
1370 event.type === "turn.completed"1371 event.type === "turn.completed" ||
1372 event.type === "turn.aborted"
1371 ) {1373 ) {
1372 const status = (() => {1374 const status = (() => {
1373 switch (event.type) {1375 switch (event.type) {
@@ -1383,6 +1385,8 @@ const make = Effect.gen(function* () { · 9 unmodified lines
1383 return normalizeRuntimeTurnState(event.payload.state) === "failed"1385 return normalizeRuntimeTurnState(event.payload.state) === "failed"
1384 ? "error"1386 ? "error"
1385 : "ready";1387 : "ready";
1388 case "turn.aborted":
1389 return "ready";
1386 case "session.started":1390 case "session.started":
1387 case "thread.started":1391 case "thread.started":
1388 // Provider thread/session start notifications can arrive during an1392 // Provider thread/session start notifications can arrive during an
@@ -1393,7 +1397,9 @@ const make = Effect.gen(function* () { · 4 unmodified lines
1393 const nextActiveTurnId =1397 const nextActiveTurnId =
1394 event.type === "turn.started"1398 event.type === "turn.started"
1395 ? (eventTurnId ?? null)1399 ? (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"
1397 ? null1403 ? null
1398 : event.type === "session.state.changed" &&1404 : event.type === "session.state.changed" &&
1399 !sessionStatusAllowsActiveTurn(1405 !sessionStatusAllowsActiveTurn(
@@ -1634,7 +1640,7 @@ const make = Effect.gen(function* () { · 234 unmodified lines
1634 });1640 });
1635 }1641 }
1636 1642
1637 if (event.type === "turn.completed") {1643 if (event.type === "turn.completed" || event.type === "turn.aborted") {
1638 const detailedThread = yield* getLoadedThreadDetail();1644 const detailedThread = yield* getLoadedThreadDetail();
1639 const messages = detailedThread?.messages ?? [];1645 const messages = detailedThread?.messages ?? [];
1640 const proposedPlans = detailedThread?.proposedPlans ?? [];1646 const proposedPlans = detailedThread?.proposedPlans ?? [];
apps/server/src/provider/Drivers/HermesDriver.ts+131−0
@@ -0,0 +1,131 @@
1import {
2 DEFAULT_HERMES_MODEL,
3 HERMES_DRIVER_KIND,
4 HermesSettings,
5 TextGenerationError,
6 type ServerProvider,
7} from "@t3tools/contracts";
8import * as Crypto from "effect/Crypto";
9import * as DateTime from "effect/DateTime";
10import * as Effect from "effect/Effect";
11import * as Schema from "effect/Schema";
12import * as Stream from "effect/Stream";
13
14import type { TextGenerationShape } from "../../textGeneration/TextGeneration.ts";
15import { makeHermesAdapter } from "../Layers/HermesAdapter.ts";
16import {
17 defaultProviderContinuationIdentity,
18 type ProviderDriver,
19 type ProviderInstance,
20} from "../ProviderDriver.ts";
21import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
22import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts";
23
24const decodeHermesSettings = Schema.decodeSync(HermesSettings);
25
26export type HermesDriverEnv = Crypto.Crypto;
27
28const 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
42const makeTextGeneration = (): TextGenerationShape => ({
43 generateCommitMessage: () => unsupportedTextGeneration("generateCommitMessage"),
44 generatePrContent: () => unsupportedTextGeneration("generatePrContent"),
45 generateBranchName: () => unsupportedTextGeneration("generateBranchName"),
46 generateThreadTitle: () => unsupportedTextGeneration("generateThreadTitle"),
47});
48
49export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
4 HERMES_GATEWAY_PROTOCOL_VERSION,
5 HermesGatewayRequestId,
6 HermesGatewaySessionId,
7 ProviderInstanceId,
8 ThreadId,
9 type HermesGatewayT3ToPluginMessage,
10} from "@t3tools/contracts";
11import * as Effect from "effect/Effect";
12import * as Fiber from "effect/Fiber";
13import * as Option from "effect/Option";
14import * as PubSub from "effect/PubSub";
15import * as Stream from "effect/Stream";
16
17import {
18 HermesGatewayBroker,
19 type HermesGatewayBrokerShape,
20 type HermesGatewayEnvelope,
21} from "../Services/HermesGatewayBroker.ts";
22import {
23 makeHermesAdapter,
24 sanitizeHermesItemData,
25 sanitizeHermesRequestArgs,
26 shouldProjectHermesTurnStarted,
27} from "./HermesAdapter.ts";
28
29it("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
54it.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 @@
1import {
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";
17import * as Crypto from "effect/Crypto";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as PubSub from "effect/PubSub";
21import * as Schema from "effect/Schema";
22import * as Stream from "effect/Stream";
23
24import {
25 ProviderAdapterRequestError,
26 ProviderAdapterSessionNotFoundError,
27 ProviderAdapterValidationError,
28} from "../Errors.ts";
29import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts";
30import { HermesGatewayBroker } from "../Services/HermesGatewayBroker.ts";
31
32const PROVIDER = ProviderDriverKind.make("hermes");
33const isResumeCursor = Schema.is(HermesGatewayResumeCursor);
34
35type HermesAdapterShape = ProviderAdapterShape<
36 ProviderAdapterRequestError | ProviderAdapterSessionNotFoundError | ProviderAdapterValidationError
37>;
38
39interface SessionContext {
40 readonly hermesSessionId: HermesGatewaySessionId;
41 readonly turns: Array<{ readonly id: TurnId; readonly items: Array<unknown> }>;
42 session: ProviderSession;
43}
44
45type PluginMessage = Exclude<HermesGatewayPluginToT3Message, { readonly type: "connection.hello" }>;
46
47const nowIso = () => DateTime.formatIso(DateTime.nowUnsafe());
48const MAX_PERSISTED_GATEWAY_FIELD_CHARS = 4_096;
49
50const boundedString = (value: unknown) =>
51 typeof value === "string" && value.length <= MAX_PERSISTED_GATEWAY_FIELD_CHARS
52 ? value
53 : undefined;
54
55const asRecord = (value: unknown) =>
56 typeof value === "object" && value !== null
57 ? (value as Readonly<Record<string, unknown>>)
58 : undefined;
59
60export 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
87export 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
110export const shouldProjectHermesTurnStarted = (
111 pendingSteerRequestIds: Set<HermesGatewayRequestId>,
112 incomingRequestId: HermesGatewayRequestId,
113) => !pendingSteerRequestIds.delete(incomingRequestId);
114
115export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import {
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";
13import * as Effect from "effect/Effect";
14import * as Fiber from "effect/Fiber";
15import * as Context from "effect/Context";
16import * as Deferred from "effect/Deferred";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Stream from "effect/Stream";
20
21import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
22import * as ServerSettings from "../../serverSettings.ts";
23import { HermesDriver } from "../Drivers/HermesDriver.ts";
24import type { ProviderInstance } from "../ProviderDriver.ts";
25import {
26 HermesGatewayBroker,
27 type HermesGatewayTransport,
28} from "../Services/HermesGatewayBroker.ts";
29import { HermesGatewayBrokerLive, makeHermesGatewayBroker } from "./HermesGatewayBroker.ts";
30
31const instanceId = ProviderInstanceId.make("hermes_remote");
32const otherInstanceId = ProviderInstanceId.make("hermes_other");
33class HermesTestInstance extends Context.Service<HermesTestInstance, ProviderInstance>()(
34 "t3/provider/Layers/HermesGatewayBroker.test/HermesTestInstance",
35) {}
36
37const 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
46const 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
58const 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
72const 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
85it.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
209it.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
252it.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
285it.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
334it.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
355it.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
438it.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
524it.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 @@
1import * as NodeCrypto from "node:crypto";
2import {
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";
19import * as Crypto from "effect/Crypto";
20import * as Clock from "effect/Clock";
21import * as DateTime from "effect/DateTime";
22import * as Deferred from "effect/Deferred";
23import * as Duration from "effect/Duration";
24import * as Effect from "effect/Effect";
25import * as Encoding from "effect/Encoding";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as PubSub from "effect/PubSub";
29import * as Ref from "effect/Ref";
30import * as Schema from "effect/Schema";
31import * as Semaphore from "effect/Semaphore";
32import * as Stream from "effect/Stream";
33
34import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
35import { ServerSettingsService } from "../../serverSettings.ts";
36import { ProviderAdapterRequestError } from "../Errors.ts";
37import {
38 HermesGatewayBroker,
39 type HermesGatewayConnectionRegistration,
40 type HermesGatewayEnvelope,
41 type HermesGatewayTransport,
42 type HermesGatewayBrokerShape,
43} from "../Services/HermesGatewayBroker.ts";
44
45const ENROLLMENT_TTL = Duration.minutes(10);
46const REQUEST_TIMEOUT = Duration.seconds(30);
47const textEncoder = new TextEncoder();
48const textDecoder = new TextDecoder();
49const isStrictCapabilities = Schema.is(HermesGatewayCapabilities);
50
51interface PendingEnrollment {
52 readonly input: HermesGatewayCreateEnrollmentInput;
53 readonly expiresAtMillis: number;
54}
55
56interface 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
69interface 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
79interface 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
92type PluginMessage = Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>;
93
94const credentialSecretName = (instanceId: ProviderInstanceId) =>
95 `hermes-gateway-credential-${Buffer.from(instanceId, "utf8").toString("base64url")}`;
96
97const metadataSecretName = (instanceId: ProviderInstanceId) =>
98 `hermes-gateway-metadata-${Buffer.from(instanceId, "utf8").toString("base64url")}`;
99
100const 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
113const 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
125const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
126
127const credentialsEqual = (left: Uint8Array, right: string) => {
128 const rightBytes = textEncoder.encode(right);
129 return left.byteLength === rightBytes.byteLength && NodeCrypto.timingSafeEqual(left, rightBytes);
130};
131
132const 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
160export 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
892export const HermesGatewayBrokerLive = Layer.effect(HermesGatewayBroker, makeHermesGatewayBroker);
apps/server/src/provider/Services/HermesGatewayBroker.ts+101−0
@@ -0,0 +1,101 @@
1import type {
2 HermesGatewayConnectionHello,
3 HermesGatewayCreateEnrollmentInput,
4 HermesGatewayEnrollmentResult,
5 HermesGatewayInstanceStatus,
6 HermesGatewayPluginToT3Message,
7 HermesGatewayRevokeInstanceResult,
8 HermesGatewayT3ToPluginMessage,
9 ProviderInstanceId,
10} from "@t3tools/contracts";
11import * as Context from "effect/Context";
12import * as Effect from "effect/Effect";
13import * as Stream from "effect/Stream";
14
15import type { HermesGatewayManagementError } from "@t3tools/contracts";
16import type { ProviderAdapterRequestError } from "../Errors.ts";
17
18export 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
25export interface HermesGatewayConnectionRegistration {
26 readonly instanceId: ProviderInstanceId;
27 readonly generation: number;
28 readonly accepted: Extract<
29 HermesGatewayT3ToPluginMessage,
30 { readonly type: "connection.accepted" }
31 >;
32}
33
34export interface HermesGatewayEnvelope {
35 readonly instanceId: ProviderInstanceId;
36 readonly message: Exclude<HermesGatewayPluginToT3Message, HermesGatewayConnectionHello>;
37}
38
39export 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
81const unavailable = () => Effect.die(new Error("HermesGatewayBroker live layer is not installed"));
82
83export 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
24import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";24import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts";
25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";25import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts";
26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";26import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts";
27import { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts";
27import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";28import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts";
28import type { AnyProviderDriver } from "./ProviderDriver.ts";29import type { AnyProviderDriver } from "./ProviderDriver.ts";
29 30
@@ -37,6 +38,7 @@ export type BuiltInDriversEnv = · 7 unmodified lines
37 | CodexDriverEnv38 | CodexDriverEnv
38 | CursorDriverEnv39 | CursorDriverEnv
39 | GrokDriverEnv40 | GrokDriverEnv
41 | HermesDriverEnv
40 | OpenCodeDriverEnv;42 | OpenCodeDriverEnv;
41 43
42/**44/**
@@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray<AnyProviderDriver<BuiltInDriversEnv · 7 unmodified lines
50 CursorDriver,52 CursorDriver,
51 GrokDriver,53 GrokDriver,
52 OpenCodeDriver,54 OpenCodeDriver,
55 HermesDriver,
53];56];
apps/server/src/provider/hermesGatewayHttp.ts+116−0
@@ -0,0 +1,116 @@
1import {
2 HermesGatewayConnectionHello,
3 HermesGatewayPluginToT3Message,
4 HermesGatewayT3ToPluginMessage,
5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import * as Option from "effect/Option";
9import * as Ref from "effect/Ref";
10import * as Schema from "effect/Schema";
11import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
12import * as Socket from "effect/unstable/socket/Socket";
13
14import {
15 HermesGatewayBroker,
16 type HermesGatewayConnectionRegistration,
17} from "./Services/HermesGatewayBroker.ts";
18import { ProviderAdapterRequestError } from "./Errors.ts";
19
20export const HERMES_GATEWAY_WEBSOCKET_PATH = "/api/hermes-gateway/ws";
21
22const decodePluginFrame = Schema.decodeUnknownEffect(
23 Schema.fromJsonString(HermesGatewayPluginToT3Message),
24);
25const encodeServerFrame = Schema.encodeSync(Schema.fromJsonString(HermesGatewayT3ToPluginMessage));
26const isConnectionHello = Schema.is(HermesGatewayConnectionHello);
27
28export 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
21import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";21import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts";
22import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";22import * as ProviderSessionRuntime from "./persistence/ProviderSessionRuntime.ts";
23import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";23import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry.ts";
24import { HermesGatewayBrokerLive } from "./provider/Layers/HermesGatewayBroker.ts";
25import { hermesGatewayWebSocketRouteLayer } from "./provider/hermesGatewayHttp.ts";
24import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";26import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts";
25import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";27import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts";
26import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";28import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper.ts";
@@ -287,6 +289,10 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( · 260 unmodified lines
287 Layer.provideMerge(OrchestrationLayerLive),289 Layer.provideMerge(OrchestrationLayerLive),
288);290);
289 291
292const HermesProviderRuntimeLive = ProviderInstanceRegistryHydrationLive.pipe(
293 Layer.provideMerge(HermesGatewayBrokerLive),
294);
295
290const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(296const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
291 // Core Services297 // Core Services
292 Layer.provideMerge(CheckpointingLayerLive),298 Layer.provideMerge(CheckpointingLayerLive),
@@ -303,7 +309,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 10 unmodified lines
303 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;309 // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`;
304 // `providerInstances` hydration merges `settings.providers.<kind>`310 // `providerInstances` hydration merges `settings.providers.<kind>`
305 // with explicit `providerInstances` entries on boot.311 // with explicit `providerInstances` entries on boot.
306 Layer.provideMerge(ProviderInstanceRegistryHydrationLive),312 Layer.provideMerge(HermesProviderRuntimeLive),
307 // Shared native/canonical NDJSON writers used by both the per-instance313 // Shared native/canonical NDJSON writers used by both the per-instance
308 // drivers (native stream, written from inside each `<X>Adapter`) and314 // drivers (native stream, written from inside each `<X>Adapter`) and
309 // `ProviderService` (canonical stream, written after event normalization).315 // `ProviderService` (canonical stream, written after event normalization).
@@ -362,6 +368,7 @@ export const makeRoutesLayer = Layer.mergeAll( · 52 unmodified lines
362 assetRouteLayer,368 assetRouteLayer,
363 staticAndDevRouteLayer,369 staticAndDevRouteLayer,
364 websocketRpcRouteLayer,370 websocketRpcRouteLayer,
371 hermesGatewayWebSocketRouteLayer,
365 ),372 ),
366 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),373 McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)),
367).pipe(374).pipe(
apps/server/src/ws.ts+30−0
@@ -78,6 +78,7 @@ import { · 77 unmodified lines
78} from "./observability/RpcInstrumentation.ts";78} from "./observability/RpcInstrumentation.ts";
79import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";79import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts";
80import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";80import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts";
81import { HermesGatewayBroker } from "./provider/Services/HermesGatewayBroker.ts";
81import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";82import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
82import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";83import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
83import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";84import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
@@ -307,6 +308,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ · 223 unmodified lines
307 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],308 [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope],
308 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],309 [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope],
309 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],310 [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope],
311 [WS_METHODS.hermesGatewayCreateEnrollment, AuthOrchestrationOperateScope],
312 [WS_METHODS.hermesGatewayGetInstanceStatus, AuthOrchestrationReadScope],
313 [WS_METHODS.hermesGatewayListInstances, AuthOrchestrationReadScope],
314 [WS_METHODS.hermesGatewayRevokeInstance, AuthOrchestrationOperateScope],
310 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],315 [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope],
311 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],316 [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope],
312 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],317 [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope],
@@ -420,6 +425,7 @@ const makeWsRpcLayer = ( · 107 unmodified lines
420 const portDiscovery = yield* PortScanner.PortDiscovery;425 const portDiscovery = yield* PortScanner.PortDiscovery;
421 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;426 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
422 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;427 const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner;
428 const hermesGatewayBroker = yield* HermesGatewayBroker;
423 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;429 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
424 const config = yield* ServerConfig.ServerConfig;430 const config = yield* ServerConfig.ServerConfig;
425 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;431 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
@@ -1521,6 +1527,30 @@ const makeWsRpcLayer = ( · 1095 unmodified lines
1521 "rpc.aggregate": "server",1527 "rpc.aggregate": "server",
1522 },1528 },
1523 ),1529 ),
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 ),
1524 [WS_METHODS.serverDiscoverSourceControl]: (_input) =>1554 [WS_METHODS.serverDiscoverSourceControl]: (_input) =>
1525 observeRpcEffect(1555 observeRpcEffect(
1526 WS_METHODS.serverDiscoverSourceControl,1556 WS_METHODS.serverDiscoverSourceControl,
apps/web/src/components/HermesIcon.tsx+18−0
@@ -0,0 +1,18 @@
1import 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 */
7export 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
75import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";75import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
76import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";76import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
77import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";77import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
78import { ComposerPrimaryActions } from "./ComposerPrimaryActions";78import {
79 ComposerPrimaryActions,
80 shouldUseCollapsedHermesRunningActions,
81} from "./ComposerPrimaryActions";
79import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";82import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel";
80import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";83import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
81import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";84import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
@@ -171,9 +174,10 @@ import { getProviderDisplayName, getProviderInteractionModeToggle } from "../../ · 89 unmodified lines
171import {174import {
172 applyProviderInstanceSettings,175 applyProviderInstanceSettings,
173 deriveProviderInstanceEntries,176 deriveProviderInstanceEntries,
174 NO_PROVIDER_MODEL_SELECTION,177 getProviderInstanceComposerUnavailableMessage,
178 isProviderInstanceComposerAvailable,
179 resolveComposerProviderInstanceId,
175 resolveProviderDriverKindForInstanceSelection,180 resolveProviderDriverKindForInstanceSelection,
176 resolveSelectableProviderInstanceEntry,
177 sortProviderInstanceEntries,181 sortProviderInstanceEntries,
178 type ProviderInstanceEntry,182 type ProviderInstanceEntry,
179} from "../../providerInstances";183} from "../../providerInstances";
@@ -412,6 +416,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 232 unmodified lines
412 isComplete: boolean;416 isComplete: boolean;
413 } | null;417 } | null;
414 isRunning: boolean;418 isRunning: boolean;
419 allowSendWhileRunning: boolean;
415 showPlanFollowUpPrompt: boolean;420 showPlanFollowUpPrompt: boolean;
416 promptHasText: boolean;421 promptHasText: boolean;
417 isSendBusy: boolean;422 isSendBusy: boolean;
@@ -438,6 +443,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( · 20 unmodified lines
438 compact={props.compact}443 compact={props.compact}
439 pendingAction={props.pendingAction}444 pendingAction={props.pendingAction}
440 isRunning={props.isRunning}445 isRunning={props.isRunning}
446 allowSendWhileRunning={props.allowSendWhileRunning}
441 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}447 showPlanFollowUpPrompt={props.showPlanFollowUpPrompt}
442 promptHasText={props.promptHasText}448 promptHasText={props.promptHasText}
443 isSendBusy={props.isSendBusy}449 isSendBusy={props.isSendBusy}
@@ -776,52 +782,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 332 unmodified lines
776 782
777 // Resolve which configured instance the composer is currently targeting.783 // Resolve which configured instance the composer is currently targeting.
778 // Priority:784 // Priority:
779 // 1. The composer draft's `activeProvider` — the user's unsaved pick785 // 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
780 // from the model picker (must win, otherwise the UI appears to789 // from the model picker (must win, otherwise the UI appears to
781 // ignore picker selections).790 // ignore picker selections).
782 // 2. Thread's persisted instance id (server-side saved selection).791 // 3. Thread's persisted instance id (server-side saved selection).
783 // 3. Project default's instance id.792 // 4. Project default's instance id.
784 // 4. First enabled entry matching the current driver kind.793 // 5. First enabled entry matching the current driver kind.
785 // 5. First enabled entry overall / default instance for the kind.794 // 6. First enabled entry overall / default instance for the kind.
786 //795 //
787 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {796 const selectedInstanceId = useMemo<ProviderInstanceId>(() => {
788 const candidates: Array<string | null | undefined> = [797 return resolveComposerProviderInstanceId({
789 composerDraft.activeProvider,798 entries: providerInstanceEntries,
790 activeThread?.session?.providerInstanceId,799 draftInstanceId: composerDraft.activeProvider ?? null,
791 activeThreadModelSelection?.instanceId,800 threadInstanceId: activeThread?.session?.providerInstanceId,
792 activeProjectDefaultModelSelection?.instanceId,801 threadModelInstanceId: activeThreadModelSelection?.instanceId,
793 ];802 projectInstanceId: activeProjectDefaultModelSelection?.instanceId,
794 for (const candidate of candidates) {803 requestedDriverKind,
795 if (!candidate) continue;804 lockedProvider,
796 const match = providerInstanceEntries.find(805 lockedContinuationGroupKey,
797 (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable,806 });
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 );
825 }, [807 }, [
826 activeProjectDefaultModelSelection?.instanceId,808 activeProjectDefaultModelSelection?.instanceId,
827 activeThread?.session?.providerInstanceId,809 activeThread?.session?.providerInstanceId,
@@ -840,12 +822,28 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 12 unmodified lines
840 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),822 () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId),
841 [providerInstanceEntries, selectedInstanceId],823 [providerInstanceEntries, selectedInstanceId],
842 );824 );
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 );
844 // The driver kind follows the instance that will actually run the turn,841 // The driver kind follows the instance that will actually run the turn,
845 // which can differ from the persisted selection when that selection is842 // which can differ from the persisted selection when that selection is
846 // disabled.843 // disabled.
847 const selectedProvider: ProviderDriverKind =844 const selectedProvider: ProviderDriverKind =
848 selectedProviderEntry?.driverKind ?? requestedDriverKind;845 selectedProviderEntry?.driverKind ?? requestedDriverKind;
846 const allowHermesSteering = phase === "running" && selectedProvider === "hermes";
849 847
850 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({848 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
851 threadRef: composerDraftTarget,849 threadRef: composerDraftTarget,
@@ -1216,7 +1214,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 364 unmodified lines
1216 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],1214 [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
1217 );1215 );
1218 const collapsedComposerPrimaryActionDisabled =1216 const collapsedComposerPrimaryActionDisabled =
1219 phase === "running" ||1217 (phase === "running" && !allowHermesSteering) ||
1220 isSendBusy ||1218 isSendBusy ||
1221 isConnecting ||1219 isConnecting ||
1222 noProviderAvailable ||1220 noProviderAvailable ||
@@ -1765,7 +1763,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 542 unmodified lines
1765 isConnecting ||1763 isConnecting ||
1766 noProviderAvailable ||1764 noProviderAvailable ||
1767 environmentUnavailable !== null ||1765 environmentUnavailable !== null ||
1768 phase === "running"1766 (phase === "running" && !allowHermesSteering)
1769 ) {1767 ) {
1770 return false;1768 return false;
1771 }1769 }
@@ -1783,6 +1781,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 11 unmodified lines
1783 isSendBusy,1781 isSendBusy,
1784 noProviderAvailable,1782 noProviderAvailable,
1785 phase,1783 phase,
1784 allowHermesSteering,
1786 showPlanFollowUpPrompt,1785 showPlanFollowUpPrompt,
1787 ]);1786 ]);
1788 1787
@@ -2368,29 +2367,56 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 579 unmodified lines
2368 ? activePendingProgress.customAnswer ||2367 ? activePendingProgress.customAnswer ||
2369 "Type your own answer, or leave this blank to use the selected option"2368 "Type your own answer, or leave this blank to use the selected option"
2370 : prompt.trim() ||2369 : prompt.trim() ||
2371 (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")}2370 (noProviderAvailable ? providerUnavailableMessage : "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>
2393 </button>2371 </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 )}
2394 </div>2420 </div>
2395 ) : null}2421 ) : null}
2396 2422
@@ -2575,7 +2601,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 178 unmodified lines
2575 : projectSelectionRequired2601 : projectSelectionRequired
2576 ? "Choose a project above to start a thread"2602 ? "Choose a project above to start a thread"
2577 : noProviderAvailable2603 : noProviderAvailable
2578 ? "Enable a provider in Settings to send a message"2604 ? providerUnavailableMessage
2579 : phase === "disconnected"2605 : phase === "disconnected"
2580 ? "Ask for follow-up changes or attach images"2606 ? "Ask for follow-up changes or attach images"
2581 : "Ask anything, @tag files/folders, $use skills, or / for commands"2607 : "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
2643 className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3"2669 className="shrink-0 gap-2 px-2 text-muted-foreground/70 sm:px-3"
2644 >2670 >
2645 <CircleAlertIcon className="size-4" />2671 <CircleAlertIcon className="size-4" />
2646 No provider available2672 {providerUnavailableMessage}
2647 </Button>2673 </Button>
2648 ) : (2674 ) : (
2649 <ProviderModelPicker2675 <ProviderModelPicker
@@ -2721,6 +2747,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) · 71 unmodified lines
2721 activeThreadProviderDisplayName={activeThreadProviderDisplayName}2747 activeThreadProviderDisplayName={activeThreadProviderDisplayName}
2722 pendingAction={pendingPrimaryAction}2748 pendingAction={pendingPrimaryAction}
2723 isRunning={phase === "running"}2749 isRunning={phase === "running"}
2750 allowSendWhileRunning={allowHermesSteering}
2724 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}2751 showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt}
2725 promptHasText={prompt.trim().length > 0}2752 promptHasText={prompt.trim().length > 0}
2726 isSendBusy={isSendBusy}2753 isSendBusy={isSendBusy}
apps/web/src/components/chat/ComposerPrimaryActions.test.ts+40−1
@@ -1,6 +1,45 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import { formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";3import {
4 formatPendingPrimaryActionLabel,
5 resolveRunningPrimaryActionMode,
6 shouldUseCollapsedHermesRunningActions,
7} from "./ComposerPrimaryActions";
8
9describe("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});
4 43
5describe("formatPendingPrimaryActionLabel", () => {44describe("formatPendingPrimaryActionLabel", () => {
6 it("returns 'Submitting...' while responding", () => {45 it("returns 'Submitting...' while responding", () => {
apps/web/src/components/chat/ComposerPrimaryActions.tsx+54−1
@@ -18,6 +18,7 @@ interface ComposerPrimaryActionsProps { · 17 unmodified lines
18 compact: boolean;18 compact: boolean;
19 pendingAction: PendingActionState | null;19 pendingAction: PendingActionState | null;
20 isRunning: boolean;20 isRunning: boolean;
21 allowSendWhileRunning?: boolean;
21 showPlanFollowUpPrompt: boolean;22 showPlanFollowUpPrompt: boolean;
22 promptHasText: boolean;23 promptHasText: boolean;
23 isSendBusy: boolean;24 isSendBusy: boolean;
@@ -49,6 +50,21 @@ export const formatPendingPrimaryActionLabel = (input: { · 25 unmodified lines
49 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";50 return input.questionIndex > 0 ? "Submit answers" : "Submit answer";
50};51};
51 52
53export 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
61export function shouldUseCollapsedHermesRunningActions(input: {
62 readonly isRunning: boolean;
63 readonly allowSendWhileRunning: boolean;
64}): boolean {
65 return resolveRunningPrimaryActionMode(input) === "steer";
66}
67
52const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {68const preventPointerFocus: PointerEventHandler<HTMLElement> = (event) => {
53 event.preventDefault();69 event.preventDefault();
54};70};
@@ -57,6 +73,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 2 unmodified lines
57 compact,73 compact,
58 pendingAction,74 pendingAction,
59 isRunning,75 isRunning,
76 allowSendWhileRunning = false,
60 showPlanFollowUpPrompt,77 showPlanFollowUpPrompt,
61 promptHasText,78 promptHasText,
62 isSendBusy,79 isSendBusy,
@@ -125,7 +142,8 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 62 unmodified lines
125 );142 );
126 }143 }
127 144
128 if (isRunning) {145 const runningMode = resolveRunningPrimaryActionMode({ isRunning, allowSendWhileRunning });
146 if (runningMode === "stop") {
129 return (147 return (
130 <button148 <button
131 type="button"149 type="button"
@@ -141,6 +159,41 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ · 9 unmodified lines
141 );159 );
142 }160 }
143 161
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
144 if (showPlanFollowUpPrompt) {197 if (showPlanFollowUpPrompt) {
145 if (promptHasText) {198 if (promptHasText) {
146 return (199 return (
apps/web/src/components/chat/providerIconUtils.ts+2−0
@@ -1,6 +1,7 @@
1import { ProviderDriverKind } from "@t3tools/contracts";1import { ProviderDriverKind } from "@t3tools/contracts";
2import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";2import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons";
3import { PROVIDER_OPTIONS } from "../../session-logic";3import { PROVIDER_OPTIONS } from "../../session-logic";
4import { HermesIcon } from "../HermesIcon";
4 5
5export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {6export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = {
6 [ProviderDriverKind.make("codex")]: OpenAI,7 [ProviderDriverKind.make("codex")]: OpenAI,
@@ -8,6 +9,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon> · 1 unmodified line
8 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,9 [ProviderDriverKind.make("opencode")]: OpenCodeIcon,
9 [ProviderDriverKind.make("cursor")]: CursorIcon,10 [ProviderDriverKind.make("cursor")]: CursorIcon,
10 [ProviderDriverKind.make("grok")]: GrokIcon,11 [ProviderDriverKind.make("grok")]: GrokIcon,
12 [ProviderDriverKind.make("hermes")]: HermesIcon,
11};13};
12 14
13function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {15function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is {
apps/web/src/components/settings/AddProviderInstanceDialog.logic.ts+39−0
@@ -1,11 +1,50 @@
1import type { ProviderDriverKind } from "@t3tools/contracts";
2
1export type WizardNavigation =3export type WizardNavigation =
2 | { readonly kind: "navigate"; readonly step: number }4 | { readonly kind: "navigate"; readonly step: number }
3 | { readonly kind: "blocked"; readonly step: number; readonly error: string };5 | { readonly kind: "blocked"; readonly step: number; readonly error: string };
4 6
5const IDENTITY_STEP = 1;7const IDENTITY_STEP = 1;
8const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
6 9
7export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const;10export const ADD_PROVIDER_WIZARD_STEPS = ["Driver", "Identity", "Config"] as const;
8 11
12export 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
24export 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
9/**48/**
10 * Resolve navigation within the add-provider wizard.49 * Resolve navigation within the add-provider wizard.
11 *50 *
apps/web/src/components/settings/AddProviderInstanceDialog.test.ts+56−1
@@ -1,6 +1,12 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2 2
3import { resolveWizardNavigation } from "./AddProviderInstanceDialog.logic";3import { ProviderDriverKind } from "@t3tools/contracts";
4
5import {
6 isOwnedHermesEnrollmentRetry,
7 resolveWizardNavigation,
8 validateProviderInstanceIdForWizard,
9} from "./AddProviderInstanceDialog.logic";
4 10
5describe("resolveWizardNavigation", () => {11describe("resolveWizardNavigation", () => {
6 const invalidId = { instanceIdError: "Instance ID is required." };12 const invalidId = { instanceIdError: "Instance ID is required." };
@@ -42,3 +48,52 @@ describe("resolveWizardNavigation", () => { · 35 unmodified lines
42 expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });48 expect(resolveWizardNavigation(0, -1, 3, invalidId)).toEqual({ kind: "navigate", step: 0 });
43 });49 });
44});50});
51
52describe("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 @@
1"use client";1"use client";
2 2
3import { Radio as RadioPrimitive } from "@base-ui/react/radio";3import { Radio as RadioPrimitive } from "@base-ui/react/radio";
4import { CheckIcon } from "lucide-react";4import { CheckIcon, CopyIcon, LoaderIcon } from "lucide-react";
5import { useMemo, useState } from "react";5import { useEffect, useMemo, useState } from "react";
6import {6import {
7 type HermesGatewayEnrollmentResult,
7 ProviderInstanceId,8 ProviderInstanceId,
8 ProviderDriverKind,9 ProviderDriverKind,
9 type ProviderInstanceConfig,10 type ProviderInstanceConfig,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
12import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
11 13
14import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
12import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";15import { usePrimarySettings, useUpdatePrimarySettings } from "../../hooks/useSettings";
13import { cn } from "../../lib/utils";16import { cn } from "../../lib/utils";
14import { normalizeProviderAccentColor } from "../../providerInstances";17import { normalizeProviderAccentColor } from "../../providerInstances";
@@ -31,10 +34,20 @@ import { ProviderSettingsForm, deriveProviderSettingsFields } from "./ProviderSe · 16 unmodified lines
31import { AnimatedHeight } from "../AnimatedHeight";34import { AnimatedHeight } from "../AnimatedHeight";
32import {35import {
33 ADD_PROVIDER_WIZARD_STEPS,36 ADD_PROVIDER_WIZARD_STEPS,
37 isOwnedHermesEnrollmentRetry,
34 resolveWizardNavigation,38 resolveWizardNavigation,
35 type WizardNavigation,39 type WizardNavigation,
40 validateProviderInstanceIdForWizard,
36} from "./AddProviderInstanceDialog.logic";41} from "./AddProviderInstanceDialog.logic";
37import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";42import { AddProviderInstanceWizardSteps } from "./AddProviderInstanceWizardSteps";
43import { usePrimaryEnvironment } from "../../state/environments";
44import { serverEnvironment } from "../../state/server";
45import { useAtomCommand } from "../../state/use-atom-command";
46import {
47 defaultHermesConnectorUrl,
48 formatHermesLastConnected,
49 messageFromUnknownError,
50} from "./HermesGatewayInstanceSection.logic";
38 51
39const PROVIDER_ACCENT_SWATCHES = [52const PROVIDER_ACCENT_SWATCHES = [
40 "#2563eb",53 "#2563eb",
@@ -63,10 +76,12 @@ function slugifyLabel(value: string): string { · 22 unmodified lines
63 76
64function deriveInstanceId(driver: ProviderDriverKind, label: string): string {77function deriveInstanceId(driver: ProviderDriverKind, label: string): string {
65 const slug = slugifyLabel(label);78 const slug = slugifyLabel(label);
79 if (driver === "hermes") {
80 return slug ? `hermes-${slug.replaceAll("_", "-")}` : "";
81 }
66 return slug ? `${driver}_${slug}` : "";82 return slug ? `${driver}_${slug}` : "";
67}83}
68 84
69const INSTANCE_ID_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
70const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");85const DEFAULT_DRIVER_KIND = ProviderDriverKind.make("codex");
71const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;86const DEFAULT_DRIVER_OPTION = DRIVER_OPTIONS[0]!;
72const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};87const EMPTY_CONFIG_DRAFT: Record<string, unknown> = {};
@@ -99,21 +114,6 @@ const COMING_SOON_DRIVER_OPTIONS: readonly ComingSoonDriverOption[] = [ · 26 unmodified lines
99 },114 },
100];115];
101 116
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 */
107function 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
117interface AddProviderInstanceDialogProps {117interface AddProviderInstanceDialogProps {
118 open: boolean;118 open: boolean;
119 onOpenChange: (open: boolean) => void;119 onOpenChange: (open: boolean) => void;
@@ -122,6 +122,13 @@ interface AddProviderInstanceDialogProps { · 2 unmodified lines
122export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {122export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderInstanceDialogProps) {
123 const settings = usePrimarySettings();123 const settings = usePrimarySettings();
124 const updateSettings = useUpdatePrimarySettings();124 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 });
125 132
126 const [wizardStep, setWizardStep] = useState(0);133 const [wizardStep, setWizardStep] = useState(0);
127 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);134 const [driver, setDriver] = useState<ProviderDriverKind>(DEFAULT_DRIVER_KIND);
@@ -134,6 +141,37 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines
134 // Errors are suppressed until the user has tried to submit once. After that141 // Errors are suppressed until the user has tried to submit once. After that
135 // they update live so fixing the problem clears the message in place.142 // they update live so fixing the problem clears the message in place.
136 const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false);143 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]);
137 175
138 const existingIds = useMemo(176 const existingIds = useMemo(
139 () => new Set(Object.keys(settings.providerInstances ?? {})),177 () => new Set(Object.keys(settings.providerInstances ?? {})),
@@ -146,7 +184,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 6 unmodified lines
146 () => deriveProviderSettingsFields(driverOption),184 () => deriveProviderSettingsFields(driverOption),
147 [driverOption],185 [driverOption],
148 );186 );
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 });
150 const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;196 const showInstanceIdError = hasAttemptedSubmit && instanceIdError !== null;
151 const previewLabel = label.trim() || `${driverOption.label} Workspace`;197 const previewLabel = label.trim() || `${driverOption.label} Workspace`;
152 const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;198 const wizardStepSummaries = [driverOption.label, previewLabel, null] as const;
@@ -179,7 +225,7 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 26 unmodified lines
179 );225 );
180 };226 };
181 227
182 const handleSave = () => {228 const handleSave = async () => {
183 setHasAttemptedSubmit(true);229 setHasAttemptedSubmit(true);
184 if (instanceIdError !== null) return;230 if (instanceIdError !== null) return;
185 231
@@ -195,15 +241,60 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 9 unmodified lines
195 ...(hasConfig ? { config } : {}),241 ...(hasConfig ? { config } : {}),
196 };242 };
197 // `ProviderInstanceId.make` revalidates the slug; we've already checked243 // `ProviderInstanceId.make` revalidates the slug; we've already checked
198 // it via `validateInstanceId`, but going through the brand constructor244 // it via `validateProviderInstanceIdForWizard`, but going through the brand constructor
199 // keeps the type boundary honest and guards against any future drift in245 // keeps the type boundary honest and guards against any future drift in
200 // the slug rules.246 // the slug rules.
201 const brandedId = ProviderInstanceId.make(instanceId);247 const brandedId = ProviderInstanceId.make(instanceId);
248 const isOwnedHermesRetry = isOwnedHermesEnrollmentRetry({
249 driver,
250 instanceId,
251 createdHermesInstanceId: createdHermesIdentity?.instanceId ?? null,
252 });
202 const nextMap = {253 const nextMap = {
203 ...settings.providerInstances,254 ...settings.providerInstances,
204 [brandedId]: nextInstance,255 [brandedId]: nextInstance,
205 };256 };
206 try {257 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
207 updateSettings({ providerInstances: nextMap });298 updateSettings({ providerInstances: nextMap });
208 toastManager.add({299 toastManager.add({
209 type: "success",300 type: "success",
@@ -212,11 +303,15 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 2 unmodified lines
212 });303 });
213 onOpenChange(false);304 onOpenChange(false);
214 } catch (error) {305 } catch (error) {
306 const message = messageFromUnknownError(error);
307 setSaveError(message);
215 toastManager.add({308 toastManager.add({
216 type: "error",309 type: "error",
217 title: "Could not add provider instance",310 title: "Could not add provider instance",
218 description: error instanceof Error ? error.message : "Update failed.",311 description: message,
219 });312 });
313 } finally {
314 setIsSaving(false);
220 }315 }
221 };316 };
222 317
@@ -230,203 +325,280 @@ export function AddProviderInstanceDialog({ open, onOpenChange }: AddProviderIns · 7 unmodified lines
230 Configure an additional provider instance — for example, a second Codex install325 Configure an additional provider instance — for example, a second Codex install
231 pointed at a different workspace.326 pointed at a different workspace.
232 </DialogDescription>327 </DialogDescription>
233 <AddProviderInstanceWizardSteps328 {hermesEnrollment === null && createdHermesIdentity === null ? (
234 currentStep={wizardStep}329 <AddProviderInstanceWizardSteps
235 summaries={wizardStepSummaries}330 currentStep={wizardStep}
236 instanceIdError={instanceIdError}331 summaries={wizardStepSummaries}
237 onNavigation={applyWizardNavigation}332 instanceIdError={instanceIdError}
238 />333 onNavigation={applyWizardNavigation}
334 />
335 ) : null}
239 </DialogHeader>336 </DialogHeader>
240 337
241 <div338 <div
242 data-slot="dialog-panel"339 data-slot="dialog-panel"
243 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"340 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"
244 >341 >
245 <AnimatedHeight>342 {hermesEnrollment ? (
246 <div className={cn("grid gap-2", wizardStep !== 0 && "hidden")}>343 <div className="grid gap-3">
247 <div id="add-instance-driver-label" className="text-sm font-medium text-foreground">344 <div>
248 Driver345 <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>
249 </div>350 </div>
250 <RadioGroup351 <div className="flex min-w-0 items-center gap-2">
251 value={driver}352 <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded bg-muted px-2 py-2 text-[11px]">
252 onValueChange={(value) => setDriver(ProviderDriverKind.make(value))}353 {hermesEnrollment.command}
253 aria-labelledby="add-instance-driver-label"354 </code>
254 className="grid grid-cols-1 gap-2 sm:grid-cols-2"355 <Button
255 >356 type="button"
256 {DRIVER_OPTIONS.map((option) => {357 size="icon-sm"
257 const IconComponent = option.icon;358 variant="outline"
258 return (359 onClick={() => copyToClipboard(hermesEnrollment.command, undefined)}
259 <RadioPrimitive.Root360 aria-label="Copy Hermes enrollment command"
260 key={option.value}361 >
261 value={option.value}362 {isCopied ? <CheckIcon /> : <CopyIcon />}
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"363 </Button>
263 >364 </div>
264 <IconComponent className="size-4 shrink-0" aria-hidden />365 <p className="text-[11px] text-muted-foreground">
265 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">366 The persistent gateway credential is delivered directly to the plugin and is never
266 {option.label}367 shown in T3 Code.
267 </span>368 </p>
268 <RadioPrimitive.Indicator369 </div>
269 className="grid size-5 shrink-0 place-items-center rounded-full bg-primary text-primary-foreground"370 ) : (
270 aria-hidden371 <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"
271 >392 >
272 <CheckIcon className="size-3.5 shrink-0" />393 <IconComponent className="size-4 shrink-0" aria-hidden />
273 </RadioPrimitive.Indicator>394 <span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
274 {option.badgeLabel ? (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>
275 <Badge variant="warning" size="sm">429 <Badge variant="warning" size="sm">
276 {option.badgeLabel}430 Coming Soon
277 </Badge>431 </Badge>
278 ) : null}432 </RadioPrimitive.Root>
279 </RadioPrimitive.Root>433 );
280 );434 })}
281 })}435 </RadioGroup>
282 {COMING_SOON_DRIVER_OPTIONS.map((option) => {436 </div>
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>
321 437
322 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>438 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
323 <span className="text-xs font-medium text-foreground">Instance ID</span>439 <span className="text-xs font-medium text-foreground">Label</span>
324 <Input440 <Input
325 className="bg-background"441 className="bg-background"
326 placeholder={`${driver}_work`}442 placeholder="e.g. Work"
327 value={instanceId}443 value={label}
328 onChange={(event) => {444 onChange={(event) => setLabel(event.target.value)}
329 setInstanceIdOverride(event.target.value);445 />
330 }}
331 aria-invalid={showInstanceIdError}
332 />
333 {showInstanceIdError ? (
334 <span className="text-[11px] text-destructive">{instanceIdError}</span>
335 ) : (
336 <span className="text-[11px] text-muted-foreground">446 <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.
338 </span>448 </span>
339 )}449 </label>
340 </label>
341 450
342 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>451 <label className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
343 <span className="text-xs font-medium text-foreground">Accent color</span>452 <span className="text-xs font-medium text-foreground">Instance ID</span>
344 <div className="flex min-w-0 flex-wrap items-center gap-2">453 <Input
345 <input454 className="bg-background"
346 type="color"455 placeholder={`${driver}_work`}
347 value={normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]}456 value={instanceId}
348 onChange={(event) => setAccentColor(event.target.value)}457 onChange={(event) => {
349 aria-label="Provider instance accent color"458 setInstanceIdOverride(event.target.value);
350 className="h-8 w-10 cursor-pointer rounded-xl border border-input bg-background p-0.5"459 }}
460 aria-invalid={showInstanceIdError}
351 />461 />
352 <div className="flex flex-wrap gap-1.5">462 {showInstanceIdError ? (
353 {PROVIDER_ACCENT_SWATCHES.map((swatch) => {463 <span className="text-[11px] text-destructive">{instanceIdError}</span>
354 const selected = accentColor.toLowerCase() === swatch;464 ) : (
355 return (465 <span className="text-[11px] text-muted-foreground">
356 <button466 Routing key used by threads and sessions. Letters, digits, '-', or '_'.
357 key={swatch}467 </span>
358 type="button"468 )}
359 className={cn(469 </label>
360 "size-6 cursor-pointer rounded-full border transition",470
361 selected471 <div className={cn("grid gap-2", wizardStep !== 1 && "hidden")}>
362 ? "scale-110 border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"472 <span className="text-xs font-medium text-foreground">Accent color</span>
363 : "border-black/10 hover:scale-105 dark:border-white/20",473 <div className="flex min-w-0 flex-wrap items-center gap-2">
364 )}474 <input
365 style={{ backgroundColor: swatch }}475 type="color"
366 onClick={() => setAccentColor(swatch)}476 value={
367 aria-label={`Use ${swatch} accent`}477 normalizeProviderAccentColor(accentColor) ?? PROVIDER_ACCENT_SWATCHES[0]
368 />478 }
369 );479 onChange={(event) => setAccentColor(event.target.value)}
370 })}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}
371 </div>514 </div>
372 {accentColor ? (515 <span className="text-[11px] text-muted-foreground">
373 <Button516 Optional marker shown in the picker.
374 type="button"517 </span>
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}
383 </div>518 </div>
384 <span className="text-[11px] text-muted-foreground">
385 Optional marker shown in the picker.
386 </span>
387 </div>
388 519
389 {driverSettingsFields.length > 0 ? (520 {driver === "hermes" && wizardStep === 2 ? (
390 <div className={cn("grid gap-4", wizardStep !== 2 && "hidden")}>521 <label className="grid gap-2">
391 <ProviderSettingsForm522 <span className="text-xs font-medium text-foreground">Connector URL</span>
392 definition={driverOption}523 <Input
393 value={configDraft}524 className="bg-background"
394 idPrefix={`add-provider-${driver}`}525 value={hermesConnectorUrl}
395 variant="dialog"526 onChange={(event) => setHermesConnectorUrl(event.target.value)}
396 onChange={setConfigDraft}527 placeholder="wss://t3.example.com/api/hermes-gateway/ws"
397 />528 spellCheck={false}
398 </div>529 />
399 ) : wizardStep === 2 ? (530 <span className="text-[11px] text-muted-foreground">
400 <div className="grid gap-2">531 Defaults to this browser&apos;s origin and works over Tailscale or any
401 <p className="text-sm text-muted-foreground">532 reachable network.
402 This driver has no required configuration. You can add the instance now.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.
403 </p>557 </p>
404 </div>558 ) : null}
405 ) : null}559 </AnimatedHeight>
406 </AnimatedHeight>560 )}
407 </div>561 </div>
408 562
409 <DialogFooter variant="bare">563 <DialogFooter variant="bare">
410 <Button564 {hermesEnrollment ? (
411 variant="outline"565 <Button size="sm" onClick={() => onOpenChange(false)}>
412 size="sm"566 Done
413 onClick={() => {567 </Button>
414 if (wizardStep === 0) {568 ) : (
415 onOpenChange(false);569 <Button
416 return;570 variant="outline"
417 }571 size="sm"
418 setWizardStep((step) => Math.max(0, step - 1));572 onClick={() => {
419 }}573 if (createdHermesIdentity) {
420 >574 onOpenChange(false);
421 {wizardStep === 0 ? "Cancel" : "Back"}575 return;
422 </Button>576 }
423 {wizardStep < ADD_PROVIDER_WIZARD_STEPS.length - 1 ? (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 ? (
424 <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}>589 <Button size="sm" onClick={() => navigateToStep(wizardStep + 1)}>
425 Next590 Next
426 </Button>591 </Button>
427 ) : (592 ) : (
428 <Button size="sm" onClick={handleSave}>593 <Button size="sm" disabled={isSaving} onClick={() => void handleSave()}>
429 Add instance594 {isSaving ? <LoaderIcon className="animate-spin" /> : null}
595 {isSaving
596 ? createdHermesIdentity
597 ? "Retrying"
598 : "Adding"
599 : createdHermesIdentity
600 ? "Retry enrollment"
601 : "Add instance"}
430 </Button>602 </Button>
431 )}603 )}
432 </DialogFooter>604 </DialogFooter>
apps/web/src/components/settings/HermesGatewayInstanceSection.logic.test.ts+38−0
@@ -0,0 +1,38 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 defaultHermesConnectorUrl,
5 formatHermesLastConnected,
6 hermesGatewayStatusLabel,
7 messageFromUnknownError,
8 shouldApplyHermesConnectorStatusUrl,
9} from "./HermesGatewayInstanceSection.logic";
10
11describe("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 @@
1import type { HermesGatewayConnectionState } from "@t3tools/contracts";
2
3export const HERMES_GATEWAY_SOCKET_PATH = "/api/hermes-gateway/ws";
4
5export function defaultHermesConnectorUrl(origin: string): string {
6 return new URL(HERMES_GATEWAY_SOCKET_PATH, origin).toString();
7}
8
9export function shouldApplyHermesConnectorStatusUrl(hasLocalEdits: boolean): boolean {
10 return !hasLocalEdits;
11}
12
13export 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
28export 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
34export 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
3import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
4import {
5 type HermesGatewayConnectionState,
6 type HermesGatewayEnrollmentResult,
7 type HermesGatewayInstanceStatus,
8 type ProviderInstanceId,
9} from "@t3tools/contracts";
10import { CheckIcon, CopyIcon, LoaderIcon, RefreshCwIcon, UnplugIcon } from "lucide-react";
11import { useCallback, useEffect, useRef, useState } from "react";
12
13import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
14import { usePrimaryEnvironment } from "../../state/environments";
15import { serverEnvironment } from "../../state/server";
16import { useAtomCommand } from "../../state/use-atom-command";
17import { Badge } from "../ui/badge";
18import { Button } from "../ui/button";
19import { Input } from "../ui/input";
20import { toastManager } from "../ui/toast";
21import {
22 defaultHermesConnectorUrl,
23 formatHermesLastConnected,
24 hermesGatewayStatusLabel,
25 messageFromUnknownError,
26 shouldApplyHermesConnectorStatusUrl,
27} from "./HermesGatewayInstanceSection.logic";
28
29const 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
40function browserDefaultConnectorUrl() {
41 if (typeof window === "undefined") return "http://localhost/api/hermes-gateway/ws";
42 return defaultHermesConnectorUrl(window.location.origin);
43}
44
45export 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 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
2import type { ServerProviderModel } from "@t3tools/contracts";2import type { ServerProviderModel } from "@t3tools/contracts";
3 3
4import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard";4import {
5 deriveProviderModelsForDisplay,
6 isProviderInstanceDisplayNameEditable,
7} from "./ProviderInstanceCard";
5 8
6describe("deriveProviderModelsForDisplay", () => {9describe("deriveProviderModelsForDisplay", () => {
7 it("uses current config custom models instead of stale live custom rows", () => {10 it("uses current config custom models instead of stale live custom rows", () => {
@@ -34,3 +37,11 @@ describe("deriveProviderModelsForDisplay", () => { · 26 unmodified lines
34 ).toEqual(["server-model", "kept-custom"]);37 ).toEqual(["server-model", "kept-custom"]);
35 });38 });
36});39});
40
41describe("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
13import * as Arr from "effect/Array";13import * as Arr from "effect/Array";
14import * as Result from "effect/Result";14import * as Result from "effect/Result";
15import { useState, type ReactNode } from "react";15import { useState, type ReactNode } from "react";
16import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
16import {17import {
17 isProviderDriverKind,18 isProviderDriverKind,
18 type ProviderInstanceConfig,19 type ProviderInstanceConfig,
@@ -26,6 +27,9 @@ import { · 7 unmodified lines
26import { cn } from "../../lib/utils";27import { cn } from "../../lib/utils";
27import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";28import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";
28import { normalizeProviderAccentColor } from "../../providerInstances";29import { normalizeProviderAccentColor } from "../../providerInstances";
30import { usePrimaryEnvironment } from "../../state/environments";
31import { serverEnvironment } from "../../state/server";
32import { useAtomCommand } from "../../state/use-atom-command";
29import { Badge } from "../ui/badge";33import { Badge } from "../ui/badge";
30import { Button } from "../ui/button";34import { Button } from "../ui/button";
31import { Checkbox } from "../ui/checkbox";35import { Checkbox } from "../ui/checkbox";
@@ -43,6 +47,8 @@ import { ProviderModelsSection } from "./ProviderModelsSection"; · 11 unmodified lines
43import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";47import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
44import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";48import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker";
45import { RedactedSensitiveText } from "./RedactedSensitiveText";49import { RedactedSensitiveText } from "./RedactedSensitiveText";
50import { HermesGatewayInstanceSection } from "./HermesGatewayInstanceSection";
51import { messageFromUnknownError } from "./HermesGatewayInstanceSection.logic";
46import {52import {
47 getProviderVersionAdvisoryPresentation,53 getProviderVersionAdvisoryPresentation,
48 PROVIDER_STATUS_STYLES,54 PROVIDER_STATUS_STYLES,
@@ -131,6 +137,10 @@ export function deriveProviderModelsForDisplay(input: { · 82 unmodified lines
131 return [...serverModels, ...customModels];137 return [...serverModels, ...customModels];
132}138}
133 139
140export function isProviderInstanceDisplayNameEditable(driver: string): boolean {
141 return driver !== "hermes";
142}
143
134function ProviderAuthEmail(props: {144function ProviderAuthEmail(props: {
135 readonly email: string | undefined;145 readonly email: string | undefined;
136 readonly prefix?: string;146 readonly prefix?: string;
@@ -394,6 +404,11 @@ export function ProviderInstanceCard({ · 257 unmodified lines
394 onRunUpdate,404 onRunUpdate,
395 isUpdating = false,405 isUpdating = false,
396}: ProviderInstanceCardProps) {406}: ProviderInstanceCardProps) {
407 const environmentId = usePrimaryEnvironment()?.environmentId ?? null;
408 const revokeHermesInstance = useAtomCommand(serverEnvironment.hermesGatewayRevokeInstance, {
409 reportFailure: false,
410 });
411 const [isDeleting, setIsDeleting] = useState(false);
397 const enabled = instance.enabled ?? true;412 const enabled = instance.enabled ?? true;
398 // The server-reported status wins when present; otherwise fall back to413 // The server-reported status wins when present; otherwise fall back to
399 // "disabled"/"warning" based on the local `enabled` flag so the dot414 // "disabled"/"warning" based on the local `enabled` flag so the dot
@@ -413,8 +428,21 @@ export function ProviderInstanceCard({ · 13 unmodified lines
413 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);428 const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory);
414 const updateCommand = versionAdvisory?.updateCommand ?? null;429 const updateCommand = versionAdvisory?.updateCommand ?? null;
415 const FallbackIconComponent = driverOption?.icon;430 const FallbackIconComponent = driverOption?.icon;
416 const displayName =431 const configuredDisplayName = instance.displayName?.trim();
417 instance.displayName?.trim() || driverOption?.label || String(instance.driver);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);
418 const accentColor = normalizeProviderAccentColor(instance.accentColor);446 const accentColor = normalizeProviderAccentColor(instance.accentColor);
419 const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({447 const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({
420 onCopy: ({ providerName }) => {448 onCopy: ({ providerName }) => {
@@ -501,6 +529,49 @@ export function ProviderInstanceCard({ · 80 unmodified lines
501 );529 );
502 };530 };
503 531
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
504 const titleIconNode = driverKind ? (575 const titleIconNode = driverKind ? (
505 <ProviderInstanceIcon576 <ProviderInstanceIcon
506 driverKind={driverKind}577 driverKind={driverKind}
@@ -563,10 +634,15 @@ export function ProviderInstanceCard({ · 56 unmodified lines
563 size="icon-xs"634 size="icon-xs"
564 variant="ghost"635 variant="ghost"
565 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"636 className="size-5 rounded-sm p-0 text-muted-foreground hover:text-destructive"
566 onClick={onDelete}637 disabled={isDeleting}
638 onClick={() => void handleDelete()}
567 aria-label={`Delete provider instance ${instanceId}`}639 aria-label={`Delete provider instance ${instanceId}`}
568 >640 >
569 <Trash2Icon className="size-3" />641 {isDeleting ? (
642 <LoaderIcon className="size-3 animate-spin" />
643 ) : (
644 <Trash2Icon className="size-3" />
645 )}
570 </Button>646 </Button>
571 }647 }
572 />648 />
@@ -730,22 +806,38 @@ export function ProviderInstanceCard({ · 157 unmodified lines
730 <Collapsible open={isExpanded} onOpenChange={onExpandedChange}>806 <Collapsible open={isExpanded} onOpenChange={onExpandedChange}>
731 <CollapsibleContent>807 <CollapsibleContent>
732 <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4">808 <div className="space-y-5 px-3 pb-4 pt-2 sm:px-4">
733 <div>809 {isProviderInstanceDisplayNameEditable(String(instance.driver)) ? (
734 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">810 <div>
735 <span className="text-xs font-medium text-foreground">Display name</span>811 <label htmlFor={`provider-instance-${instanceId}-display-name`} className="block">
736 <DraftInput812 <span className="text-xs font-medium text-foreground">Display name</span>
737 id={`provider-instance-${instanceId}-display-name`}813 <DraftInput
738 className="mt-1.5"814 id={`provider-instance-${instanceId}-display-name`}
739 value={instance.displayName ?? ""}815 className="mt-1.5"
740 onCommit={updateDisplayName}816 value={instance.displayName ?? ""}
741 placeholder={driverOption?.label ?? "Instance label"}817 onCommit={updateDisplayName}
742 spellCheck={false}818 placeholder={driverOption?.label ?? "Instance label"}
743 />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>
744 <span className="mt-1 block text-xs text-muted-foreground">835 <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.
746 </span>838 </span>
747 </label>839 </div>
748 </div>840 )}
749 841
750 <div>842 <div>
751 <ProviderAccentColorPicker843 <ProviderAccentColorPicker
@@ -757,14 +849,18 @@ export function ProviderInstanceCard({ · 5 unmodified lines
757 />849 />
758 </div>850 </div>
759 851
760 <div>852 {isHermes ? (
761 <ProviderEnvironmentSection853 <HermesGatewayInstanceSection instanceId={instanceId} nickname={hermesNickname} />
762 environment={instance.environment ?? []}854 ) : (
763 onChange={updateEnvironment}855 <div>
764 />856 <ProviderEnvironmentSection
765 </div>857 environment={instance.environment ?? []}
858 onChange={updateEnvironment}
859 />
860 </div>
861 )}
766 862
767 {driverOption ? (863 {driverOption && !isHermes ? (
768 <ProviderSettingsForm864 <ProviderSettingsForm
769 definition={driverOption}865 definition={driverOption}
770 value={instance.config}866 value={instance.config}
@@ -774,7 +870,7 @@ export function ProviderInstanceCard({ · 3 unmodified lines
774 />870 />
775 ) : null}871 ) : null}
776 872
777 {driverOption !== undefined ? (873 {driverOption !== undefined && !isHermes ? (
778 <ProviderModelsSection874 <ProviderModelsSection
779 instanceId={instanceId}875 instanceId={instanceId}
780 driverKind={driverKind}876 driverKind={driverKind}
@@ -788,7 +884,7 @@ export function ProviderInstanceCard({ · 7 unmodified lines
788 onFavoriteModelsChange={onFavoriteModelsChange}884 onFavoriteModelsChange={onFavoriteModelsChange}
789 onModelOrderChange={onModelOrderChange}885 onModelOrderChange={onModelOrderChange}
790 />886 />
791 ) : (887 ) : driverOption === undefined ? (
792 <div>888 <div>
793 <p className="text-xs text-muted-foreground">889 <p className="text-xs text-muted-foreground">
794 This instance uses a driver (890 This instance uses a driver (
@@ -797,7 +893,7 @@ export function ProviderInstanceCard({ · 2 unmodified lines
797 edited from this surface.893 edited from this surface.
798 </p>894 </p>
799 </div>895 </div>
800 )}896 ) : null}
801 </div>897 </div>
802 </CollapsibleContent>898 </CollapsibleContent>
803 </Collapsible>899 </Collapsible>
apps/web/src/components/settings/providerDriverMeta.ts+8−0
@@ -3,11 +3,13 @@ import { · 2 unmodified lines
3 CodexSettings,3 CodexSettings,
4 CursorSettings,4 CursorSettings,
5 GrokSettings,5 GrokSettings,
6 HermesSettings,
6 OpenCodeSettings,7 OpenCodeSettings,
7 ProviderDriverKind,8 ProviderDriverKind,
8} from "@t3tools/contracts";9} from "@t3tools/contracts";
9import type * as Schema from "effect/Schema";10import type * as Schema from "effect/Schema";
10import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";11import { ClaudeAI, CursorIcon, GrokIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons";
12import { HermesIcon } from "../HermesIcon";
11 13
12type ProviderSettingsSchema = {14type ProviderSettingsSchema = {
13 readonly fields: Readonly<Record<string, Schema.Top>>;15 readonly fields: Readonly<Record<string, Schema.Top>>;
@@ -67,6 +69,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = · 53 unmodified lines
67 icon: OpenCodeIcon,69 icon: OpenCodeIcon,
68 settingsSchema: OpenCodeSettings,70 settingsSchema: OpenCodeSettings,
69 },71 },
72 {
73 value: ProviderDriverKind.make("hermes"),
74 label: "Hermes",
75 icon: HermesIcon,
76 settingsSchema: HermesSettings,
77 },
70];78];
71 79
72export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<80export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial<
apps/web/src/modelSelection.ts+1−1
@@ -64,7 +64,7 @@ function readInstanceCustomModels( · 63 unmodified lines
64 }64 }
65 const legacyProviders = settings.providers as Record<65 const legacyProviders = settings.providers as Record<
66 string,66 string,
67 { readonly customModels: ReadonlyArray<string> } | undefined67 { readonly customModels?: ReadonlyArray<string> } | undefined
68 >;68 >;
69 return legacyProviders[driverKind]?.customModels ?? [];69 return legacyProviders[driverKind]?.customModels ?? [];
70}70}
apps/web/src/providerInstances.test.ts+89−0
@@ -5,7 +5,10 @@ import { · 4 unmodified lines
5 deriveProviderInstanceEntries,5 deriveProviderInstanceEntries,
6 getDefaultProviderInstanceModel,6 getDefaultProviderInstanceModel,
7 isProviderInstancePickerReady,7 isProviderInstancePickerReady,
8 isProviderInstanceComposerAvailable,
9 getProviderInstanceComposerUnavailableMessage,
8 isProviderInstancePickerVisible,10 isProviderInstancePickerVisible,
11 resolveComposerProviderInstanceId,
9 resolveDefaultProviderModelSelection,12 resolveDefaultProviderModelSelection,
10 resolveSelectableProviderInstance,13 resolveSelectableProviderInstance,
11 resolveProviderDriverKindForInstanceSelection,14 resolveProviderDriverKindForInstanceSelection,
@@ -68,6 +71,66 @@ describe("isProviderInstancePickerReady", () => { · 56 unmodified lines
68 });71 });
69});72});
70 73
74describe("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
71describe("isProviderInstancePickerVisible", () => {134describe("isProviderInstancePickerVisible", () => {
72 it("keeps enabled instances in the rail and removes disabled instances", () => {135 it("keeps enabled instances in the rail and removes disabled instances", () => {
73 const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([136 const [enabledEntry, disabledEntry] = deriveProviderInstanceEntries([
@@ -130,6 +193,32 @@ describe("deriveProviderInstanceEntries", () => { · 56 unmodified lines
130 expect(entry?.driverKind).toBe("codex");193 expect(entry?.driverKind).toBe("codex");
131 expect(entry?.isDefault).toBe(false);194 expect(entry?.isDefault).toBe(false);
132 });195 });
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 });
133});222});
134 223
135describe("resolveSelectableProviderInstance", () => {224describe("resolveSelectableProviderInstance", () => {
apps/web/src/providerInstances.ts+94−0
@@ -74,6 +74,31 @@ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boo · 73 unmodified lines
74 return entry.enabled && entry.isAvailable && entry.status === "ready";74 return entry.enabled && entry.isAvailable && entry.status === "ready";
75}75}
76 76
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 */
82export function isProviderInstanceComposerAvailable(entry: ProviderInstanceEntry): boolean {
83 return entry.driverKind !== "hermes" || isProviderInstancePickerReady(entry);
84}
85
86export 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
77/** Picker rails contain configured, enabled instances only. */102/** Picker rails contain configured, enabled instances only. */
78export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean {103export function isProviderInstancePickerVisible(entry: ProviderInstanceEntry): boolean {
79 return entry.enabled;104 return entry.enabled;
@@ -139,6 +164,15 @@ function resolveInstanceDisplayName( · 59 unmodified lines
139): string {164): string {
140 const trimmedSnapshotName = snapshot.displayName?.trim();165 const trimmedSnapshotName = snapshot.displayName?.trim();
141 const kindLabel = driverKindLabel(driverKind);166 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 }
142 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) {176 if (trimmedSnapshotName && trimmedSnapshotName !== kindLabel) {
143 return trimmedSnapshotName;177 return trimmedSnapshotName;
144 }178 }
@@ -312,6 +346,66 @@ export function resolveSelectableProviderInstanceEntry( · 167 unmodified lines
312 );346 );
313}347}
314 348
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 */
356export 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
315/**409/**
316 * Resolve the routing key for a selection that may reference an instance410 * Resolve the routing key for a selection that may reference an instance
317 * id that no longer exists (e.g. a persisted thread selection after the411 * 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
3The plugin deliberately uses only the public Hermes plugin and platform-adapter
4surfaces 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
3Experimental Hermes platform plugin for connecting one already-running Hermes
4process to T3 Code. The plugin makes an outbound WebSocket connection; Hermes
5does not need to listen on a public port.
6
7Each T3 thread maps deterministically to one Hermes gateway session. A new T3
8thread creates a new session identity, later messages use the same identity,
9active-turn messages use Hermes' native `/steer` path, and stopping a turn does
10not delete its transcript.
11
12## Install from this repository
13
14Copy or symlink this directory into the active Hermes profile:
15
16```bash
17mkdir -p ~/.hermes/plugins
18ln -s /path/to/t3code/integrations/hermes-t3-gateway \
19 ~/.hermes/plugins/hermes-t3-gateway
20hermes plugins enable hermes-t3-gateway
21```
22
23In T3 Code, open the Hermes instance settings, choose **Add Hermes**, enter a
24unique nickname, and copy the generated enrollment command. It has this shape:
25
26```bash
27hermes 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
33normalizes it to `/api/hermes-gateway/ws`, enrolls over the first authenticated
34`connection.hello` frame, and saves these values with Hermes'
35profile-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
42The long-lived credential is never printed. Restart `hermes gateway` after
43enrollment. `hermes t3 status` reports the local enrollment without revealing
44the 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
57Attachments intentionally advertise `false`. Adding bounded image/file input is
58the first post-stability feature and should not reuse arbitrary raw payloads.
59
60See [COMPATIBILITY.md](./COMPATIBILITY.md) for public Hermes extension-surface
61limitations.
62
63## Tests
64
65The pure protocol and transport tests do not require a live Hermes or T3 server:
66
67```bash
68python -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
4from __future__ import annotations
5
6from .adapter import (
7 T3PlatformAdapter,
8 check_requirements,
9 env_enablement,
10 validate_config,
11)
12from .cli import register_cli, t3_command
13
14
15def _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
26def _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
43def 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
3from __future__ import annotations
4
5import asyncio
6import contextvars
7import logging
8import os
9import threading
10import uuid
11import weakref
12from dataclasses import dataclass, field
13from typing import Any
14
15from gateway.config import Platform, PlatformConfig
16from gateway.platforms.base import (
17 BasePlatformAdapter,
18 MessageEvent,
19 MessageType,
20 SendResult,
21)
22from gateway.session import build_session_key
23
24from .cli import CREDENTIAL_ENV, INSTANCE_ID_ENV, NICKNAME_ENV, URL_ENV
25from .connection import T3GatewayConnection, dependency_available
26from .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
36logger = logging.getLogger(__name__)
37
38
39def _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
49class _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
63class _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
75class 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
916def check_requirements() -> bool:
917 return dependency_available()
918
919
920def 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
929def 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
3from __future__ import annotations
4
5import argparse
6import asyncio
7import os
8import sys
9
10from .connection import ConnectionRejected, enroll_once, websocket_url
11
12URL_ENV = "HERMES_T3_GATEWAY_URL"
13INSTANCE_ID_ENV = "HERMES_T3_GATEWAY_INSTANCE_ID"
14CREDENTIAL_ENV = "HERMES_T3_GATEWAY_CREDENTIAL"
15NICKNAME_ENV = "HERMES_T3_GATEWAY_NICKNAME"
16
17
18def _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
27def 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
49def 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
103def _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
118if __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
3from __future__ import annotations
4
5import asyncio
6import json
7import logging
8from collections.abc import Awaitable, Callable
9from contextlib import suppress
10from typing import Any
11from urllib.parse import urlsplit, urlunsplit
12
13from .protocol import WEBSOCKET_PATH, connection_hello
14
15logger = logging.getLogger(__name__)
16
17try:
18 import websockets
19except ImportError: # pragma: no cover - Hermes currently installs websockets
20 websockets = None
21
22MessageHandler = Callable[[dict[str, Any]], Awaitable[None]]
23StateHandler = Callable[[bool, str | None], Awaitable[None] | None]
24
25
26class ConnectionRejected(RuntimeError):
27 def __init__(self, code: str, message: str):
28 super().__init__(message)
29 self.code = code
30
31
32def 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
49def dependency_available() -> bool:
50 return websockets is not None
51
52
53async 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
69async 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
101async 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
123class 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 @@
1name: hermes-t3-gateway
2label: T3 Code
3kind: platform
4version: 0.1.0
5description: >
6 Experimental outbound gateway that exposes one Hermes Agent process as a
7 named T3 Code provider instance over an authenticated WebSocket.
8author: T3 Tools
9provides_hooks:
10 - pre_tool_call
11 - post_tool_call
12optional_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
3from __future__ import annotations
4
5import uuid
6from datetime import datetime, timezone
7from typing import Any
8
9PROTOCOL_VERSION = 1
10PLUGIN_VERSION = "0.1.0"
11WEBSOCKET_PATH = "/api/hermes-gateway/ws"
12
13CAPABILITIES = {
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
24SERVER_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
38def request_id() -> str:
39 return str(uuid.uuid4())
40
41
42def item_id() -> str:
43 return str(uuid.uuid4())
44
45
46def iso_now() -> str:
47 return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
48
49
50def frame(frame_type: str, **payload: Any) -> dict[str, Any]:
51 return {
52 "type": frame_type,
53 "protocolVersion": PROTOCOL_VERSION,
54 **payload,
55 }
56
57
58def 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
75def 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
92def 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
105def 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
128def 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 @@
1from __future__ import annotations
2
3import dataclasses
4import enum
5import importlib.util
6import pathlib
7import sys
8import types
9import unittest
10
11ROOT = pathlib.Path(__file__).resolve().parents[1]
12PACKAGE = "hermes_t3_gateway_adapter_test"
13
14
15class 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
26class PlatformConfig:
27 enabled: bool = True
28 extra: dict = dataclasses.field(default_factory=dict)
29
30
31class MessageType(enum.Enum):
32 TEXT = "text"
33 COMMAND = "command"
34
35
36@dataclasses.dataclass
37class MessageEvent:
38 text: str
39 message_type: MessageType
40 source: object
41 message_id: str
42 metadata: dict
43
44
45@dataclasses.dataclass
46class SendResult:
47 success: bool
48 message_id: str | None = None
49 error: str | None = None
50
51
52@dataclasses.dataclass
53class Source:
54 platform: Platform
55 chat_id: str
56 message_id: str
57
58
59class 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
109def build_session_key(source):
110 return f"agent:main:t3:dm:{source.chat_id}"
111
112
113def 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
137def 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
153adapter_module = load_plugin_modules()
154
155
156class 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
165class 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
410if __name__ == "__main__":
411 unittest.main()
integrations/hermes-t3-gateway/tests/test_connection.py+103−0
@@ -0,0 +1,103 @@
1from __future__ import annotations
2
3import importlib.util
4import json
5import pathlib
6import sys
7import types
8import unittest
9
10ROOT = pathlib.Path(__file__).resolve().parents[1]
11PACKAGE = "hermes_t3_gateway_test"
12
13package = types.ModuleType(PACKAGE)
14package.__path__ = [str(ROOT)]
15sys.modules.setdefault(PACKAGE, package)
16
17for 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
26connection = sys.modules[f"{PACKAGE}.connection"]
27
28
29class 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
46class 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
102if __name__ == "__main__":
103 unittest.main()
integrations/hermes-t3-gateway/tests/test_protocol.py+63−0
@@ -0,0 +1,63 @@
1from __future__ import annotations
2
3import importlib.util
4import pathlib
5import unittest
6
7ROOT = pathlib.Path(__file__).resolve().parents[1]
8SPEC = importlib.util.spec_from_file_location(
9 "t3_gateway_protocol", ROOT / "protocol.py"
10)
11assert SPEC and SPEC.loader
12protocol = importlib.util.module_from_spec(SPEC)
13SPEC.loader.exec_module(protocol)
14
15
16class 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
62if __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
315 key: ({ environmentId }) => environmentId,315 key: ({ environmentId }) => environmentId,
316 },316 },
317 }),317 }),
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 }),
318 updateProvider: createEnvironmentRpcCommand(runtime, {334 updateProvider: createEnvironmentRpcCommand(runtime, {
319 label: "environment-data:server:update-provider",335 label: "environment-data:server:update-provider",
320 tag: WS_METHODS.serverUpdateProvider,336 tag: WS_METHODS.serverUpdateProvider,
packages/contracts/src/hermesGateway.test.ts+414−0
@@ -0,0 +1,414 @@
1import * as Schema from "effect/Schema";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 DEFAULT_HERMES_MODEL,
6 DEFAULT_MODEL_BY_PROVIDER,
7 HERMES_DRIVER_KIND,
8 PROVIDER_DISPLAY_NAMES,
9} from "./model.ts";
10import {
11 HermesGatewayCapabilities,
12 HermesGatewayConnectionHello,
13 HermesGatewayCreateEnrollmentInput,
14 HermesGatewayInstanceStatus,
15 HermesGatewayPluginToT3Message,
16 HermesGatewayResumeCursor,
17 HermesGatewayT3ToPluginMessage,
18} from "./hermesGateway.ts";
19import { WS_METHODS } from "./rpc.ts";
20import { DEFAULT_SERVER_SETTINGS, HermesSettings } from "./settings.ts";
21
22const decodeCreateEnrollment = Schema.decodeUnknownSync(HermesGatewayCreateEnrollmentInput);
23const decodeCapabilities = Schema.decodeUnknownSync(HermesGatewayCapabilities);
24const decodeInstanceStatus = Schema.decodeUnknownSync(HermesGatewayInstanceStatus);
25const decodeHello = Schema.decodeUnknownSync(HermesGatewayConnectionHello);
26const decodeResumeCursor = Schema.decodeUnknownSync(HermesGatewayResumeCursor);
27const decodeT3Message = Schema.decodeUnknownSync(HermesGatewayT3ToPluginMessage);
28const decodePluginMessage = Schema.decodeUnknownSync(HermesGatewayPluginToT3Message);
29const decodeHermesSettings = Schema.decodeUnknownSync(HermesSettings);
30
31describe("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
97describe("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
186describe("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
282describe("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
393describe("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 */
10import * as Schema from "effect/Schema";
11
12import {
13 IsoDateTime,
14 NonNegativeInt,
15 PositiveInt,
16 ThreadId,
17 TrimmedNonEmptyString,
18 TurnId,
19} from "./baseSchemas.ts";
20import { ProviderApprovalDecision, ProviderUserInputAnswers } from "./orchestration.ts";
21import { ProviderInstanceId } from "./providerInstance.ts";
22import { CanonicalItemType, CanonicalRequestType, UserInputQuestion } from "./providerRuntime.ts";
23
24export const HERMES_GATEWAY_PROTOCOL_VERSION = 1 as const;
25
26export const HermesGatewayProtocolVersion = Schema.Literal(HERMES_GATEWAY_PROTOCOL_VERSION);
27export type HermesGatewayProtocolVersion = typeof HermesGatewayProtocolVersion.Type;
28
29export const HermesGatewayRequestId = TrimmedNonEmptyString.pipe(
30 Schema.brand("HermesGatewayRequestId"),
31);
32export 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 */
38export const HermesGatewaySessionId = TrimmedNonEmptyString.pipe(
39 Schema.brand("HermesGatewaySessionId"),
40);
41export type HermesGatewaySessionId = typeof HermesGatewaySessionId.Type;
42
43export const HermesGatewayResumeCursor = Schema.Struct({
44 protocolVersion: HermesGatewayProtocolVersion,
45 sessionId: HermesGatewaySessionId,
46});
47export type HermesGatewayResumeCursor = typeof HermesGatewayResumeCursor.Type;
48
49export const HermesGatewayItemId = TrimmedNonEmptyString.pipe(Schema.brand("HermesGatewayItemId"));
50export type HermesGatewayItemId = typeof HermesGatewayItemId.Type;
51
52export const HermesGatewayEnrollmentToken = TrimmedNonEmptyString.pipe(
53 Schema.brand("HermesGatewayEnrollmentToken"),
54);
55export type HermesGatewayEnrollmentToken = typeof HermesGatewayEnrollmentToken.Type;
56
57export const HermesGatewayCredential = TrimmedNonEmptyString.pipe(
58 Schema.brand("HermesGatewayCredential"),
59);
60export type HermesGatewayCredential = typeof HermesGatewayCredential.Type;
61
62export const HermesGatewayNickname = TrimmedNonEmptyString.check(Schema.isMaxLength(64));
63export 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 */
69export const HermesGatewayConnectorUrl = TrimmedNonEmptyString.check(
70 Schema.isMaxLength(2_048),
71 Schema.isPattern(/^(?:https?|wss?):\/\/\S+$/i),
72);
73export type HermesGatewayConnectorUrl = typeof HermesGatewayConnectorUrl.Type;
74
75export 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});
83export 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 */
93export 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});
101export type HermesGatewayHelloCapabilities = typeof HermesGatewayHelloCapabilities.Type;
102
103export const HermesGatewayConnectionState = Schema.Literals([
104 "offline",
105 "connecting",
106 "connected",
107 "upgrade-required",
108 "revoked",
109]);
110export 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 */
118export 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});
130export type HermesGatewayInstanceStatus = typeof HermesGatewayInstanceStatus.Type;
131
132export const HermesGatewayCreateEnrollmentInput = Schema.Struct({
133 instanceId: ProviderInstanceId,
134 nickname: HermesGatewayNickname,
135 connectorUrl: HermesGatewayConnectorUrl,
136});
137export 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 */
144export const HermesGatewayEnrollmentResult = Schema.Struct({
145 instanceId: ProviderInstanceId,
146 expiresAt: IsoDateTime,
147 connectorUrl: HermesGatewayConnectorUrl,
148 command: TrimmedNonEmptyString,
149 oneTimeToken: HermesGatewayEnrollmentToken,
150});
151export type HermesGatewayEnrollmentResult = typeof HermesGatewayEnrollmentResult.Type;
152
153export const HermesGatewayListInstancesResult = Schema.Array(HermesGatewayInstanceStatus);
154export type HermesGatewayListInstancesResult = typeof HermesGatewayListInstancesResult.Type;
155
156export const HermesGatewayGetInstanceStatusInput = Schema.Struct({
157 instanceId: ProviderInstanceId,
158});
159export type HermesGatewayGetInstanceStatusInput = typeof HermesGatewayGetInstanceStatusInput.Type;
160
161export const HermesGatewayRenameInstanceInput = Schema.Struct({
162 instanceId: ProviderInstanceId,
163 nickname: HermesGatewayNickname,
164});
165export type HermesGatewayRenameInstanceInput = typeof HermesGatewayRenameInstanceInput.Type;
166
167export const HermesGatewayRevokeInstanceInput = Schema.Struct({
168 instanceId: ProviderInstanceId,
169});
170export type HermesGatewayRevokeInstanceInput = typeof HermesGatewayRevokeInstanceInput.Type;
171
172export const HermesGatewayRevokeInstanceResult = HermesGatewayInstanceStatus;
173export type HermesGatewayRevokeInstanceResult = typeof HermesGatewayRevokeInstanceResult.Type;
174
175export const HermesGatewayManagementOperation = Schema.Literals([
176 "create-enrollment",
177 "get-status",
178 "list-instances",
179 "revoke-instance",
180]);
181export type HermesGatewayManagementOperation = typeof HermesGatewayManagementOperation.Type;
182
183export 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]);
191export type HermesGatewayManagementErrorCode = typeof HermesGatewayManagementErrorCode.Type;
192
193export 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
203const HermesGatewayEnrollmentAuthentication = Schema.Struct({
204 type: Schema.Literal("enrollment-token"),
205 token: HermesGatewayEnrollmentToken,
206});
207export type HermesGatewayEnrollmentAuthentication =
208 typeof HermesGatewayEnrollmentAuthentication.Type;
209
210const HermesGatewayCredentialAuthentication = Schema.Struct({
211 type: Schema.Literal("instance-credential"),
212 instanceId: ProviderInstanceId,
213 credential: HermesGatewayCredential,
214});
215export type HermesGatewayCredentialAuthentication =
216 typeof HermesGatewayCredentialAuthentication.Type;
217
218export const HermesGatewayAuthentication = Schema.Union([
219 HermesGatewayEnrollmentAuthentication,
220 HermesGatewayCredentialAuthentication,
221]);
222export 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 */
229export 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});
238export type HermesGatewayConnectionHello = typeof HermesGatewayConnectionHello.Type;
239
240export 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});
248export type HermesGatewayConnectionAccepted = typeof HermesGatewayConnectionAccepted.Type;
249
250export const HermesGatewayConnectionRejectionCode = Schema.Literals([
251 "invalid-authentication",
252 "enrollment-expired",
253 "instance-revoked",
254 "version-incompatible",
255 "internal-error",
256]);
257export type HermesGatewayConnectionRejectionCode = typeof HermesGatewayConnectionRejectionCode.Type;
258
259export const HermesGatewayConnectionRejected = Schema.Struct({
260 type: Schema.Literal("connection.rejected"),
261 requestId: HermesGatewayRequestId,
262 code: HermesGatewayConnectionRejectionCode,
263 message: TrimmedNonEmptyString,
264 expectedProtocolVersion: HermesGatewayProtocolVersion,
265});
266export type HermesGatewayConnectionRejected = typeof HermesGatewayConnectionRejected.Type;
267
268export const HermesGatewayConnectionStatus = Schema.Struct({
269 type: Schema.Literal("connection.status"),
270 protocolVersion: HermesGatewayProtocolVersion,
271 activeSessionCount: NonNegativeInt,
272});
273export type HermesGatewayConnectionStatus = typeof HermesGatewayConnectionStatus.Type;
274
275const HermesGatewaySessionContext = Schema.Struct({
276 threadId: ThreadId,
277 sessionId: HermesGatewaySessionId,
278});
279
280const HermesGatewayTurnContext = Schema.Struct({
281 ...HermesGatewaySessionContext.fields,
282 turnId: TurnId,
283});
284
285const HermesGatewayTurnText = Schema.String.check(
286 Schema.isMinLength(1),
287 Schema.isMaxLength(120_000),
288);
289
290export 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});
297export type HermesGatewaySessionEnsure = typeof HermesGatewaySessionEnsure.Type;
298
299export const HermesGatewayTurnStart = Schema.Struct({
300 type: Schema.Literal("turn.start"),
301 protocolVersion: HermesGatewayProtocolVersion,
302 requestId: HermesGatewayRequestId,
303 ...HermesGatewayTurnContext.fields,
304 text: HermesGatewayTurnText,
305});
306export type HermesGatewayTurnStart = typeof HermesGatewayTurnStart.Type;
307
308export const HermesGatewayTurnSteer = Schema.Struct({
309 type: Schema.Literal("turn.steer"),
310 protocolVersion: HermesGatewayProtocolVersion,
311 requestId: HermesGatewayRequestId,
312 ...HermesGatewayTurnContext.fields,
313 text: HermesGatewayTurnText,
314});
315export type HermesGatewayTurnSteer = typeof HermesGatewayTurnSteer.Type;
316
317export const HermesGatewayTurnInterrupt = Schema.Struct({
318 type: Schema.Literal("turn.interrupt"),
319 protocolVersion: HermesGatewayProtocolVersion,
320 requestId: HermesGatewayRequestId,
321 ...HermesGatewayTurnContext.fields,
322});
323export type HermesGatewayTurnInterrupt = typeof HermesGatewayTurnInterrupt.Type;
324
325export const HermesGatewayApprovalResponse = Schema.Struct({
326 type: Schema.Literal("approval.respond"),
327 protocolVersion: HermesGatewayProtocolVersion,
328 ...HermesGatewayTurnContext.fields,
329 requestId: HermesGatewayRequestId,
330 decision: ProviderApprovalDecision,
331});
332export type HermesGatewayApprovalResponse = typeof HermesGatewayApprovalResponse.Type;
333
334export const HermesGatewayUserInputResponse = Schema.Struct({
335 type: Schema.Literal("user-input.respond"),
336 protocolVersion: HermesGatewayProtocolVersion,
337 ...HermesGatewayTurnContext.fields,
338 requestId: HermesGatewayRequestId,
339 answers: ProviderUserInputAnswers,
340});
341export type HermesGatewayUserInputResponse = typeof HermesGatewayUserInputResponse.Type;
342
343export const HermesGatewaySessionStop = Schema.Struct({
344 type: Schema.Literal("session.stop"),
345 protocolVersion: HermesGatewayProtocolVersion,
346 requestId: HermesGatewayRequestId,
347 ...HermesGatewaySessionContext.fields,
348});
349export type HermesGatewaySessionStop = typeof HermesGatewaySessionStop.Type;
350
351export const HermesGatewayPing = Schema.Struct({
352 type: Schema.Literal("ping"),
353 protocolVersion: HermesGatewayProtocolVersion,
354 requestId: HermesGatewayRequestId,
355 sentAt: IsoDateTime,
356});
357export type HermesGatewayPing = typeof HermesGatewayPing.Type;
358
359export 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});
367export type HermesGatewaySessionReady = typeof HermesGatewaySessionReady.Type;
368
369export const HermesGatewayTurnStarted = Schema.Struct({
370 type: Schema.Literal("turn.started"),
371 protocolVersion: HermesGatewayProtocolVersion,
372 requestId: HermesGatewayRequestId,
373 ...HermesGatewayTurnContext.fields,
374});
375export type HermesGatewayTurnStarted = typeof HermesGatewayTurnStarted.Type;
376
377export const HermesGatewayContentStreamKind = Schema.Literals([
378 "assistant_text",
379 "reasoning_text",
380 "reasoning_summary_text",
381 "plan_text",
382 "command_output",
383 "unknown",
384]);
385export type HermesGatewayContentStreamKind = typeof HermesGatewayContentStreamKind.Type;
386
387export 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});
396export type HermesGatewayContentDelta = typeof HermesGatewayContentDelta.Type;
397
398export const HermesGatewayItemStatus = Schema.Literals([
399 "inProgress",
400 "completed",
401 "failed",
402 "declined",
403]);
404export type HermesGatewayItemStatus = typeof HermesGatewayItemStatus.Type;
405
406const 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
417export const HermesGatewayItemStarted = Schema.Struct({
418 type: Schema.Literal("item.started"),
419 ...HermesGatewayItemLifecycleFields,
420});
421export type HermesGatewayItemStarted = typeof HermesGatewayItemStarted.Type;
422
423export const HermesGatewayItemUpdated = Schema.Struct({
424 type: Schema.Literal("item.updated"),
425 ...HermesGatewayItemLifecycleFields,
426});
427export type HermesGatewayItemUpdated = typeof HermesGatewayItemUpdated.Type;
428
429export const HermesGatewayItemCompleted = Schema.Struct({
430 type: Schema.Literal("item.completed"),
431 ...HermesGatewayItemLifecycleFields,
432});
433export type HermesGatewayItemCompleted = typeof HermesGatewayItemCompleted.Type;
434
435const HermesGatewayInteractionContext = Schema.Struct({
436 ...HermesGatewayTurnContext.fields,
437 requestId: HermesGatewayRequestId,
438});
439
440export 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});
448export type HermesGatewayRequestOpened = typeof HermesGatewayRequestOpened.Type;
449
450export 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});
458export type HermesGatewayRequestResolved = typeof HermesGatewayRequestResolved.Type;
459
460export const HermesGatewayUserInputRequested = Schema.Struct({
461 type: Schema.Literal("user-input.requested"),
462 protocolVersion: HermesGatewayProtocolVersion,
463 ...HermesGatewayInteractionContext.fields,
464 questions: Schema.Array(UserInputQuestion),
465});
466export type HermesGatewayUserInputRequested = typeof HermesGatewayUserInputRequested.Type;
467
468export const HermesGatewayUserInputResolved = Schema.Struct({
469 type: Schema.Literal("user-input.resolved"),
470 protocolVersion: HermesGatewayProtocolVersion,
471 ...HermesGatewayInteractionContext.fields,
472 answers: ProviderUserInputAnswers,
473});
474export type HermesGatewayUserInputResolved = typeof HermesGatewayUserInputResolved.Type;
475
476export const HermesGatewayTurnCompletionState = Schema.Literals(["completed", "failed"]);
477export type HermesGatewayTurnCompletionState = typeof HermesGatewayTurnCompletionState.Type;
478
479export 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});
488export type HermesGatewayTurnCompleted = typeof HermesGatewayTurnCompleted.Type;
489
490export const HermesGatewayTurnAborted = Schema.Struct({
491 type: Schema.Literal("turn.aborted"),
492 protocolVersion: HermesGatewayProtocolVersion,
493 ...HermesGatewayTurnContext.fields,
494 reason: TrimmedNonEmptyString,
495});
496export type HermesGatewayTurnAborted = typeof HermesGatewayTurnAborted.Type;
497
498export 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});
505export type HermesGatewaySessionExited = typeof HermesGatewaySessionExited.Type;
506
507export const HermesGatewayPong = Schema.Struct({
508 type: Schema.Literal("pong"),
509 protocolVersion: HermesGatewayProtocolVersion,
510 requestId: HermesGatewayRequestId,
511 sentAt: IsoDateTime,
512});
513export type HermesGatewayPong = typeof HermesGatewayPong.Type;
514
515export 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]);
523export type HermesGatewayProtocolErrorCode = typeof HermesGatewayProtocolErrorCode.Type;
524
525export 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});
533export type HermesGatewayProtocolError = typeof HermesGatewayProtocolError.Type;
534
535export 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]);
547export type HermesGatewayT3ToPluginMessage = typeof HermesGatewayT3ToPluginMessage.Type;
548
549export 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]);
568export type HermesGatewayPluginToT3Message = typeof HermesGatewayPluginToT3Message.Type;
569
570export const HermesGatewayWireMessage = Schema.Union([
571 HermesGatewayT3ToPluginMessage,
572 HermesGatewayPluginToT3Message,
573]);
574export type HermesGatewayWireMessage = typeof HermesGatewayWireMessage.Type;
packages/contracts/src/index.ts+1−0
@@ -27,3 +27,4 @@ export * from "./review.ts"; · 26 unmodified lines
27export * from "./preview.ts";27export * from "./preview.ts";
28export * from "./previewAutomation.ts";28export * from "./previewAutomation.ts";
29export * from "./rpc.ts";29export * from "./rpc.ts";
30export * from "./hermesGateway.ts";
packages/contracts/src/model.ts+4−0
@@ -132,8 +132,10 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); · 131 unmodified lines
132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");132const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor");
133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");133const GROK_DRIVER_KIND = ProviderDriverKind.make("grok");
134const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");134const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode");
135export const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes");
135 136
136export const DEFAULT_MODEL = "gpt-5.6-sol";137export const DEFAULT_MODEL = "gpt-5.6-sol";
138export const DEFAULT_HERMES_MODEL = "hermes";
137 139
138/**140/**
139 * Codex default-model preference, most preferred first. The provider snapshot141 * 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
152 [CURSOR_DRIVER_KIND]: "auto",154 [CURSOR_DRIVER_KIND]: "auto",
153 [GROK_DRIVER_KIND]: "grok-build",155 [GROK_DRIVER_KIND]: "grok-build",
154 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",156 [OPENCODE_DRIVER_KIND]: "openai/gpt-5",
157 [HERMES_DRIVER_KIND]: DEFAULT_HERMES_MODEL,
155};158};
156 159
157/** Per-provider text generation model defaults. */160/** Per-provider text generation model defaults. */
@@ -218,4 +221,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial<Record<ProviderDriverKind, string>> · 60 unmodified lines
218 [CURSOR_DRIVER_KIND]: "Cursor",221 [CURSOR_DRIVER_KIND]: "Cursor",
219 [GROK_DRIVER_KIND]: "Grok",222 [GROK_DRIVER_KIND]: "Grok",
220 [OPENCODE_DRIVER_KIND]: "OpenCode",223 [OPENCODE_DRIVER_KIND]: "OpenCode",
224 [HERMES_DRIVER_KIND]: "Hermes",
221};225};
packages/contracts/src/rpc.ts+50−0
@@ -59,6 +59,16 @@ import { · 58 unmodified lines
59 OrchestrationRpcSchemas,59 OrchestrationRpcSchemas,
60} from "./orchestration.ts";60} from "./orchestration.ts";
61import { ProviderInstanceId } from "./providerInstance.ts";61import { ProviderInstanceId } from "./providerInstance.ts";
62import {
63 HermesGatewayCreateEnrollmentInput,
64 HermesGatewayEnrollmentResult,
65 HermesGatewayGetInstanceStatusInput,
66 HermesGatewayInstanceStatus,
67 HermesGatewayListInstancesResult,
68 HermesGatewayManagementError,
69 HermesGatewayRevokeInstanceInput,
70 HermesGatewayRevokeInstanceResult,
71} from "./hermesGateway.ts";
62import {72import {
63 RelayClientInstallFailedError,73 RelayClientInstallFailedError,
64 RelayClientInstallProgressEventSchema,74 RelayClientInstallProgressEventSchema,
@@ -219,6 +229,12 @@ export const WS_METHODS = { · 154 unmodified lines
219 serverGetProcessResourceHistory: "server.getProcessResourceHistory",229 serverGetProcessResourceHistory: "server.getProcessResourceHistory",
220 serverSignalProcess: "server.signalProcess",230 serverSignalProcess: "server.signalProcess",
221 231
232 // Hermes gateway instance management
233 hermesGatewayCreateEnrollment: "hermesGateway.createEnrollment",
234 hermesGatewayGetInstanceStatus: "hermesGateway.getInstanceStatus",
235 hermesGatewayListInstances: "hermesGateway.listInstances",
236 hermesGatewayRevokeInstance: "hermesGateway.revokeInstance",
237
222 // Cloud environment methods238 // Cloud environment methods
223 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",239 cloudGetRelayClientStatus: "cloud.getRelayClientStatus",
224 cloudInstallRelayClient: "cloud.installRelayClient",240 cloudInstallRelayClient: "cloud.installRelayClient",
@@ -334,6 +350,36 @@ export const WsServerSignalProcessRpc = Rpc.make(WS_METHODS.serverSignalProcess, · 109 unmodified lines
334 error: EnvironmentAuthorizationError,350 error: EnvironmentAuthorizationError,
335});351});
336 352
353export const WsHermesGatewayCreateEnrollmentRpc = Rpc.make(
354 WS_METHODS.hermesGatewayCreateEnrollment,
355 {
356 payload: HermesGatewayCreateEnrollmentInput,
357 success: HermesGatewayEnrollmentResult,
358 error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
359 },
360);
361
362export const WsHermesGatewayGetInstanceStatusRpc = Rpc.make(
363 WS_METHODS.hermesGatewayGetInstanceStatus,
364 {
365 payload: HermesGatewayGetInstanceStatusInput,
366 success: HermesGatewayInstanceStatus,
367 error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
368 },
369);
370
371export const WsHermesGatewayListInstancesRpc = Rpc.make(WS_METHODS.hermesGatewayListInstances, {
372 payload: Schema.Struct({}),
373 success: HermesGatewayListInstancesResult,
374 error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
375});
376
377export const WsHermesGatewayRevokeInstanceRpc = Rpc.make(WS_METHODS.hermesGatewayRevokeInstance, {
378 payload: HermesGatewayRevokeInstanceInput,
379 success: HermesGatewayRevokeInstanceResult,
380 error: Schema.Union([HermesGatewayManagementError, EnvironmentAuthorizationError]),
381});
382
337export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, {383export const WsCloudGetRelayClientStatusRpc = Rpc.make(WS_METHODS.cloudGetRelayClientStatus, {
338 payload: Schema.Struct({}),384 payload: Schema.Struct({}),
339 success: RelayClientStatusSchema,385 success: RelayClientStatusSchema,
@@ -713,6 +759,10 @@ export const WsRpcGroup = RpcGroup.make( · 373 unmodified lines
713 WsServerGetProcessDiagnosticsRpc,759 WsServerGetProcessDiagnosticsRpc,
714 WsServerGetProcessResourceHistoryRpc,760 WsServerGetProcessResourceHistoryRpc,
715 WsServerSignalProcessRpc,761 WsServerSignalProcessRpc,
762 WsHermesGatewayCreateEnrollmentRpc,
763 WsHermesGatewayGetInstanceStatusRpc,
764 WsHermesGatewayListInstancesRpc,
765 WsHermesGatewayRevokeInstanceRpc,
716 WsCloudGetRelayClientStatusRpc,766 WsCloudGetRelayClientStatusRpc,
717 WsCloudInstallRelayClientRpc,767 WsCloudInstallRelayClientRpc,
718 WsSourceControlLookupRepositoryRpc,768 WsSourceControlLookupRepositoryRpc,
packages/contracts/src/settings.ts+19−0
@@ -389,6 +389,19 @@ export const OpenCodeSettings = makeProviderSettingsSchema( · 388 unmodified lines
389);389);
390export type OpenCodeSettings = typeof OpenCodeSettings.Type;390export type OpenCodeSettings = typeof OpenCodeSettings.Type;
391 391
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 */
397export const HermesSettings = makeProviderSettingsSchema({
398 enabled: Schema.Boolean.pipe(
399 Schema.withDecodingDefault(Effect.succeed(true)),
400 Schema.annotateKey({ providerSettingsForm: { hidden: true } }),
401 ),
402});
403export type HermesSettings = typeof HermesSettings.Type;
404
392export const ObservabilitySettings = Schema.Struct({405export const ObservabilitySettings = Schema.Struct({
393 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),406 otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
394 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),407 otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))),
@@ -433,6 +446,7 @@ export const ServerSettings = Schema.Struct({ · 38 unmodified lines
433 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),446 cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
434 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),447 grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
435 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),448 opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
449 hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))),
436 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),450 }).pipe(Schema.withDecodingDefault(Effect.succeed({}))),
437 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values451 // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values
438 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob452 // are `ProviderInstanceConfig` envelopes. The driver-specific config blob
@@ -536,6 +550,10 @@ const OpenCodeSettingsPatch = Schema.Struct({ · 97 unmodified lines
536 customModels: Schema.optionalKey(Schema.Array(Schema.String)),550 customModels: Schema.optionalKey(Schema.Array(Schema.String)),
537});551});
538 552
553const HermesSettingsPatch = Schema.Struct({
554 enabled: Schema.optionalKey(Schema.Boolean),
555});
556
539export const ServerSettingsPatch = Schema.Struct({557export const ServerSettingsPatch = Schema.Struct({
540 // Server settings558 // Server settings
541 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),559 enableAssistantStreaming: Schema.optionalKey(Schema.Boolean),
@@ -558,6 +576,7 @@ export const ServerSettingsPatch = Schema.Struct({ · 16 unmodified lines
558 cursor: Schema.optionalKey(CursorSettingsPatch),576 cursor: Schema.optionalKey(CursorSettingsPatch),
559 grok: Schema.optionalKey(GrokSettingsPatch),577 grok: Schema.optionalKey(GrokSettingsPatch),
560 opencode: Schema.optionalKey(OpenCodeSettingsPatch),578 opencode: Schema.optionalKey(OpenCodeSettingsPatch),
579 hermes: Schema.optionalKey(HermesSettingsPatch),
561 }),580 }),
562 ),581 ),
563 // Whole-map replacement for the new instance config. Patching individual582 // Whole-map replacement for the new instance config. Patching individual